relaycheck 0.1.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.
- relaycheck/__init__.py +14 -0
- relaycheck/cli.py +400 -0
- relaycheck/client.py +505 -0
- relaycheck/families.py +62 -0
- relaycheck/models.py +256 -0
- relaycheck/probes/__init__.py +117 -0
- relaycheck/probes/base.py +293 -0
- relaycheck/probes/billing.py +396 -0
- relaycheck/probes/context.py +543 -0
- relaycheck/probes/echo.py +328 -0
- relaycheck/probes/identity.py +480 -0
- relaycheck/probes/params.py +578 -0
- relaycheck/probes/reliability.py +265 -0
- relaycheck/probes/stream.py +478 -0
- relaycheck/probes/tokenizer.py +323 -0
- relaycheck/probes/twins.py +365 -0
- relaycheck/reporter.py +324 -0
- relaycheck/selection.py +93 -0
- relaycheck-0.1.0.dist-info/METADATA +627 -0
- relaycheck-0.1.0.dist-info/RECORD +24 -0
- relaycheck-0.1.0.dist-info/WHEEL +5 -0
- relaycheck-0.1.0.dist-info/entry_points.txt +2 -0
- relaycheck-0.1.0.dist-info/licenses/LICENSE +32 -0
- relaycheck-0.1.0.dist-info/top_level.txt +1 -0
relaycheck/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""relaycheck — detect model substitution and billing fraud in LLM API relays.
|
|
2
|
+
|
|
3
|
+
The tool answers three questions a relay user cannot otherwise answer:
|
|
4
|
+
|
|
5
|
+
1. Is the model I am calling actually the model I asked for?
|
|
6
|
+
2. Am I paying for tokens I cannot see?
|
|
7
|
+
3. Is the billing basis what the merchant says it is?
|
|
8
|
+
|
|
9
|
+
Everything is client-side, read-only, and evidence-based: every finding carries
|
|
10
|
+
the raw response fragments that produced it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
__all__ = ["__version__"]
|
relaycheck/cli.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""relaycheck command-line interface.
|
|
2
|
+
|
|
3
|
+
Everything the tool does is read-only: it sends chat completions and reads
|
|
4
|
+
public/self-scoped panel endpoints. It never creates accounts, never redeems
|
|
5
|
+
anything, and never modifies remote state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import traceback
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Sequence
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .client import DEFAULT_BROWSER_UA, RelayClient, RelayError
|
|
22
|
+
from .models import SEVERITY_ORDER, Severity
|
|
23
|
+
from .probes import ProbeContext, probe_names, run_probes, select_probes
|
|
24
|
+
from .reporter import Report, render_text, utc_now_iso
|
|
25
|
+
from .selection import describe_selection, select_models
|
|
26
|
+
|
|
27
|
+
EXIT_OK = 0
|
|
28
|
+
EXIT_FINDINGS = 1
|
|
29
|
+
EXIT_ERROR = 2
|
|
30
|
+
|
|
31
|
+
_EPILOG = """\
|
|
32
|
+
使用示例
|
|
33
|
+
--------
|
|
34
|
+
最简用法(自动发现模型,跑默认探针):
|
|
35
|
+
|
|
36
|
+
relaycheck --base-url https://api.example.com --api-key sk-xxxx
|
|
37
|
+
|
|
38
|
+
指定要对比的模型(双胞胎检测最有价值,尽量选声称来自不同厂商的):
|
|
39
|
+
|
|
40
|
+
relaycheck -u https://api.example.com -k sk-xxxx \\
|
|
41
|
+
--models "gpt-4o,claude-3-5-sonnet,deepseek-chat,gemini-1.5-pro"
|
|
42
|
+
|
|
43
|
+
全量探针(含参数透传与流式完整性,请求数更多):
|
|
44
|
+
|
|
45
|
+
relaycheck -u https://api.example.com -k sk-xxxx --probes all
|
|
46
|
+
|
|
47
|
+
只做 tokenizer 指纹(最便宜、最硬的证据):
|
|
48
|
+
|
|
49
|
+
relaycheck -u https://api.example.com -k sk-xxxx --probes tokenizer,twins
|
|
50
|
+
|
|
51
|
+
检查长输入是否被悄悄截断(请求数少但每次都很贵,按需使用):
|
|
52
|
+
|
|
53
|
+
relaycheck -u https://api.example.com -k sk-xxxx --probes context
|
|
54
|
+
|
|
55
|
+
输出
|
|
56
|
+
----
|
|
57
|
+
每次运行都会在 --out-dir 下产出:
|
|
58
|
+
report.md 可读报告(可直接发给商家或作为投诉附件)
|
|
59
|
+
report.json 完整原始数据,任何结论都能自行复核
|
|
60
|
+
|
|
61
|
+
退出码
|
|
62
|
+
------
|
|
63
|
+
0 未发现达到 --fail-on 的问题
|
|
64
|
+
1 发现达到 --fail-on 的问题(默认 high)
|
|
65
|
+
2 运行失败(无法连接、密钥无效等)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
70
|
+
p = argparse.ArgumentParser(
|
|
71
|
+
prog="relaycheck",
|
|
72
|
+
description="检测 LLM API 中转站是否掉包模型、是否虚报计费。",
|
|
73
|
+
epilog=_EPILOG,
|
|
74
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
75
|
+
)
|
|
76
|
+
p.add_argument("-u", "--base-url", help="中转站地址,如 https://api.example.com(可带 /v1)")
|
|
77
|
+
p.add_argument("-k", "--api-key", help="API Key(也可用环境变量 RELAYCHECK_API_KEY)")
|
|
78
|
+
p.add_argument(
|
|
79
|
+
"-m", "--models",
|
|
80
|
+
help="要审计的模型名,逗号分隔。缺省时自动从 /v1/models 里挑(按厂商多样性优先)",
|
|
81
|
+
)
|
|
82
|
+
p.add_argument(
|
|
83
|
+
"--max-models", type=int, default=6,
|
|
84
|
+
help="最多审计多少个模型(默认 6,控制请求数与花费)",
|
|
85
|
+
)
|
|
86
|
+
p.add_argument(
|
|
87
|
+
"--probes",
|
|
88
|
+
help=f"要跑的探针,逗号分隔,或 all。可选:{', '.join(probe_names())}",
|
|
89
|
+
)
|
|
90
|
+
p.add_argument("--out-dir", help="输出目录(默认 relaycheck-<host>-<时间戳>)")
|
|
91
|
+
p.add_argument("--timeout", type=float, default=60.0, help="单次请求超时秒数(默认 60)")
|
|
92
|
+
p.add_argument(
|
|
93
|
+
"--delay", type=float, default=0.4,
|
|
94
|
+
help="两次请求之间的最小间隔秒数(默认 0.4,避免触发限流)",
|
|
95
|
+
)
|
|
96
|
+
p.add_argument("--max-retries", type=int, default=3, help="失败重试次数(默认 3)")
|
|
97
|
+
p.add_argument(
|
|
98
|
+
"--budget", type=float, default=240.0, metavar="SECONDS",
|
|
99
|
+
help=(
|
|
100
|
+
"每个探针的墙钟预算秒数(默认 240)。中转站很慢时探针会在预算用尽后"
|
|
101
|
+
"提前结束,并在报告里如实标注为「不完整」而不是「通过」。"
|
|
102
|
+
"设为 0 表示不限制。"
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
p.add_argument(
|
|
106
|
+
"--reliability-samples", type=int, default=8, metavar="N",
|
|
107
|
+
help="可用性探针的采样次数(默认 8)",
|
|
108
|
+
)
|
|
109
|
+
p.add_argument(
|
|
110
|
+
"--context-sizes", metavar="A,B,C",
|
|
111
|
+
help=(
|
|
112
|
+
"上下文探针的测试深度(token 数,逗号分隔,默认 2000,8000,32000)。"
|
|
113
|
+
"探针从最浅一档开始逐级加深,一旦发现截断就停止。"
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
p.add_argument(
|
|
117
|
+
"--context-max-models", type=int, default=1, metavar="N",
|
|
118
|
+
help="上下文探针最多测几个模型(默认 1;该探针每档深度都要发一次长请求,很贵)",
|
|
119
|
+
)
|
|
120
|
+
p.add_argument("--user-agent", default=DEFAULT_BROWSER_UA, help="自定义 User-Agent")
|
|
121
|
+
p.add_argument(
|
|
122
|
+
"--header", action="append", default=[], metavar="K:V",
|
|
123
|
+
help="附加请求头,可重复,如 --header 'X-Token: abc'",
|
|
124
|
+
)
|
|
125
|
+
p.add_argument("--insecure", action="store_true", help="跳过 TLS 证书校验(不推荐)")
|
|
126
|
+
p.add_argument(
|
|
127
|
+
"--fail-on", choices=["none", "medium", "high", "critical", "low"], default="high",
|
|
128
|
+
help="达到该级别即返回退出码 1(默认 high)",
|
|
129
|
+
)
|
|
130
|
+
p.add_argument("--list-probes", action="store_true", help="列出所有探针后退出")
|
|
131
|
+
p.add_argument("--list-models", action="store_true", help="只列出可用模型后退出")
|
|
132
|
+
p.add_argument("-v", "--verbose", action="store_true", help="打印每个请求的细节")
|
|
133
|
+
p.add_argument("--version", action="version", version=f"relaycheck {__version__}")
|
|
134
|
+
return p
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _make_output_safe() -> None:
|
|
138
|
+
"""Stop a printing problem from being able to kill an audit.
|
|
139
|
+
|
|
140
|
+
Windows consoles usually run a legacy code page (cp936, cp1252, ...). A
|
|
141
|
+
progress line containing a glyph that code page lacks used to raise
|
|
142
|
+
``UnicodeEncodeError`` **inside the probe loop**, aborting the whole run
|
|
143
|
+
before any report was written. Worse, the traceback's exit status is 1 —
|
|
144
|
+
the very code this tool reserves for "findings at or above --fail-on", so a
|
|
145
|
+
cosmetic bug became indistinguishable from a verdict.
|
|
146
|
+
|
|
147
|
+
Only ``errors`` is relaxed, never ``encoding``: keeping the console's own
|
|
148
|
+
code page means Chinese output stays readable in cmd.exe, and anything the
|
|
149
|
+
code page genuinely cannot represent degrades to ``?`` instead of taking
|
|
150
|
+
the audit down with it.
|
|
151
|
+
"""
|
|
152
|
+
for stream in (sys.stdout, sys.stderr):
|
|
153
|
+
try:
|
|
154
|
+
stream.reconfigure(errors="replace")
|
|
155
|
+
except (AttributeError, OSError, ValueError): # pragma: no cover - exotic streams
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
160
|
+
"""Entry point. Every failure mode must land on a distinct exit code."""
|
|
161
|
+
_make_output_safe()
|
|
162
|
+
try:
|
|
163
|
+
return _run(argv)
|
|
164
|
+
except KeyboardInterrupt:
|
|
165
|
+
print("\n已中断。", file=sys.stderr)
|
|
166
|
+
return EXIT_ERROR
|
|
167
|
+
except Exception as exc: # noqa: BLE001 - the exit code is the whole point
|
|
168
|
+
traceback.print_exc()
|
|
169
|
+
print(
|
|
170
|
+
f"错误:审计未能完成({type(exc).__name__}: {exc})。\n"
|
|
171
|
+
" 没有生成报告 —— 这不代表发现了问题,也不代表中转站没问题,"
|
|
172
|
+
"只代表这次没查成。",
|
|
173
|
+
file=sys.stderr,
|
|
174
|
+
)
|
|
175
|
+
return EXIT_ERROR
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run(argv: Sequence[str] | None = None) -> int:
|
|
179
|
+
args = build_parser().parse_args(argv)
|
|
180
|
+
|
|
181
|
+
if args.list_probes:
|
|
182
|
+
for probe in select_probes(["all"]):
|
|
183
|
+
print(f"{probe.name:<28} {probe.description}")
|
|
184
|
+
return EXIT_OK
|
|
185
|
+
|
|
186
|
+
base_url = args.base_url or os.environ.get("RELAYCHECK_BASE_URL") or os.environ.get(
|
|
187
|
+
"OPENAI_BASE_URL"
|
|
188
|
+
)
|
|
189
|
+
api_key = args.api_key or os.environ.get("RELAYCHECK_API_KEY") or os.environ.get(
|
|
190
|
+
"OPENAI_API_KEY"
|
|
191
|
+
)
|
|
192
|
+
if not base_url:
|
|
193
|
+
print("错误:缺少 --base-url(或设置 RELAYCHECK_BASE_URL)", file=sys.stderr)
|
|
194
|
+
return EXIT_ERROR
|
|
195
|
+
if not api_key:
|
|
196
|
+
print("错误:缺少 --api-key(或设置 RELAYCHECK_API_KEY)", file=sys.stderr)
|
|
197
|
+
return EXIT_ERROR
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
probes = select_probes(_split(args.probes))
|
|
201
|
+
except KeyError as exc:
|
|
202
|
+
print(f"错误:{exc}", file=sys.stderr)
|
|
203
|
+
return EXIT_ERROR
|
|
204
|
+
|
|
205
|
+
extra_headers = _parse_headers(args.header)
|
|
206
|
+
client = RelayClient(
|
|
207
|
+
base_url,
|
|
208
|
+
api_key,
|
|
209
|
+
timeout=args.timeout,
|
|
210
|
+
max_retries=args.max_retries,
|
|
211
|
+
user_agent=args.user_agent,
|
|
212
|
+
extra_headers=extra_headers,
|
|
213
|
+
delay_between_requests=args.delay,
|
|
214
|
+
verbose=args.verbose,
|
|
215
|
+
)
|
|
216
|
+
if args.insecure:
|
|
217
|
+
client.session.verify = False
|
|
218
|
+
import urllib3
|
|
219
|
+
|
|
220
|
+
urllib3.disable_warnings() # noqa: S101 - narrowly scoped, user opted in
|
|
221
|
+
|
|
222
|
+
print(f"relaycheck {__version__}")
|
|
223
|
+
print(f"目标: {client.base_url}")
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------- models
|
|
226
|
+
available: list[str] = []
|
|
227
|
+
notes: list[str] = []
|
|
228
|
+
try:
|
|
229
|
+
available = client.list_models()
|
|
230
|
+
except RelayError as exc:
|
|
231
|
+
notes.append(f"无法获取模型列表({exc});将只使用 --models 指定的模型")
|
|
232
|
+
print(f"警告: 无法获取 /v1/models({exc})")
|
|
233
|
+
|
|
234
|
+
if args.list_models:
|
|
235
|
+
for name in available:
|
|
236
|
+
print(name)
|
|
237
|
+
if not available:
|
|
238
|
+
print("(空)")
|
|
239
|
+
return EXIT_OK
|
|
240
|
+
|
|
241
|
+
explicit = _split(args.models)
|
|
242
|
+
if explicit:
|
|
243
|
+
unknown = [m for m in explicit if available and m not in available]
|
|
244
|
+
if unknown:
|
|
245
|
+
notes.append(f"以下模型不在 /v1/models 返回中,仍会尝试:{', '.join(unknown)}")
|
|
246
|
+
models = explicit[: max(1, args.max_models)]
|
|
247
|
+
else:
|
|
248
|
+
if not available:
|
|
249
|
+
print("错误:没有可用模型,且未指定 --models", file=sys.stderr)
|
|
250
|
+
return EXIT_ERROR
|
|
251
|
+
models = select_models(available, max(1, args.max_models))
|
|
252
|
+
|
|
253
|
+
if not models:
|
|
254
|
+
print("错误:没有可审计的模型", file=sys.stderr)
|
|
255
|
+
return EXIT_ERROR
|
|
256
|
+
|
|
257
|
+
print(f"可用模型 {len(available)} 个,受测 {len(models)} 个: {describe_selection(models)}")
|
|
258
|
+
print(f"探针: {', '.join(p.name for p in probes)}")
|
|
259
|
+
if args.max_models < len([m for m in available if m]) and not explicit:
|
|
260
|
+
notes.append(
|
|
261
|
+
f"仅审计了 {len(models)}/{len(available)} 个模型(--max-models {args.max_models})"
|
|
262
|
+
)
|
|
263
|
+
print("")
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------------ run
|
|
266
|
+
probe_options: dict[str, Any] = {
|
|
267
|
+
"probe_budget_s": (args.budget if args.budget and args.budget > 0 else float("inf")),
|
|
268
|
+
"reliability_samples": max(1, args.reliability_samples),
|
|
269
|
+
"context_sizes": args.context_sizes,
|
|
270
|
+
"context_max_models": max(1, args.context_max_models),
|
|
271
|
+
}
|
|
272
|
+
ctx = ProbeContext(
|
|
273
|
+
client=client,
|
|
274
|
+
models=models,
|
|
275
|
+
max_calls=max(40, 40 * max(1, len(models))),
|
|
276
|
+
options=probe_options,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
started = utc_now_iso()
|
|
280
|
+
t0 = time.monotonic()
|
|
281
|
+
|
|
282
|
+
def _on_probe(probe: Any) -> None:
|
|
283
|
+
print(f" → {probe.name} …", flush=True)
|
|
284
|
+
|
|
285
|
+
def _on_progress(message: str) -> None:
|
|
286
|
+
# Intra-probe heartbeat. A probe that loops over models on a relay that
|
|
287
|
+
# times out can run for minutes; without a heartbeat the audit looks
|
|
288
|
+
# hung, and the operator cannot tell "slow" from "stuck".
|
|
289
|
+
print(f" · {message}", flush=True)
|
|
290
|
+
|
|
291
|
+
def _on_result(probe: Any, res: Any) -> None:
|
|
292
|
+
worst = res.findings[0].severity.value if res.findings else "clean"
|
|
293
|
+
flag = "错误" if res.error else worst
|
|
294
|
+
extra = ""
|
|
295
|
+
if res.data.get("truncated"):
|
|
296
|
+
extra = " [预算用尽,未跑完]"
|
|
297
|
+
print(
|
|
298
|
+
f" √ {probe.name}: {flag} "
|
|
299
|
+
f"({res.requests_made} 请求 / {res.duration_s:.1f}s){extra}",
|
|
300
|
+
flush=True,
|
|
301
|
+
)
|
|
302
|
+
if probe.name == "reliability":
|
|
303
|
+
stats = res.data.get("reliability") or {}
|
|
304
|
+
if stats:
|
|
305
|
+
p50 = stats.get("p50_latency_s")
|
|
306
|
+
# ``None`` means nothing succeeded, so there is no latency to
|
|
307
|
+
# report. "n/as" would be the alternative.
|
|
308
|
+
latency = f"中位延迟 {p50:.1f}s" if p50 is not None else "无成功样本,无法给出延迟"
|
|
309
|
+
print(
|
|
310
|
+
f" 成功率 {stats.get('succeeded')}/{stats.get('samples')}"
|
|
311
|
+
f"(失败率 {stats.get('failure_rate', 0):.0%}),{latency}",
|
|
312
|
+
flush=True,
|
|
313
|
+
)
|
|
314
|
+
if stats.get("failure_rate", 0) > 0.2:
|
|
315
|
+
notes.append(
|
|
316
|
+
"中转站可用性很差(失败率 "
|
|
317
|
+
f"{stats['failure_rate']:.0%}),"
|
|
318
|
+
"其余探针的结论可能不完整;报告中已逐项标注。"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
results = run_probes(
|
|
323
|
+
probes, ctx, on_probe=_on_probe, on_result=_on_result, on_progress=_on_progress
|
|
324
|
+
)
|
|
325
|
+
except KeyboardInterrupt:
|
|
326
|
+
print("\n已中断。", file=sys.stderr)
|
|
327
|
+
return EXIT_ERROR
|
|
328
|
+
|
|
329
|
+
duration = time.monotonic() - t0
|
|
330
|
+
|
|
331
|
+
report = Report(
|
|
332
|
+
target=client.base_url,
|
|
333
|
+
models=models,
|
|
334
|
+
available_models=available,
|
|
335
|
+
probe_names=[p.name for p in probes],
|
|
336
|
+
results=results,
|
|
337
|
+
tool_version=__version__,
|
|
338
|
+
started_at=started,
|
|
339
|
+
duration_s=duration,
|
|
340
|
+
requests_made=client.request_count,
|
|
341
|
+
notes=notes,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# --------------------------------------------------------------- output
|
|
345
|
+
out_dir = Path(args.out_dir) if args.out_dir else Path(_default_out_dir(client.base_url))
|
|
346
|
+
json_path = report.write_json(out_dir / "report.json")
|
|
347
|
+
md_path = report.write_markdown(out_dir / "report.md")
|
|
348
|
+
|
|
349
|
+
print("")
|
|
350
|
+
print(render_text(report))
|
|
351
|
+
print(f"报告: {md_path}")
|
|
352
|
+
print(f"原始: {json_path}")
|
|
353
|
+
|
|
354
|
+
return _exit_code(report, args.fail_on)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# --------------------------------------------------------------------- helpers
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _split(value: str | None) -> list[str]:
|
|
361
|
+
if not value:
|
|
362
|
+
return []
|
|
363
|
+
return [item.strip() for item in re.split(r"[,\n]", value) if item.strip()]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _parse_headers(items: Sequence[str]) -> dict[str, str]:
|
|
367
|
+
headers: dict[str, str] = {}
|
|
368
|
+
for item in items:
|
|
369
|
+
if ":" not in item:
|
|
370
|
+
continue
|
|
371
|
+
key, _, value = item.partition(":")
|
|
372
|
+
headers[key.strip()] = value.strip()
|
|
373
|
+
return headers
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _default_out_dir(base_url: str) -> str:
|
|
377
|
+
host = urlparse(base_url).netloc or "relay"
|
|
378
|
+
safe = re.sub(r"[^A-Za-z0-9._-]", "_", host)
|
|
379
|
+
return f"relaycheck-{safe}-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _exit_code(report: Report, fail_on: str) -> int:
|
|
383
|
+
if fail_on == "none":
|
|
384
|
+
return EXIT_OK
|
|
385
|
+
threshold = {
|
|
386
|
+
"critical": Severity.CRITICAL,
|
|
387
|
+
"high": Severity.HIGH,
|
|
388
|
+
"medium": Severity.MEDIUM,
|
|
389
|
+
"low": Severity.LOW,
|
|
390
|
+
}[fail_on]
|
|
391
|
+
order = list(SEVERITY_ORDER)
|
|
392
|
+
limit = order.index(threshold)
|
|
393
|
+
for finding in report.all_findings:
|
|
394
|
+
if order.index(finding.severity) <= limit and finding.severity is not Severity.CLEAN:
|
|
395
|
+
return EXIT_FINDINGS
|
|
396
|
+
return EXIT_OK
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
if __name__ == "__main__": # pragma: no cover
|
|
400
|
+
raise SystemExit(main())
|