mxready 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.
- mxready/__init__.py +1 -0
- mxready/api/__init__.py +4 -0
- mxready/api/middleware.py +149 -0
- mxready/api/rules.py +10 -0
- mxready/api/scans.py +164 -0
- mxready/api/workspace.py +156 -0
- mxready/app.py +179 -0
- mxready/cli/__init__.py +1 -0
- mxready/cli/build_verification_bundle.py +45 -0
- mxready/cli/generate_public_report.py +140 -0
- mxready/cli/scan_batch.py +368 -0
- mxready/cli/scan_repository.py +212 -0
- mxready/cli/serve.py +55 -0
- mxready/config.py +117 -0
- mxready/errors.py +11 -0
- mxready/logging_setup.py +88 -0
- mxready/mcp/__init__.py +5 -0
- mxready/mcp/server.py +433 -0
- mxready/models.py +382 -0
- mxready/persistence/__init__.py +1 -0
- mxready/persistence/sqlite.py +581 -0
- mxready/py.typed +0 -0
- mxready/reporting/__init__.py +4 -0
- mxready/reporting/badge.py +40 -0
- mxready/reporting/comparison.py +33 -0
- mxready/reporting/markdown.py +157 -0
- mxready/reporting/sarif.py +94 -0
- mxready/reporting/terminal.py +75 -0
- mxready/repository/__init__.py +14 -0
- mxready/repository/git_client.py +275 -0
- mxready/repository/github_archive.py +566 -0
- mxready/repository/identity.py +140 -0
- mxready/resources/__init__.py +19 -0
- mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
- mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
- mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
- mxready/resources/frontend/index.html +17 -0
- mxready/resources/rules/v1/core.yml +634 -0
- mxready/resources/rules/v1/manifest.yml +4 -0
- mxready/resources/schemas/verification-result-v1.json +259 -0
- mxready/scanning/__init__.py +17 -0
- mxready/scanning/analyzer.py +183 -0
- mxready/scanning/facts.py +596 -0
- mxready/scanning/indexer.py +299 -0
- mxready/scanning/path_context.py +174 -0
- mxready/scanning/python_source.py +67 -0
- mxready/scanning/rule_engine.py +595 -0
- mxready/scanning/rule_loader.py +282 -0
- mxready/scanning/toolchain.py +100 -0
- mxready/services/__init__.py +3 -0
- mxready/services/scans.py +239 -0
- mxready/services/worker.py +38 -0
- mxready/verification/__init__.py +11 -0
- mxready/verification/bundle.py +137 -0
- mxready/verification/validation.py +101 -0
- mxready-1.0.0.dist-info/METADATA +526 -0
- mxready-1.0.0.dist-info/RECORD +71 -0
- mxready-1.0.0.dist-info/WHEEL +5 -0
- mxready-1.0.0.dist-info/entry_points.txt +8 -0
- mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
- mxready-1.0.0.dist-info/top_level.txt +2 -0
- mxready_runner/__init__.py +1 -0
- mxready_runner/__main__.py +3 -0
- mxready_runner/cli.py +88 -0
- mxready_runner/contracts.py +26 -0
- mxready_runner/execute.py +185 -0
- mxready_runner/inspect.py +79 -0
- mxready_runner/process.py +103 -0
- mxready_runner/py.typed +0 -0
- mxready_runner/redact.py +36 -0
- mxready_runner/schema.py +326 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import NAMESPACE_URL, uuid5
|
|
10
|
+
|
|
11
|
+
from mxready.config import Settings
|
|
12
|
+
from mxready.errors import MxReadyError
|
|
13
|
+
from mxready.models import ScanReport
|
|
14
|
+
from mxready.reporting.badge import render_badge
|
|
15
|
+
from mxready.reporting.comparison import new_blockers
|
|
16
|
+
from mxready.reporting.markdown import render_markdown
|
|
17
|
+
from mxready.reporting.sarif import render_sarif
|
|
18
|
+
from mxready.reporting.terminal import render_terminal_summary
|
|
19
|
+
from mxready.repository.identity import parse_repository_url
|
|
20
|
+
from mxready.scanning.analyzer import ScanAnalyzer
|
|
21
|
+
from mxready.scanning.rule_loader import load_rule_catalog
|
|
22
|
+
|
|
23
|
+
_COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
|
24
|
+
_SCP_LIKE_REMOTE = re.compile(
|
|
25
|
+
r"^(?:[^@/:]+@)?(?P<host>[A-Za-z0-9._-]*\.[A-Za-z0-9-]+):(?P<path>[A-Za-z0-9._~/-]+)$"
|
|
26
|
+
)
|
|
27
|
+
_SSH_URL_REMOTE = re.compile(
|
|
28
|
+
r"^ssh://(?:[^@/]+@)?(?P<host>[A-Za-z0-9._-]+)/(?P<path>[A-Za-z0-9._~/-]+)$"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
33
|
+
arguments = _build_parser().parse_args(argv)
|
|
34
|
+
try:
|
|
35
|
+
repository_root = _local_repository(arguments.local_path)
|
|
36
|
+
commit = _commit_sha(arguments.commit or _detect_commit(repository_root))
|
|
37
|
+
identity = parse_repository_url(
|
|
38
|
+
arguments.repo_url or _detect_repository_url(repository_root)
|
|
39
|
+
)
|
|
40
|
+
settings = Settings.from_env()
|
|
41
|
+
report = ScanAnalyzer(
|
|
42
|
+
load_rule_catalog(
|
|
43
|
+
settings.rules_dir,
|
|
44
|
+
extra_directory=settings.extra_rules_dir,
|
|
45
|
+
),
|
|
46
|
+
enable_prefilter=settings.prefilter_enabled,
|
|
47
|
+
).analyze(
|
|
48
|
+
repository_root,
|
|
49
|
+
repository_url=identity.clone_url,
|
|
50
|
+
commit=commit,
|
|
51
|
+
scan_id=uuid5(NAMESPACE_URL, f"{identity.clone_url}@{commit}"),
|
|
52
|
+
stage_callback=lambda status: None,
|
|
53
|
+
)
|
|
54
|
+
output_dir = Path(arguments.output)
|
|
55
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
stem = f"{identity.name}-{commit[:12]}"
|
|
57
|
+
_write_outputs(output_dir, stem, report)
|
|
58
|
+
blockers = [f for f in report.findings if f.severity.value == "blocker"]
|
|
59
|
+
if arguments.fail_on == "new-blockers":
|
|
60
|
+
if arguments.baseline is None:
|
|
61
|
+
raise ValueError("--baseline is required with --fail-on new-blockers")
|
|
62
|
+
baseline_path = Path(arguments.baseline)
|
|
63
|
+
if baseline_path.stat().st_size > 20 * 1024 * 1024:
|
|
64
|
+
raise ValueError("Baseline exceeds 20 MiB")
|
|
65
|
+
baseline = ScanReport.model_validate_json(baseline_path.read_bytes())
|
|
66
|
+
blockers = new_blockers(baseline, report)
|
|
67
|
+
print(f"New or increased blocker groups: {len(blockers)}")
|
|
68
|
+
elif arguments.baseline is not None:
|
|
69
|
+
raise ValueError("--baseline requires --fail-on new-blockers")
|
|
70
|
+
except (MxReadyError, OSError, ValueError) as error:
|
|
71
|
+
print(f"MXReady scan error: {error}", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
print(
|
|
75
|
+
_terminal_summary(
|
|
76
|
+
report, output_note=f"{stem}.json / {stem}.md / {stem}.svg / {stem}.sarif"
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return 2 if blockers else 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _terminal_summary(report: ScanReport, *, output_note: str) -> str:
|
|
83
|
+
"""按标准输出编码选取摘要语言;旧代码页无法编码中文时回退纯 ASCII。"""
|
|
84
|
+
summary = render_terminal_summary(report, output_note=output_note)
|
|
85
|
+
if _can_encode(summary):
|
|
86
|
+
return summary
|
|
87
|
+
summary = render_terminal_summary(report, output_note=output_note, english=True)
|
|
88
|
+
if _can_encode(summary):
|
|
89
|
+
return summary
|
|
90
|
+
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
|
|
91
|
+
return summary.encode(encoding, errors="backslashreplace").decode(encoding)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _can_encode(text: str) -> bool:
|
|
95
|
+
encoding = getattr(sys.stdout, "encoding", None)
|
|
96
|
+
if not encoding:
|
|
97
|
+
return True
|
|
98
|
+
try:
|
|
99
|
+
text.encode(encoding)
|
|
100
|
+
except (LookupError, UnicodeEncodeError):
|
|
101
|
+
return False
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
106
|
+
parser = argparse.ArgumentParser(
|
|
107
|
+
prog="mxready-scan",
|
|
108
|
+
description="Generate MXReady reports from a trusted local source directory.",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument("local_path", help="Local repository directory to inspect")
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--repo-url",
|
|
113
|
+
help="Canonical public GitHub/Gitee URL; defaults to the local origin remote",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--commit",
|
|
117
|
+
help="Resolved 40-character Git commit SHA; defaults to the local HEAD",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"--output", required=True, help="Directory for JSON, Markdown, SVG, and SARIF"
|
|
121
|
+
)
|
|
122
|
+
parser.add_argument("--baseline", type=Path, help="Trusted baseline report JSON")
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--fail-on", choices=("all-blockers", "new-blockers"), default="all-blockers"
|
|
125
|
+
)
|
|
126
|
+
return parser
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _local_repository(value: str) -> Path:
|
|
130
|
+
path = Path(value)
|
|
131
|
+
if not path.exists():
|
|
132
|
+
raise ValueError(f"Local repository directory does not exist: {path}")
|
|
133
|
+
if not path.is_dir():
|
|
134
|
+
raise ValueError(f"Local repository path is not a directory: {path}")
|
|
135
|
+
return path.resolve()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _commit_sha(value: str) -> str:
|
|
139
|
+
if not _COMMIT_PATTERN.fullmatch(value):
|
|
140
|
+
raise ValueError("Commit must be a 40-character lowercase hexadecimal SHA.")
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _run_git(repository_root: Path, *arguments: str) -> str:
|
|
145
|
+
"""在只读模式下询问本地仓库;git 不可用或命令失败时返回空字符串。"""
|
|
146
|
+
try:
|
|
147
|
+
completed = subprocess.run(
|
|
148
|
+
("git", "-C", str(repository_root), *arguments),
|
|
149
|
+
capture_output=True,
|
|
150
|
+
check=False,
|
|
151
|
+
encoding="utf-8",
|
|
152
|
+
errors="replace",
|
|
153
|
+
shell=False,
|
|
154
|
+
text=True,
|
|
155
|
+
timeout=10,
|
|
156
|
+
)
|
|
157
|
+
except (OSError, subprocess.SubprocessError):
|
|
158
|
+
return ""
|
|
159
|
+
return completed.stdout.strip() if completed.returncode == 0 else ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _detect_commit(repository_root: Path) -> str:
|
|
163
|
+
commit = _run_git(repository_root, "rev-parse", "HEAD")
|
|
164
|
+
if not commit:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"Cannot resolve the local HEAD (git rev-parse HEAD failed); "
|
|
167
|
+
"pass --commit with a 40-character commit SHA."
|
|
168
|
+
)
|
|
169
|
+
return commit
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _detect_repository_url(repository_root: Path) -> str:
|
|
173
|
+
remote = _run_git(repository_root, "remote", "get-url", "origin")
|
|
174
|
+
normalized = _normalize_remote_url(remote)
|
|
175
|
+
if not normalized.startswith("https://"):
|
|
176
|
+
raise ValueError(
|
|
177
|
+
"Cannot resolve a public origin remote (git remote get-url origin failed); "
|
|
178
|
+
"pass --repo-url with a GitHub/Gitee HTTPS URL."
|
|
179
|
+
)
|
|
180
|
+
return normalized
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _normalize_remote_url(value: str) -> str:
|
|
184
|
+
"""把 SSH 形式的 origin 地址转成 parse_repository_url 接受的 HTTPS 形式。"""
|
|
185
|
+
candidate = value.strip()
|
|
186
|
+
if "://" in candidate:
|
|
187
|
+
match = _SSH_URL_REMOTE.fullmatch(candidate) if candidate.startswith("ssh://") else None
|
|
188
|
+
else:
|
|
189
|
+
match = _SCP_LIKE_REMOTE.fullmatch(candidate)
|
|
190
|
+
if match is None:
|
|
191
|
+
return candidate
|
|
192
|
+
return f"https://{match.group('host')}/{match.group('path')}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _write_outputs(output_dir: Path, stem: str, report: ScanReport) -> None:
|
|
196
|
+
(output_dir / f"{stem}.sarif").write_text(render_sarif(report), encoding="utf-8")
|
|
197
|
+
(output_dir / f"{stem}.json").write_text(
|
|
198
|
+
report.model_dump_json(indent=2) + "\n",
|
|
199
|
+
encoding="utf-8",
|
|
200
|
+
)
|
|
201
|
+
(output_dir / f"{stem}.md").write_text(
|
|
202
|
+
render_markdown(report),
|
|
203
|
+
encoding="utf-8",
|
|
204
|
+
)
|
|
205
|
+
(output_dir / f"{stem}.svg").write_text(
|
|
206
|
+
render_badge(report),
|
|
207
|
+
encoding="utf-8",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
raise SystemExit(main())
|
mxready/cli/serve.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Start the installed Web application with persistent per-user data."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from mxready.app import create_app
|
|
13
|
+
from mxready.config import Settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_data_directory() -> Path:
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
return Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "MXReady"
|
|
19
|
+
if sys.platform == "darwin":
|
|
20
|
+
return Path.home() / "Library" / "Application Support" / "MXReady"
|
|
21
|
+
return Path(os.getenv("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))) / "mxready"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _port(value: str) -> int:
|
|
25
|
+
number = int(value)
|
|
26
|
+
if not 1 <= number <= 65535:
|
|
27
|
+
raise argparse.ArgumentTypeError("Port must be between 1 and 65535")
|
|
28
|
+
return number
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
33
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
34
|
+
parser.add_argument("--port", type=_port, default=8000)
|
|
35
|
+
parser.add_argument("--data-dir", type=Path)
|
|
36
|
+
args = parser.parse_args(argv)
|
|
37
|
+
if not shutil.which("git"):
|
|
38
|
+
print(
|
|
39
|
+
"Git is required. Install Git and add it to PATH before starting MXReady.",
|
|
40
|
+
file=sys.stderr,
|
|
41
|
+
)
|
|
42
|
+
return 1
|
|
43
|
+
settings = Settings.from_env()
|
|
44
|
+
data_dir = args.data_dir or Path(os.getenv("MXREADY_DATA_DIR", str(default_data_directory())))
|
|
45
|
+
settings = replace(
|
|
46
|
+
settings,
|
|
47
|
+
data_dir=data_dir,
|
|
48
|
+
temp_dir=Path(os.getenv("MXREADY_TEMP_DIR", str(data_dir / "tmp"))),
|
|
49
|
+
)
|
|
50
|
+
uvicorn.run(create_app(settings), host=args.host, port=args.port)
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
raise SystemExit(main())
|
mxready/config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""应用配置:默认值 + 环境变量注入(12-factor)。
|
|
2
|
+
|
|
3
|
+
所有可配置项均支持通过 `MXREADY_*` 环境变量覆盖,未设置时使用内置默认值。
|
|
4
|
+
参见仓库根目录 `.env.example` 与 `docs/deployment.md`。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from mxready.resources import default_frontend_directory, default_rules_directory
|
|
12
|
+
|
|
13
|
+
_ENV_PREFIX = "MXREADY_"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _path_env(name: str, default: str) -> Path:
|
|
17
|
+
return Path(os.getenv(_ENV_PREFIX + name, default))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _optional_path_env(name: str) -> Path | None:
|
|
21
|
+
raw = os.getenv(_ENV_PREFIX + name)
|
|
22
|
+
if raw is None or not raw.strip():
|
|
23
|
+
return None
|
|
24
|
+
return Path(raw.strip())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _int_env(name: str, default: int) -> int:
|
|
28
|
+
try:
|
|
29
|
+
return int(os.getenv(_ENV_PREFIX + name, str(default)))
|
|
30
|
+
except ValueError:
|
|
31
|
+
return default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bool_env(name: str, default: bool) -> bool:
|
|
35
|
+
raw = os.getenv(_ENV_PREFIX + name)
|
|
36
|
+
if raw is None:
|
|
37
|
+
return default
|
|
38
|
+
return raw.strip().casefold() in {"1", "true", "yes", "on"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _hosts_env(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
|
|
42
|
+
raw = os.getenv(_ENV_PREFIX + name)
|
|
43
|
+
if raw is None or not raw.strip():
|
|
44
|
+
return default
|
|
45
|
+
return tuple(host.strip() for host in raw.split(",") if host.strip())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# 预过滤开关的合法取值:auto 为默认启用,off 为应急止血(回退全量扫描,
|
|
49
|
+
# 结果集完全不变,只影响耗时)。
|
|
50
|
+
_PREFILTER_MODES = ("auto", "off")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _prefilter_mode_env(name: str, default: str = "auto") -> str:
|
|
54
|
+
"""取值非法时回退默认值:拼错不该让服务起不来,也不该静默关掉优化。"""
|
|
55
|
+
raw = os.getenv(_ENV_PREFIX + name)
|
|
56
|
+
if raw is None or not raw.strip():
|
|
57
|
+
return default
|
|
58
|
+
mode = raw.strip().casefold()
|
|
59
|
+
return mode if mode in _PREFILTER_MODES else default
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Settings:
|
|
64
|
+
# 路径
|
|
65
|
+
data_dir: Path = Path("data")
|
|
66
|
+
rules_dir: Path = default_rules_directory()
|
|
67
|
+
extra_rules_dir: Path | None = None
|
|
68
|
+
temp_dir: Path = Path("data/tmp")
|
|
69
|
+
frontend_dist: Path = field(default_factory=default_frontend_directory)
|
|
70
|
+
# 日志
|
|
71
|
+
log_level: str = "INFO"
|
|
72
|
+
# 托管平台白名单(保持安全默认值)
|
|
73
|
+
allowed_hosts: tuple[str, ...] = ("github.com", "gitee.com")
|
|
74
|
+
# 数据保留(0 = 不自动清理,默认关闭以保护申报证据)
|
|
75
|
+
scan_retention_days: int = 0
|
|
76
|
+
# 防护(默认关闭限流,公网部署时按需开启)
|
|
77
|
+
rate_limit_enabled: bool = False
|
|
78
|
+
rate_limit_per_minute: int = 20
|
|
79
|
+
max_concurrent_scans: int = 2
|
|
80
|
+
max_request_bytes: int = 1_048_576
|
|
81
|
+
scan_worker_threads: int = 2
|
|
82
|
+
task_lease_seconds: int = 120
|
|
83
|
+
# 规则正则的必需字面量预过滤:auto 启用;off 关闭(应急止血,结论不变)
|
|
84
|
+
prefilter_mode: str = "auto"
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
if self.scan_worker_threads < 1 or self.task_lease_seconds < 3:
|
|
88
|
+
raise ValueError("Worker threads must be positive and task leases at least 3 seconds")
|
|
89
|
+
if self.max_concurrent_scans < 0 or self.max_request_bytes < 1:
|
|
90
|
+
raise ValueError("Invalid scan concurrency or request size limit")
|
|
91
|
+
if self.prefilter_mode not in _PREFILTER_MODES:
|
|
92
|
+
raise ValueError("prefilter_mode must be 'auto' or 'off'")
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def prefilter_enabled(self) -> bool:
|
|
96
|
+
return self.prefilter_mode != "off"
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_env(cls) -> "Settings":
|
|
100
|
+
"""读取 `MXREADY_*` 环境变量构造配置,未设置项使用默认值。"""
|
|
101
|
+
return cls(
|
|
102
|
+
data_dir=_path_env("DATA_DIR", "data"),
|
|
103
|
+
rules_dir=_path_env("RULES_DIR", str(default_rules_directory())),
|
|
104
|
+
extra_rules_dir=_optional_path_env("EXTRA_RULES_DIR"),
|
|
105
|
+
temp_dir=_path_env("TEMP_DIR", "data/tmp"),
|
|
106
|
+
frontend_dist=_path_env("FRONTEND_DIR", str(default_frontend_directory())),
|
|
107
|
+
log_level=os.getenv(_ENV_PREFIX + "LOG_LEVEL", "INFO"),
|
|
108
|
+
allowed_hosts=_hosts_env("ALLOWED_HOSTS", ("github.com", "gitee.com")),
|
|
109
|
+
scan_retention_days=_int_env("SCAN_RETENTION_DAYS", 0),
|
|
110
|
+
rate_limit_enabled=_bool_env("RATE_LIMIT_ENABLED", False),
|
|
111
|
+
rate_limit_per_minute=_int_env("RATE_LIMIT_PER_MINUTE", 20),
|
|
112
|
+
max_concurrent_scans=_int_env("MAX_CONCURRENT_SCANS", 2),
|
|
113
|
+
max_request_bytes=_int_env("MAX_REQUEST_BYTES", 1_048_576),
|
|
114
|
+
scan_worker_threads=_int_env("SCAN_WORKER_THREADS", 2),
|
|
115
|
+
task_lease_seconds=_int_env("TASK_LEASE_SECONDS", 120),
|
|
116
|
+
prefilter_mode=_prefilter_mode_env("PREFILTER_MODE"),
|
|
117
|
+
)
|
mxready/errors.py
ADDED
mxready/logging_setup.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""统一日志配置:JSON 结构化输出 + 敏感信息脱敏。
|
|
2
|
+
|
|
3
|
+
只配置 `mxready` 命名空间的 logger;uvicorn 自身与访问日志保持默认,
|
|
4
|
+
避免重复输出与格式不一致。脱敏规则与 `src/mxready_runner/redact.py` 对齐。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any, Final
|
|
13
|
+
|
|
14
|
+
_LOGGER_NAME: Final = "mxready"
|
|
15
|
+
|
|
16
|
+
# stdlib LogRecord 自带的属性不进 JSON 字段
|
|
17
|
+
_SKIP_ATTRS: Final = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"name",
|
|
20
|
+
"msg",
|
|
21
|
+
"args",
|
|
22
|
+
"levelname",
|
|
23
|
+
"levelno",
|
|
24
|
+
"pathname",
|
|
25
|
+
"filename",
|
|
26
|
+
"module",
|
|
27
|
+
"exc_info",
|
|
28
|
+
"exc_text",
|
|
29
|
+
"stack_info",
|
|
30
|
+
"lineno",
|
|
31
|
+
"funcName",
|
|
32
|
+
"created",
|
|
33
|
+
"msecs",
|
|
34
|
+
"relativeCreated",
|
|
35
|
+
"thread",
|
|
36
|
+
"threadName",
|
|
37
|
+
"processName",
|
|
38
|
+
"process",
|
|
39
|
+
"taskName",
|
|
40
|
+
"message",
|
|
41
|
+
"asctime",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_URL_CREDENTIALS = re.compile(r"(://[^/@\s:]+):([^/@\s]+)@")
|
|
46
|
+
_SENSITIVE_NAME = r"[A-Z0-9_]*(?:TOKEN|PASSWORD|SECRET|KEY|AUTHORIZATION)"
|
|
47
|
+
_ASSIGNMENT = re.compile(rf"(?i)(\b{_SENSITIVE_NAME}\b\s*[:=]\s*)([^\s,;]+)")
|
|
48
|
+
_UNIX_HOME = re.compile(r"(?i)(/(?:home|Users)/)[^/\s]+")
|
|
49
|
+
_WINDOWS_HOME = re.compile(r"(?i)([A-Z]:\\Users\\)[^\\\s]+")
|
|
50
|
+
_TOKEN_PREFIX = re.compile(r"(?i)\b(?:ghp|gho|ghu|glpat|github_pat|hf_)[A-Za-z0-9_\-]{6,}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _redact_message(message: str) -> str:
|
|
54
|
+
redacted = _URL_CREDENTIALS.sub(r"\1:***@", message)
|
|
55
|
+
redacted = _ASSIGNMENT.sub(r"\1[REDACTED]", redacted)
|
|
56
|
+
redacted = _UNIX_HOME.sub(r"\1[USER]", redacted)
|
|
57
|
+
redacted = _WINDOWS_HOME.sub(r"\1[USER]", redacted)
|
|
58
|
+
return _TOKEN_PREFIX.sub("[REDACTED]", redacted)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _JsonFormatter(logging.Formatter):
|
|
62
|
+
"""输出单行 JSON:{ts, level, logger, message} + 自定义 extra 字段。"""
|
|
63
|
+
|
|
64
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
65
|
+
payload: dict[str, Any] = {
|
|
66
|
+
"ts": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
|
|
67
|
+
"level": record.levelname,
|
|
68
|
+
"logger": record.name,
|
|
69
|
+
"message": _redact_message(record.getMessage()),
|
|
70
|
+
}
|
|
71
|
+
for key, value in record.__dict__.items():
|
|
72
|
+
if key not in _SKIP_ATTRS and not key.startswith("_"):
|
|
73
|
+
payload[key] = value
|
|
74
|
+
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def setup_logging(log_level: str = "INFO") -> None:
|
|
78
|
+
"""为 `mxready` logger 配置 JSON 结构化输出(幂等,可重复调用)。"""
|
|
79
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
80
|
+
if logger.handlers:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
level = getattr(logging, str(log_level).upper(), logging.INFO)
|
|
84
|
+
logger.setLevel(level)
|
|
85
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
86
|
+
handler.setFormatter(_JsonFormatter())
|
|
87
|
+
logger.addHandler(handler)
|
|
88
|
+
logger.propagate = False
|