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.
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/.gitignore +5 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/CHANGELOG.md +48 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/PKG-INFO +11 -1
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/README.md +10 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/docs/README.md +1 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/pyproject.toml +1 -0
- devguard_scanner-0.4.1/sandbox/README.md +34 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/__init__.py +2 -2
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/registry.py +8 -1
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/content.py +9 -6
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/app.py +247 -11
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/schemas.py +41 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/settings.py +23 -3
- devguard_scanner-0.4.1/src/devguard/api/static/app.css +186 -0
- devguard_scanner-0.4.1/src/devguard/api/static/app.js +341 -0
- devguard_scanner-0.4.1/src/devguard/api/static/index.html +31 -0
- devguard_scanner-0.4.1/src/devguard/api/traffic.py +421 -0
- devguard_scanner-0.4.1/src/devguard/api/triage.py +277 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/cli.py +216 -0
- devguard_scanner-0.4.1/src/devguard/community.py +157 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/config.py +2 -0
- devguard_scanner-0.4.1/src/devguard/core/config_init.py +167 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/github.py +9 -3
- devguard_scanner-0.4.1/src/devguard/remote/local_git.py +107 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/cross_language.yaml +112 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/conftest.py +3 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/corpus/samples.py +34 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_api.py +165 -8
- devguard_scanner-0.4.1/tests/test_community.py +237 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_report_cli.py +51 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_rules.py +14 -0
- devguard_scanner-0.3.0/src/devguard/api/traffic.py +0 -151
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/LICENSE +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/investigator.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/prompts.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/providers/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/providers/anthropic.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/providers/openai_compat.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/run.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/sdk.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/testing.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/ai/tools.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/base.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/carriers.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/command.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/filesystem.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/hidden.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/imports.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/jsonc.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/source.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/ai_assistants.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/autoload.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/common.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/deps.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/devcontainer.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/env.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/git.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/npm.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/python_pkg.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/analyzers/surfaces/vscode.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/executor.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/limits.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/local.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/api/worker.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/filetype.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/models.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/redact.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/safefs.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/scoring.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/core/service.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/correlate/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/correlate/chains.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/mcp/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/mcp/server.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/archive.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/errors.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/fetch.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/remote/url.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/escape.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/markdown.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/sarif.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/terminal.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/report/writer.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/catalog.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/ai_instructions.yaml +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/docs.yaml +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/javascript.yaml +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/obfuscation.yaml +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/python.yaml +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/shell.yaml +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/content/windows.yaml +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/src/devguard/rules/data/vscode_settings.yaml +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/corpus/__init__.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/github_fakes.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_ai.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_corpus.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_mcp.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_parsers.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_remote.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_safefs.py +0 -0
- {devguard_scanner-0.3.0 → devguard_scanner-0.4.1}/tests/test_safety.py +0 -0
|
@@ -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
|
+
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) |
|
|
@@ -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).
|
|
@@ -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
|
-
|
|
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: "
|
|
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 '
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
359
|
-
report=fitted.model_dump(mode="json")
|
|
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() ->
|
|
493
|
-
|
|
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)
|