devguard-scanner 0.3.0__tar.gz → 0.4.1__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.
Files changed (110) hide show
  1. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/.gitignore +5 -0
  2. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/CHANGELOG.md +48 -0
  3. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/PKG-INFO +11 -1
  4. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/README.md +10 -0
  5. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/docs/README.md +1 -0
  6. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/pyproject.toml +1 -0
  7. devguard_scanner-0.4.1/sandbox/README.md +34 -0
  8. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/__init__.py +2 -2
  9. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/registry.py +8 -1
  10. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/content.py +9 -6
  11. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/app.py +247 -11
  12. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/schemas.py +41 -0
  13. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/settings.py +23 -3
  14. devguard_scanner-0.4.1/src/devguard/api/static/app.css +186 -0
  15. devguard_scanner-0.4.1/src/devguard/api/static/app.js +341 -0
  16. devguard_scanner-0.4.1/src/devguard/api/static/index.html +31 -0
  17. devguard_scanner-0.4.1/src/devguard/api/traffic.py +421 -0
  18. devguard_scanner-0.4.1/src/devguard/api/triage.py +277 -0
  19. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/cli.py +216 -0
  20. devguard_scanner-0.4.1/src/devguard/community.py +157 -0
  21. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/config.py +2 -0
  22. devguard_scanner-0.4.1/src/devguard/core/config_init.py +167 -0
  23. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/github.py +9 -3
  24. devguard_scanner-0.4.1/src/devguard/remote/local_git.py +107 -0
  25. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/cross_language.yaml +112 -0
  26. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/conftest.py +3 -0
  27. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/corpus/samples.py +34 -0
  28. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_api.py +165 -8
  29. devguard_scanner-0.4.1/tests/test_community.py +237 -0
  30. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_report_cli.py +51 -0
  31. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_rules.py +14 -0
  32. devguard_scanner-0.3.0/src/devguard/api/traffic.py +0 -151
  33. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/LICENSE +0 -0
  34. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/__init__.py +0 -0
  35. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/investigator.py +0 -0
  36. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/prompts.py +0 -0
  37. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/providers/__init__.py +0 -0
  38. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/providers/anthropic.py +0 -0
  39. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/providers/openai_compat.py +0 -0
  40. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/run.py +0 -0
  41. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/sdk.py +0 -0
  42. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/testing.py +0 -0
  43. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/tools.py +0 -0
  44. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/__init__.py +0 -0
  45. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/base.py +0 -0
  46. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/carriers.py +0 -0
  47. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/command.py +0 -0
  48. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/filesystem.py +0 -0
  49. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/hidden.py +0 -0
  50. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/imports.py +0 -0
  51. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/jsonc.py +0 -0
  52. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/source.py +0 -0
  53. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/__init__.py +0 -0
  54. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/ai_assistants.py +0 -0
  55. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/autoload.py +0 -0
  56. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/common.py +0 -0
  57. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/deps.py +0 -0
  58. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/devcontainer.py +0 -0
  59. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/env.py +0 -0
  60. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/git.py +0 -0
  61. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/npm.py +0 -0
  62. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/python_pkg.py +0 -0
  63. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/vscode.py +0 -0
  64. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/__init__.py +0 -0
  65. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/executor.py +0 -0
  66. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/limits.py +0 -0
  67. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/local.py +0 -0
  68. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/worker.py +0 -0
  69. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/__init__.py +0 -0
  70. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/filetype.py +0 -0
  71. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/models.py +0 -0
  72. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/redact.py +0 -0
  73. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/safefs.py +0 -0
  74. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/scoring.py +0 -0
  75. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/service.py +0 -0
  76. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/correlate/__init__.py +0 -0
  77. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/correlate/chains.py +0 -0
  78. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/mcp/__init__.py +0 -0
  79. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/mcp/server.py +0 -0
  80. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/__init__.py +0 -0
  81. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/archive.py +0 -0
  82. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/errors.py +0 -0
  83. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/fetch.py +0 -0
  84. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/url.py +0 -0
  85. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/__init__.py +0 -0
  86. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/escape.py +0 -0
  87. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/markdown.py +0 -0
  88. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/sarif.py +0 -0
  89. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/terminal.py +0 -0
  90. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/writer.py +0 -0
  91. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/__init__.py +0 -0
  92. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/catalog.py +0 -0
  93. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/ai_instructions.yaml +0 -0
  94. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/docs.yaml +0 -0
  95. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/javascript.yaml +0 -0
  96. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/obfuscation.yaml +0 -0
  97. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/python.yaml +0 -0
  98. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/shell.yaml +0 -0
  99. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/windows.yaml +0 -0
  100. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/data/vscode_settings.yaml +0 -0
  101. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/__init__.py +0 -0
  102. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/corpus/__init__.py +0 -0
  103. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/github_fakes.py +0 -0
  104. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_ai.py +0 -0
  105. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_corpus.py +0 -0
  106. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_mcp.py +0 -0
  107. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_parsers.py +0 -0
  108. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_remote.py +0 -0
  109. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_safefs.py +0 -0
  110. {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_safety.py +0 -0
@@ -18,3 +18,8 @@ htmlcov/
18
18
  dist/
19
19
  build/
20
20
  *.egg-info/
21
+
22
+ # Personal notes and local deploy state (secrets)
23
+ .local/
24
+ .vercel/
25
+ .env*
@@ -3,6 +3,54 @@
3
3
  All notable changes are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/) and
4
4
  the project uses [semantic versioning](https://semver.org).
5
5
 
6
+ ## 0.4.1
7
+
8
+ - **`devguard check`**: is this repository flagged as malicious by the community? Reads the GitHub remote and
9
+ HEAD of a checkout from `.git` as data (git is never run; credentials in remote URLs are dropped), or takes a
10
+ URL / `OWNER/REPO`. Exit code `1` when flagged.
11
+ - **`devguard flag`**: report a repository. The server scans that commit itself and records the flag only for a
12
+ `block` verdict, with the evidence and the number of distinct reporters, so clean repositories cannot be
13
+ flagged. API: `POST /v1/flags`, `GET /v1/flags/github/{owner}/{repo}`.
14
+ - **Shared scan results (opt-in, asked once)**: after `devguard scan` of a GitHub checkout, the verdict, counts,
15
+ and finding titles (never code) go to `POST /v1/usage` and are kept for public repositories only.
16
+ `--share` / `--no-share`, `DEVGUARD_SHARE`, `[community] share`. A flagged repository is reported right after the
17
+ scan.
18
+ - Every request record has `from: "api"` or `"cli"`; the dashboard and `/v1/stats` show API usage only.
19
+ - New rules for info-stealers that search the computer rather than known credential paths (ruleset 2026.10.1):
20
+ `DG-EXFIL-FILE-SWEEP-001` (critical: the home directory or disk is searched for keys, `.env` files, wallets, or
21
+ documents, and they are uploaded with `fetch`, `axios`, `FormData`, `requests.post(files=…)`, `curl -F`, …),
22
+ `DG-SECRET-SWEEP-001` (high: the same search without a visible upload, including `find ~ -name "*.env"`,
23
+ `grep -r "PRIVATE KEY" ~`, `ls -R /home`, `Get-ChildItem -Recurse`, and `dir /s`), and `DG-SECRET-HOME-ENV-001`
24
+ (high: a `.env` file read from the home directory). Before, such code only produced weak indicators.
25
+ - Content rules accept `within: "chars:N"`: every part within N characters, which also holds in minified bundles.
26
+ - **Dashboard** at `/` of the API (and <https://devguard-scanner.vercel.app>): scan a repository from the browser,
27
+ usage over 24 h / 7 / 30 / 90 days (requests, scans, passed, failed, errors, repositories, verdicts, scans per
28
+ day), and the recently flagged repositories with a detail page each. Plain HTML/CSS/JS served by the API, with a
29
+ strict CSP; it needs the MongoDB request log.
30
+ - `GET /v1/stats` and `GET /v1/stats/repos/{owner}/{repo}`: the data behind the dashboard (aggregates only; client
31
+ IPs are never returned). `/v1/stats` is cached for 60 s and not itself logged.
32
+ - New `scans` collection next to `api_requests`: one document per repository and commit (latest verdict, counts,
33
+ the 20 most severe findings without excerpts, first/last seen, scan count). It never expires.
34
+ - **AI triage of flagged scans** with the server's own key `DEVGUARD_OPENAI_KEY`: one strict OpenAI function call
35
+ per flagged repository and commit (`result` malware/suspicious/clean/unsure, `risk_level`, `reason`,
36
+ `reason_detail`, techniques), plus the GitHub owner and the scanned commit's author, date, and message. Runs in
37
+ the background, capped by `DEVGUARD_API_TRIAGE_MAX_PER_DAY` (200). Callers' own AI keys are unchanged.
38
+ - Leaner request log: the JSON report is stored only for `block`/`review` verdicts
39
+ (`DEVGUARD_API_MONGODB_STORE_REPORT=flagged|all|none`; `true`/`false` still work), and the `report_md_head`
40
+ field is gone (it duplicated the summary and rendered Markdown for every request).
41
+ - `sandbox/`: one Dockerfile on top of the published image, portable paths, reports and config mounted where the
42
+ CLI actually writes and reads them, and the proxy variant no longer resolves names directly.
43
+
44
+ ## 0.4.0
45
+
46
+ - `devguard config init` writes the user config with an AI profile: numbered choices on a terminal (Enter takes
47
+ the default), or the defaults and flags with `-y` or without a terminal (`--provider anthropic|openai|local`,
48
+ `--model`, `--key-env`, `--base-url`, `--path`, `--force`). `devguard config path` prints the file location.
49
+ - With a single AI profile, `--ai` uses it without `default_profile`. Without any profile, the error points to
50
+ `devguard config init`.
51
+ - Container images are published on every push to `main` (`:main`, `:sha-<commit>`), and the hosted API on Vercel
52
+ is deployed from CI with that exact image digest.
53
+
6
54
  ## 0.3.0
7
55
 
8
56
  - The default drop folder for local scans (`DEVGUARD_API_LOCAL_ROOT`, and the folder `compose.yaml` mounts) is
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devguard-scanner
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Scan untrusted code repositories for malware before you open, install, or run them. Files are analyzed as data and never executed.
5
5
  Project-URL: Homepage, https://github.com/yilmazhasan/devguard-scanner
6
6
  Project-URL: Documentation, https://github.com/yilmazhasan/devguard-scanner/tree/main/docs
@@ -77,6 +77,16 @@ Report (md): ~/devguard-scanner/reports/interview-task-1a2b3c4d/latest/report.md
77
77
 
78
78
  Exit code `1` means findings at or above `--fail-on` (default `high`), so it works as a CI gate.
79
79
 
80
+ ## Check whether others flagged it
81
+
82
+ ```bash
83
+ devguard check # this checkout, by its GitHub remote; or: devguard check owner/repo
84
+ devguard flag --note "..." # report it; the server re-scans and accepts only a `block` verdict
85
+ ```
86
+
87
+ Sharing your scan verdicts for public repositories is opt-in (asked once). See
88
+ [community features](docs/getting-started.md#community-check-flag-and-share).
89
+
80
90
  ## Scan a GitHub repository without cloning it
81
91
 
82
92
  ```bash
@@ -39,6 +39,16 @@ Report (md): ~/devguard-scanner/reports/interview-task-1a2b3c4d/latest/report.md
39
39
 
40
40
  Exit code `1` means findings at or above `--fail-on` (default `high`), so it works as a CI gate.
41
41
 
42
+ ## Check whether others flagged it
43
+
44
+ ```bash
45
+ devguard check # this checkout, by its GitHub remote; or: devguard check owner/repo
46
+ devguard flag --note "..." # report it; the server re-scans and accepts only a `block` verdict
47
+ ```
48
+
49
+ Sharing your scan verdicts for public repositories is opt-in (asked once). See
50
+ [community features](docs/getting-started.md#community-check-flag-and-share).
51
+
42
52
  ## Scan a GitHub repository without cloning it
43
53
 
44
54
  ```bash
@@ -14,4 +14,5 @@
14
14
  | [Rules](rules.md) | Rule catalogue, writing a detection rule |
15
15
  | [Threat reference](threats.md) | The `TC-xx` threat identifiers that rules reference |
16
16
  | [Dependencies](dependencies.md) | Why each runtime dependency exists |
17
+ | [Developer guide](development.md) | A tour of the codebase: what is where, how a scan flows, where to start, how to debug |
17
18
  | [Releasing](releasing.md) | Publishing to PyPI and GHCR, deploying the hosted API (maintainers) |
@@ -55,6 +55,7 @@ dev = [
55
55
  "mypy>=1.11",
56
56
  "types-PyYAML",
57
57
  "httpx>=0.27", # FastAPI TestClient
58
+ "mongomock>=4.3",
58
59
  ]
59
60
 
60
61
  [build-system]
@@ -0,0 +1,34 @@
1
+ # CLI sandbox
2
+
3
+ Run the `devguard` CLI in a locked-down container: read-only root filesystem, no capabilities, your repositories
4
+ mounted read-only, and only the reports directory writable. Both variants build `sandbox/Dockerfile`, which is the
5
+ published image plus `vi`, `less`, `file`, and `tree` for looking at a suspicious repository from the inside.
6
+
7
+ | Variant | Network |
8
+ |---|---|
9
+ | `docker-with-network/` | full internet (GitHub URL scans, any AI provider) |
10
+ | `docker-with-proxy/` | none, except HTTPS to `api.openai.com` / `api.anthropic.com` through an allowlist proxy (Docker Engine >= 26) |
11
+
12
+ ```bash
13
+ mkdir -p ~/devguard-scanner-data ~/devguard-scanner-reports ~/.config/devguard-scanner
14
+ cp -r ~/Downloads/interview-task ~/devguard-scanner-data/ # what you want to scan
15
+ cd sandbox/docker-with-proxy # or docker-with-network
16
+ export OPENAI_API_KEY=sk-... # optional, for --ai
17
+
18
+ docker compose run --rm devguard-scanner scan /data/interview-task
19
+ docker compose run --rm --entrypoint bash devguard-scanner # a shell, to inspect /data by hand
20
+ ```
21
+
22
+ Reports land in `~/devguard-scanner-reports/<repo>-<hash>/latest/` on the host. Your
23
+ `~/.config/devguard-scanner/config.toml` (from `devguard config init`) is mounted read-only.
24
+
25
+ | Variable | Default |
26
+ |---|---|
27
+ | `DEVGUARD_DROP_FOLDER` | `~/devguard-scanner-data` (mounted read-only at `/data`) |
28
+ | `DEVGUARD_REPORTS` | `~/devguard-scanner-reports` |
29
+ | `DEVGUARD_CONFIG` | `~/.config/devguard-scanner` |
30
+ | `DEVGUARD_UID` / `DEVGUARD_GID` | `1000` (set to `$(id -u)` / `$(id -g)` if yours differ) |
31
+
32
+ A container limits what a malicious repository can reach, but DevGuard never executes the code it scans, so the
33
+ sandbox mainly protects you from the scanner's own dependencies and from your own curiosity (`bash`, `npm install`).
34
+ For genuinely hostile code, add a VM. To scan without installing anything, use the [hosted API](../docs/api.md).
@@ -1,5 +1,5 @@
1
1
  """DevGuard: pre-open security scanner for untrusted code workspaces."""
2
2
 
3
- __version__ = "0.3.0"
4
- RULESET_VERSION = "2026.10.0"
3
+ __version__ = "0.4.1"
4
+ RULESET_VERSION = "2026.10.1"
5
5
  SCHEMA_VERSION = "1"
@@ -101,8 +101,15 @@ def load_provider(
101
101
  ) -> LoadedProvider:
102
102
  """Load a profile's provider. `secrets` supplies already-resolved values (an API caller's own key)."""
103
103
  name = profile_name or cfg.default_profile
104
+ if not name and len(cfg.profiles) == 1:
105
+ name = next(iter(cfg.profiles))
104
106
  if not name:
105
- raise ProviderError("no AI profile selected: pass --ai-profile or set ai.default_profile in your user config")
107
+ if not cfg.profiles:
108
+ raise ProviderError("no AI profile configured: run `devguard config init` to create one")
109
+ raise ProviderError(
110
+ f"several AI profiles ({', '.join(sorted(cfg.profiles))}) and no default: pass --ai-profile NAME "
111
+ "or set ai.default_profile in your config"
112
+ )
106
113
  profile = cfg.profiles.get(name)
107
114
  if profile is None:
108
115
  raise ProviderError(f"unknown AI profile {name!r}; configured: {', '.join(sorted(cfg.profiles)) or 'none'}")
@@ -5,7 +5,8 @@ Rule match expressions:
5
5
  {all: [expr, ...]} every child must match (optionally within N lines, see `within`)
6
6
  {any: [expr, ...]} at least one child matches
7
7
  {not: expr} child must not match
8
- `within: file` (default) or `within: "lines:N"` (top-level `all` only).
8
+ `within: file` (default), `within: "lines:N"`, or `within: "chars:N"` (top-level `all` only). `chars` measures
9
+ distance in characters, so it also holds in minified files, where a whole bundle sits on a few lines.
9
10
  """
10
11
 
11
12
  from __future__ import annotations
@@ -61,8 +62,8 @@ class YamlRule(BaseModel):
61
62
  @field_validator("within")
62
63
  @classmethod
63
64
  def _within_format(cls, v: str) -> str:
64
- if v != "file" and not re.fullmatch(r"lines:\d+", v):
65
- raise ValueError("within must be 'file' or 'lines:N'")
65
+ if v != "file" and not re.fullmatch(r"(lines|chars):\d+", v):
66
+ raise ValueError("within must be 'file', 'lines:N', or 'chars:N'")
66
67
  return v
67
68
 
68
69
 
@@ -120,6 +121,7 @@ class CompiledRule:
120
121
  spec: YamlRule
121
122
  root: _Node
122
123
  window: int | None
124
+ window_chars: bool = False
123
125
 
124
126
  def applies(self, rel: str, lang: str | None) -> bool:
125
127
  if self.spec.languages and "any" not in self.spec.languages and lang not in self.spec.languages:
@@ -141,7 +143,8 @@ class CompiledRule:
141
143
  per_child.append(res)
142
144
  if not per_child:
143
145
  return []
144
- child_lines = [[(line_of(s[0]), s) for s in spans] for spans in per_child]
146
+ position = (lambda off: off) if self.window_chars else line_of
147
+ child_lines = [[(position(s[0]), s) for s in spans] for spans in per_child]
145
148
  for anchor_line, anchor_span in child_lines[0]:
146
149
  picked = [anchor_span]
147
150
  for others in child_lines[1:]:
@@ -174,8 +177,8 @@ class ContentRuleEngine:
174
177
  compiled: list[CompiledRule] = []
175
178
  for pack in packs:
176
179
  for r in pack.rules:
177
- window = int(r.within.split(":")[1]) if r.within.startswith("lines:") else None
178
- compiled.append(CompiledRule(r, _compile(r.match), window))
180
+ window = int(r.within.split(":")[1]) if r.within != "file" else None
181
+ compiled.append(CompiledRule(r, _compile(r.match), window, r.within.startswith("chars:")))
179
182
  register(
180
183
  RuleMeta(
181
184
  id=r.id,
@@ -17,8 +17,10 @@ from pathlib import Path
17
17
  from typing import Annotated, Any, Literal
18
18
 
19
19
  from fastapi import FastAPI, Header, Query, Request
20
+ from fastapi.encoders import jsonable_encoder
20
21
  from fastapi.exceptions import RequestValidationError
21
- from fastapi.responses import JSONResponse, RedirectResponse, Response
22
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
23
+ from fastapi.staticfiles import StaticFiles
22
24
  from starlette.exceptions import HTTPException as StarletteHTTPException
23
25
 
24
26
  from devguard import RULESET_VERSION, SCHEMA_VERSION, __version__
@@ -30,6 +32,7 @@ from devguard.api.schemas import (
30
32
  AIOptions,
31
33
  ErrorResponse,
32
34
  FailOn,
35
+ FlagRequestBody,
33
36
  Gate,
34
37
  LocalListing,
35
38
  LocalScanRequestBody,
@@ -39,17 +42,30 @@ from devguard.api.schemas import (
39
42
  ScanRequestBody,
40
43
  ScanResponse,
41
44
  Summary,
45
+ UsageRequestBody,
42
46
  VersionInfo,
43
47
  )
44
48
  from devguard.api.settings import APISettings, load_settings
45
- from devguard.api.traffic import MongoSink, NullSink, TrafficSink, client_ip, markdown_head
49
+ from devguard.api.traffic import (
50
+ FLAGGED,
51
+ MongoSink,
52
+ NullSink,
53
+ TrafficSink,
54
+ cli_scan_upsert,
55
+ client_ip,
56
+ flag_upsert,
57
+ reporter_key,
58
+ top_findings,
59
+ )
60
+ from devguard.api.triage import Triager, openai_complete, triage_job
46
61
  from devguard.api.worker import AI_PROVIDERS
47
62
  from devguard.core.config import parse_size
48
63
  from devguard.core.models import ScanIssue, ScanResult, Severity
49
64
  from devguard.core.scoring import VERDICT_TEXT
50
65
  from devguard.core.service import registered_rules
51
- from devguard.remote.errors import RemoteError
66
+ from devguard.remote.errors import NotFound, RemoteError
52
67
  from devguard.remote.fetch import MIB
68
+ from devguard.remote.github import GitHubClient
53
69
  from devguard.remote.url import RepoSpec, is_full_sha, parse_repo
54
70
  from devguard.report.writer import render
55
71
  from devguard.rules.catalog import CATALOG, rule_details, rule_summary
@@ -59,6 +75,13 @@ log = logging.getLogger("devguard.api")
59
75
  Format = Literal["json", "md", "sarif"]
60
76
  MEDIA_TYPES = {"json": "application/json", "md": "text/markdown; charset=utf-8", "sarif": "application/sarif+json"}
61
77
  _DOCS_PATHS = ("/docs", "/redoc", "/openapi.json")
78
+ CLIENT_HEADER = "X-DevGuard-Client" # "cli/<version>" from the devguard CLI (devguard.community)
79
+ _UNLOGGED = ("/v1/stats",) # the dashboard polls it; logging it would only measure the dashboard
80
+ STATIC = Path(__file__).parent / "static"
81
+ _PAGE_CSP = (
82
+ "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; "
83
+ "base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
84
+ )
62
85
 
63
86
  DESCRIPTION = """
64
87
  Scan a **public GitHub repository** for malware **before** you open it in your IDE, install its dependencies,
@@ -135,6 +158,8 @@ def create_app(
135
158
  settings: APISettings | None = None,
136
159
  executor: Executor | None = None,
137
160
  traffic: TrafficSink | None = None,
161
+ triager: Triager | None = None,
162
+ github: Callable[[], GitHubClient] | None = None,
138
163
  ) -> FastAPI:
139
164
  settings = settings or load_settings()
140
165
  if executor is None:
@@ -154,8 +179,21 @@ def create_app(
154
179
  )
155
180
  sink: TrafficSink = traffic
156
181
  recording = not isinstance(sink, NullSink)
182
+ if github is None:
183
+ token = settings.github_token.get_secret_value() if settings.github_token else None
184
+ github = lambda: GitHubClient(token, timeout=10.0) # noqa: E731 (metadata only: triage, public checks)
185
+ if triager is None and isinstance(sink, MongoSink) and settings.openai_key and settings.triage_max_per_day > 0:
186
+ triager = Triager(
187
+ sink.database,
188
+ openai_complete(settings.openai_key.get_secret_value(), settings.triage_model, settings.triage_timeout_s),
189
+ github,
190
+ settings.triage_model,
191
+ settings.triage_max_per_day,
192
+ )
157
193
  gate = ConcurrencyGate(settings.concurrency)
158
194
  limiter = RateLimiter(settings.rate_limit_per_min)
195
+ usage_limiter = RateLimiter(settings.usage_rate_limit_per_min)
196
+ public_repos: dict[str, tuple[float, bool]] = {} # owner/repo → (checked at, public)
159
197
  registered_rules() # load analyzers and rules once, before the first request
160
198
 
161
199
  app = FastAPI(
@@ -168,11 +206,16 @@ def create_app(
168
206
  "name": "local",
169
207
  "description": "Self-hosted only: scan a directory you copied into the server's drop folder.",
170
208
  },
209
+ {
210
+ "name": "community",
211
+ "description": "Flags of malicious repositories (verified by a server scan), checks, and scan "
212
+ "results shared by the CLI (`devguard flag`, `devguard check`).",
213
+ },
171
214
  {"name": "rules", "description": "The rule catalogue (`devguard rules` / `devguard explain`)."},
172
215
  {"name": "meta", "description": "Version, limits, health."},
173
216
  ],
174
217
  license_info={"name": "Apache-2.0", "identifier": "Apache-2.0"},
175
- lifespan=_lifespan(sink),
218
+ lifespan=_lifespan(sink, triager),
176
219
  )
177
220
  app.state.settings = settings
178
221
 
@@ -183,10 +226,15 @@ def create_app(
183
226
  return forwarded[-1] # the entry added by the proxy closest to us
184
227
  return request.client.host if request.client else "unknown"
185
228
 
229
+ def origin(request: Request) -> str:
230
+ return "cli" if request.headers.get(CLIENT_HEADER, "").startswith("cli/") else "api"
231
+
186
232
  def record_traffic(request: Request, status: int, started: float, request_id: str) -> None:
187
233
  doc: dict[str, Any] = {
188
234
  "ts": datetime.now(UTC),
189
235
  "request_id": request_id,
236
+ "from": origin(request),
237
+ "client_version": request.headers.get(CLIENT_HEADER, "")[:40] or None,
190
238
  "method": request.method,
191
239
  "path": request.url.path[:512],
192
240
  "query": {k: v[:255] for k, v in request.query_params.items() if k in ("ref", "format", "fail_on")},
@@ -227,12 +275,14 @@ def create_app(
227
275
  response = await call_next(request)
228
276
  status = response.status_code
229
277
  finally:
230
- if recording and request.url.path.startswith("/v1/"):
278
+ if recording and request.url.path.startswith("/v1/") and not request.url.path.startswith(_UNLOGGED):
231
279
  record_traffic(request, status, started, request_id)
232
280
  response.headers["X-Request-Id"] = request_id
233
281
  response.headers["X-Content-Type-Options"] = "nosniff"
234
282
  response.headers["Referrer-Policy"] = "no-referrer"
235
- if not request.url.path.startswith(_DOCS_PATHS):
283
+ if request.url.path == "/" or request.url.path.startswith("/static/"):
284
+ response.headers["Content-Security-Policy"] = _PAGE_CSP
285
+ elif not request.url.path.startswith(_DOCS_PATHS):
236
286
  response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
237
287
  return response
238
288
 
@@ -350,14 +400,23 @@ def create_app(
350
400
  fitted, truncated = _fit(result, fmt, summary, settings.max_response_bytes)
351
401
  body = _body(fitted, fmt, summary, truncated)
352
402
  if recording:
403
+ store = settings.mongodb_store_report
353
404
  _note(
354
405
  request,
355
406
  format=fmt,
356
407
  summary=summary.model_dump(mode="json"),
357
408
  truncated=truncated,
358
- report_md_head=markdown_head(body if fmt == "md" else render(fitted, "md")),
359
- report=fitted.model_dump(mode="json") if settings.mongodb_store_report else None,
409
+ top_findings=top_findings(result.findings),
410
+ report=fitted.model_dump(mode="json")
411
+ if store == "all" or (store == "flagged" and result.verdict in FLAGGED)
412
+ else None,
360
413
  )
414
+ if triager is not None and result.source is not None and result.verdict in FLAGGED and origin(request) != "cli":
415
+ client = {
416
+ "ip": client_ip(client_key(request), settings.traffic_ip),
417
+ "country": request.headers.get("x-vercel-ip-country"),
418
+ }
419
+ triager.submit(triage_job(result, summary.model_dump(mode="json"), client))
361
420
  headers = {
362
421
  "Cache-Control": cache_control,
363
422
  "Vary": "Accept",
@@ -484,23 +543,200 @@ def create_app(
484
543
  limits=settings.public_limits(),
485
544
  )
486
545
 
546
+ # ------------------------------------------------------------------ community: flags, checks, shared CLI scans
547
+ def community_db() -> MongoSink:
548
+ if not isinstance(sink, MongoSink):
549
+ raise APIError(503, "community_disabled", "flags and checks need the request log (MongoDB) on this server")
550
+ return sink
551
+
552
+ def is_public(spec: RepoSpec) -> bool | None:
553
+ """Whether GitHub serves the repository without credentials (cached for an hour). None if unknown."""
554
+ key = spec.full_name.lower()
555
+ cached = public_repos.get(key)
556
+ if cached and time.monotonic() - cached[0] < 3600:
557
+ return cached[1]
558
+ try:
559
+ public = github().repo(spec.owner, spec.repo).get("private") is False
560
+ except NotFound:
561
+ public = False
562
+ except RemoteError:
563
+ return None
564
+ if len(public_repos) > 5000:
565
+ public_repos.clear()
566
+ public_repos[key] = (time.monotonic(), public)
567
+ return public
568
+
569
+ def check_document(db: MongoSink, spec: RepoSpec, commit: str | None) -> dict[str, Any]:
570
+ flags = db.flags_for(spec.owner, spec.repo)
571
+ return {
572
+ "repo": spec.full_name,
573
+ "flagged": bool(flags),
574
+ "commit_flagged": any(f["commit"] == commit for f in flags) if commit else None,
575
+ "flags": flags,
576
+ "latest_scan": db.repository(spec.owner, spec.repo),
577
+ }
578
+
579
+ @app.post(
580
+ "/v1/flags",
581
+ tags=["community"],
582
+ summary="Flag a repository as malicious (the server scans it first)",
583
+ responses={**_ERROR_RESPONSES},
584
+ )
585
+ def create_flag(body: FlagRequestBody, request: Request) -> JSONResponse:
586
+ db = community_db()
587
+ spec = _parse(body.repo, body.ref)
588
+ result = run_scan(request, spec, ScanOptions())
589
+ summary = _summary(result, "high").model_dump(mode="json")
590
+ _note(request, summary=summary, top_findings=top_findings(result.findings))
591
+ if result.verdict != "block" or result.source is None:
592
+ _note(request, flag={"accepted": False})
593
+ return JSONResponse(
594
+ {
595
+ "accepted": False,
596
+ "verdict": result.verdict,
597
+ "message": "The server's own scan of this commit did not find strong indicators of malware "
598
+ f"(verdict: {result.verdict}), so no flag was recorded. Only `block` verdicts can be flagged; "
599
+ "this keeps clean repositories from being flagged by anyone.",
600
+ },
601
+ headers={"Cache-Control": "no-store"},
602
+ )
603
+ ip = client_key(request)
604
+ secret = settings.mongodb_uri.get_secret_value() if settings.mongodb_uri else "devguard"
605
+ report = {
606
+ "at": datetime.now(UTC),
607
+ "from": origin(request),
608
+ "ip": client_ip(ip, settings.traffic_ip),
609
+ "country": request.headers.get("x-vercel-ip-country"),
610
+ "note": body.note,
611
+ }
612
+ summary.update(devguard_version=__version__, ruleset_version=RULESET_VERSION)
613
+ try:
614
+ flag = db.flag(*flag_upsert(result, summary, reporter_key(ip, secret), report))
615
+ except Exception as exc:
616
+ log.warning("flag not stored: %s", type(exc).__name__)
617
+ raise APIError(
618
+ 503, "community_unavailable", "the flag could not be stored; try again shortly", 30
619
+ ) from None
620
+ _note(request, flag={"accepted": True, "reporters": flag["reporters"]})
621
+ return JSONResponse(
622
+ jsonable_encoder({"accepted": True, "verdict": result.verdict, "flag": flag}),
623
+ headers={"Cache-Control": "no-store"},
624
+ )
625
+
626
+ @app.get(
627
+ "/v1/flags/github/{owner}/{repo}",
628
+ tags=["community"],
629
+ summary="Check whether a repository was flagged as malicious",
630
+ responses={503: {"model": ErrorResponse}},
631
+ )
632
+ def check_flags(
633
+ owner: str,
634
+ repo: str,
635
+ commit: Annotated[str | None, Query(pattern=r"^[0-9a-fA-F]{40}$", description="Your HEAD commit.")] = None,
636
+ ) -> JSONResponse:
637
+ db = community_db()
638
+ spec = _parse(f"{owner}/{repo}", None)
639
+ try:
640
+ doc = check_document(db, spec, commit.lower() if commit else None)
641
+ except Exception as exc:
642
+ log.warning("check unavailable: %s", type(exc).__name__)
643
+ raise APIError(503, "community_unavailable", "checks are temporarily unavailable", 30) from None
644
+ return JSONResponse(jsonable_encoder(doc), headers={"Cache-Control": "public, max-age=30, s-maxage=60"})
645
+
646
+ @app.post(
647
+ "/v1/usage",
648
+ tags=["community"],
649
+ summary="Share a CLI scan result (usage statistics; public repositories only)",
650
+ responses={429: {"model": ErrorResponse}, 503: {"model": ErrorResponse}},
651
+ )
652
+ def share_usage(body: UsageRequestBody, request: Request) -> JSONResponse:
653
+ db = community_db()
654
+ spec = _parse(body.repo, None)
655
+ wait = usage_limiter.check(client_key(request))
656
+ if wait is not None:
657
+ raise APIError(429, "rate_limited", "too many shared results from this address; try again later", wait)
658
+ public = is_public(spec)
659
+ stored = public is True
660
+ _note(
661
+ request,
662
+ repo={"owner": spec.owner, "repo": spec.repo, "full_name": spec.full_name, "ref": body.commit}
663
+ if stored
664
+ else None, # never keep the name of a private or unknown repository
665
+ usage={"verdict": body.verdict, "findings": body.findings, "public": public},
666
+ )
667
+ if stored:
668
+ db.upsert_later(
669
+ "cli_scans", *cli_scan_upsert(body.model_dump(mode="json"), spec.full_name, datetime.now(UTC))
670
+ )
671
+ try:
672
+ check = check_document(db, spec, body.commit.lower() if body.commit else None) if stored else None
673
+ except Exception:
674
+ check = None
675
+ return JSONResponse(jsonable_encoder({"stored": stored, "check": check}), headers={"Cache-Control": "no-store"})
676
+
677
+ stats_cache: dict[int, tuple[float, dict[str, Any]]] = {}
678
+
679
+ @app.get(
680
+ "/v1/stats",
681
+ tags=["meta"],
682
+ summary="Usage statistics and recently flagged repositories",
683
+ responses={503: {"model": ErrorResponse}},
684
+ )
685
+ def stats(days: Annotated[int, Query(ge=1, le=365, description="Window in days.")] = 30) -> JSONResponse:
686
+ if not settings.stats_public or not isinstance(sink, MongoSink):
687
+ return JSONResponse({"enabled": False}, headers={"Cache-Control": "public, max-age=60"})
688
+ cached = stats_cache.get(days)
689
+ if cached is None or time.monotonic() - cached[0] > 60:
690
+ try:
691
+ cached = (time.monotonic(), jsonable_encoder(sink.stats(days)))
692
+ except Exception as exc: # the database is optional; never let it break the API
693
+ log.warning("stats unavailable: %s", type(exc).__name__)
694
+ raise APIError(503, "stats_unavailable", "statistics are temporarily unavailable", 30) from None
695
+ stats_cache[days] = cached
696
+ return JSONResponse(cached[1], headers={"Cache-Control": "public, max-age=30, s-maxage=60"})
697
+
698
+ @app.get(
699
+ "/v1/stats/repos/{owner}/{repo}",
700
+ tags=["meta"],
701
+ summary="The latest scan of one repository, with its triage (dashboard detail view)",
702
+ responses={404: {"model": ErrorResponse}, 503: {"model": ErrorResponse}},
703
+ )
704
+ def stats_repo(owner: str, repo: str) -> JSONResponse:
705
+ spec = _parse(f"{owner}/{repo}", None)
706
+ if not settings.stats_public or not isinstance(sink, MongoSink):
707
+ raise APIError(404, "stats_disabled", "statistics are not enabled on this server")
708
+ try:
709
+ doc = sink.repository(spec.owner, spec.repo)
710
+ except Exception as exc:
711
+ log.warning("stats unavailable: %s", type(exc).__name__)
712
+ raise APIError(503, "stats_unavailable", "statistics are temporarily unavailable", 30) from None
713
+ if doc is None:
714
+ raise APIError(404, "not_found", f"{spec.full_name} has not been scanned here")
715
+ return JSONResponse(jsonable_encoder(doc), headers={"Cache-Control": "public, max-age=30, s-maxage=60"})
716
+
487
717
  @app.get("/healthz", tags=["meta"], summary="Liveness")
488
718
  def healthz() -> dict[str, str]:
489
719
  return {"status": "ok"}
490
720
 
491
721
  @app.get("/", include_in_schema=False)
492
- def root() -> RedirectResponse:
493
- return RedirectResponse("/docs")
722
+ def root() -> Response:
723
+ if not settings.stats_public:
724
+ return RedirectResponse("/docs")
725
+ return FileResponse(STATIC / "index.html", headers={"Cache-Control": "public, max-age=300"})
726
+
727
+ app.mount("/static", StaticFiles(directory=STATIC), name="static")
494
728
 
495
729
  return app
496
730
 
497
731
 
498
732
  # ---------------------------------------------------------------------- helpers
499
- def _lifespan(sink: TrafficSink) -> Callable[[FastAPI], Any]:
733
+ def _lifespan(sink: TrafficSink, triager: Triager | None) -> Callable[[FastAPI], Any]:
500
734
  @asynccontextmanager
501
735
  async def lifespan(_: FastAPI) -> AsyncIterator[None]:
502
736
  yield
503
737
  sink.close(5.0) # flush pending traffic records on shutdown (Vercel sends SIGTERM before scale-in)
738
+ if triager is not None:
739
+ triager.close(2.0) # unfinished triage is retried by the next flagged scan of the same commit
504
740
 
505
741
  return lifespan
506
742
 
@@ -129,3 +129,44 @@ class RuleDetails(RuleSummary):
129
129
  benign_context: str
130
130
  next_step: str
131
131
  origin: str
132
+
133
+
134
+ # ---------------------------------------------------------------------- community: flags, checks, shared CLI scans
135
+ _SHA = r"^[0-9a-fA-F]{40}$"
136
+
137
+
138
+ class FlagRequestBody(BaseModel):
139
+ model_config = ConfigDict(
140
+ extra="forbid",
141
+ json_schema_extra={"examples": [{"repo": "owner/repo", "ref": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"}]},
142
+ )
143
+
144
+ repo: str = Field(max_length=512, description="https://github.com/OWNER/REPO or OWNER/REPO.")
145
+ ref: str | None = Field(None, max_length=255, description="The commit (or branch/tag) you scanned.")
146
+ note: str | None = Field(None, max_length=500, description="What you saw. Kept private; never shown publicly.")
147
+
148
+
149
+ class UsageFinding(BaseModel):
150
+ model_config = ConfigDict(extra="forbid")
151
+
152
+ rule_id: str = Field(max_length=64)
153
+ severity: str = Field(max_length=16)
154
+ title: str = Field(max_length=200)
155
+ path: str = Field(max_length=300)
156
+ line: int | None = Field(None, ge=0)
157
+
158
+
159
+ class UsageRequestBody(BaseModel):
160
+ """A scan result shared by the CLI. Client-reported, so it never flags anything by itself."""
161
+
162
+ model_config = ConfigDict(extra="forbid")
163
+
164
+ repo: str = Field(max_length=512)
165
+ commit: str | None = Field(None, pattern=_SHA)
166
+ verdict: Verdict
167
+ findings: int = Field(ge=0, le=100_000)
168
+ severity_counts: dict[str, int] = Field(default_factory=dict, max_length=8)
169
+ automatic_triggers: int = Field(0, ge=0, le=10_000)
170
+ top_findings: list[UsageFinding] = Field(default_factory=list, max_length=20)
171
+ devguard_version: str = Field(max_length=32)
172
+ ruleset_version: str = Field(max_length=32)