devguard-scanner 0.2.0__tar.gz → 0.4.0__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.2.0 → devguard_scanner-0.4.0}/CHANGELOG.md +21 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/PKG-INFO +3 -3
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/README.md +2 -2
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/__init__.py +1 -1
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/registry.py +8 -1
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/api/local.py +3 -3
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/api/settings.py +2 -2
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/cli.py +46 -2
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/core/config.py +25 -10
- devguard_scanner-0.4.0/src/devguard/core/config_init.py +167 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_report_cli.py +65 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/.gitignore +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/LICENSE +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/docs/README.md +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/pyproject.toml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/investigator.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/prompts.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/providers/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/providers/anthropic.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/providers/openai_compat.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/run.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/sdk.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/testing.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/tools.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/base.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/carriers.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/command.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/content.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/filesystem.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/hidden.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/imports.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/jsonc.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/source.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/ai_assistants.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/autoload.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/common.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/deps.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/devcontainer.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/env.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/git.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/npm.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/python_pkg.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/vscode.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/api/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/api/app.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/api/executor.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/api/limits.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/api/schemas.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/api/traffic.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/api/worker.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/core/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/core/filetype.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/core/models.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/core/redact.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/core/safefs.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/core/scoring.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/core/service.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/correlate/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/correlate/chains.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/mcp/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/mcp/server.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/remote/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/remote/archive.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/remote/errors.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/remote/fetch.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/remote/github.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/remote/url.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/report/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/report/escape.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/report/markdown.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/report/sarif.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/report/terminal.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/report/writer.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/catalog.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/ai_instructions.yaml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/cross_language.yaml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/docs.yaml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/javascript.yaml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/obfuscation.yaml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/python.yaml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/shell.yaml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/windows.yaml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/data/vscode_settings.yaml +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/conftest.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/corpus/__init__.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/corpus/samples.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/github_fakes.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_ai.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_api.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_corpus.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_mcp.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_parsers.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_remote.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_rules.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_safefs.py +0 -0
- {devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/tests/test_safety.py +0 -0
|
@@ -3,6 +3,27 @@
|
|
|
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.0
|
|
7
|
+
|
|
8
|
+
- `devguard config init` writes the user config with an AI profile: numbered choices on a terminal (Enter takes
|
|
9
|
+
the default), or the defaults and flags with `-y` or without a terminal (`--provider anthropic|openai|local`,
|
|
10
|
+
`--model`, `--key-env`, `--base-url`, `--path`, `--force`). `devguard config path` prints the file location.
|
|
11
|
+
- With a single AI profile, `--ai` uses it without `default_profile`. Without any profile, the error points to
|
|
12
|
+
`devguard config init`.
|
|
13
|
+
- Container images are published on every push to `main` (`:main`, `:sha-<commit>`), and the hosted API on Vercel
|
|
14
|
+
is deployed from CI with that exact image digest.
|
|
15
|
+
|
|
16
|
+
## 0.3.0
|
|
17
|
+
|
|
18
|
+
- The default drop folder for local scans (`DEVGUARD_API_LOCAL_ROOT`, and the folder `compose.yaml` mounts) is
|
|
19
|
+
now `~/devguard-scanner-data` instead of `~/devguard-scanner`. Move the folder, or set the variable to keep
|
|
20
|
+
the old location.
|
|
21
|
+
- File locations use `devguard-scanner` (only the command is still `devguard`): config
|
|
22
|
+
`~/.config/devguard-scanner/config.toml` (the old `~/.config/devguard/config.toml` is still read if the new one
|
|
23
|
+
is missing), app data `~/.local/share/devguard-scanner/` (AI consent and cache, MCP audit log), and reports
|
|
24
|
+
`~/devguard-scanner/reports/` on every OS. In the container image the config is
|
|
25
|
+
`/etc/devguard-scanner/config.toml`.
|
|
26
|
+
|
|
6
27
|
## 0.2.0
|
|
7
28
|
|
|
8
29
|
Remote scanning, the HTTP API, and the container image.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: devguard-scanner
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -65,7 +65,7 @@ Findings: 5 (1 critical, 2 high, 1 medium, 1 low)
|
|
|
65
65
|
- [critical] Automatic trigger reaches dangerous code (package.json:5)
|
|
66
66
|
- [high] Browser profile data targeted (lib/collect.js:7)
|
|
67
67
|
- [high] Cryptocurrency wallet data targeted (lib/collect.js:10)
|
|
68
|
-
Report (md):
|
|
68
|
+
Report (md): ~/devguard-scanner/reports/interview-task-1a2b3c4d/latest/report.md
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
| Verdict | Meaning |
|
|
@@ -96,7 +96,7 @@ curl -s 'https://devguard-scanner.vercel.app/v1/scans/github/owner/repo?format=m
|
|
|
96
96
|
# one-off scan in a throwaway container
|
|
97
97
|
docker run --rm ghcr.io/yilmazhasan/devguard-scanner scan https://github.com/owner/repo --stdout md
|
|
98
98
|
|
|
99
|
-
# self-hosted API, plus local scans of anything you copy into ~/devguard-scanner (mounted read-only)
|
|
99
|
+
# self-hosted API, plus local scans of anything you copy into ~/devguard-scanner-data (mounted read-only)
|
|
100
100
|
docker compose up -d
|
|
101
101
|
```
|
|
102
102
|
|
|
@@ -27,7 +27,7 @@ Findings: 5 (1 critical, 2 high, 1 medium, 1 low)
|
|
|
27
27
|
- [critical] Automatic trigger reaches dangerous code (package.json:5)
|
|
28
28
|
- [high] Browser profile data targeted (lib/collect.js:7)
|
|
29
29
|
- [high] Cryptocurrency wallet data targeted (lib/collect.js:10)
|
|
30
|
-
Report (md):
|
|
30
|
+
Report (md): ~/devguard-scanner/reports/interview-task-1a2b3c4d/latest/report.md
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
| Verdict | Meaning |
|
|
@@ -58,7 +58,7 @@ curl -s 'https://devguard-scanner.vercel.app/v1/scans/github/owner/repo?format=m
|
|
|
58
58
|
# one-off scan in a throwaway container
|
|
59
59
|
docker run --rm ghcr.io/yilmazhasan/devguard-scanner scan https://github.com/owner/repo --stdout md
|
|
60
60
|
|
|
61
|
-
# self-hosted API, plus local scans of anything you copy into ~/devguard-scanner (mounted read-only)
|
|
61
|
+
# self-hosted API, plus local scans of anything you copy into ~/devguard-scanner-data (mounted read-only)
|
|
62
62
|
docker compose up -d
|
|
63
63
|
```
|
|
64
64
|
|
|
@@ -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'}")
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Local scans for a self-hosted API: directories inside one drop folder (DEVGUARD_API_LOCAL_ROOT).
|
|
2
2
|
|
|
3
|
-
Put a repository into the drop folder (default ~/devguard-scanner; /workspace in Docker, mounted read-only) and
|
|
3
|
+
Put a repository into the drop folder (default ~/devguard-scanner-data; /workspace in Docker, mounted read-only) and
|
|
4
4
|
ask the API to scan it by name. Instead of copying, a top-level entry may be a symlink to the repository
|
|
5
|
-
(`ln -s /path/to/repo ~/devguard-scanner/repo`); in Docker, bind-mount it instead
|
|
5
|
+
(`ln -s /path/to/repo ~/devguard-scanner-data/repo`); in Docker, bind-mount it instead
|
|
6
6
|
(`-v /path/to/repo:/workspace/repo:ro`), since the container cannot see symlink targets on the host.
|
|
7
7
|
|
|
8
8
|
Only what you put there is reachable: names are relative, `..` and absolute paths are refused, and symlinks
|
|
@@ -47,7 +47,7 @@ def resolve_local(root: Path, name: str) -> Path:
|
|
|
47
47
|
st = os.lstat(current)
|
|
48
48
|
except FileNotFoundError:
|
|
49
49
|
raise LocalPathError(404, "not_found", f"{first!r} is not in the drop folder") from None
|
|
50
|
-
if stat.S_ISLNK(st.st_mode): # a link
|
|
50
|
+
if stat.S_ISLNK(st.st_mode): # a link placed in the drop folder: ln -s ~/code/repo ~/devguard-scanner-data/
|
|
51
51
|
try:
|
|
52
52
|
current = current.resolve(strict=True)
|
|
53
53
|
except (OSError, RuntimeError):
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
DEVGUARD_API_<NAME> overrides one field, e.g. DEVGUARD_API_MAX_REPO_MB=200
|
|
5
5
|
DEVGUARD_GITHUB_TOKEN GitHub token without scopes (5,000 API requests/hour instead of 60)
|
|
6
6
|
DEVGUARD_API_MONGODB_URI (or MONGODB_URI) enables the request log in MongoDB
|
|
7
|
-
DEVGUARD_API_LOCAL_ROOT drop folder for local scans (default ~/devguard-scanner; ignored by the public profile)
|
|
7
|
+
DEVGUARD_API_LOCAL_ROOT drop folder for local scans (default ~/devguard-scanner-data; ignored by the public profile)
|
|
8
8
|
|
|
9
9
|
Nothing in a request can change these.
|
|
10
10
|
"""
|
|
@@ -51,7 +51,7 @@ class APISettings(BaseModel):
|
|
|
51
51
|
ai_max_cost_usd: float = 1.0
|
|
52
52
|
github_token: SecretStr | None = None
|
|
53
53
|
# Self-host only: scan directories copied into this drop folder (POST /v1/scans/local). Never public.
|
|
54
|
-
local_root: str | None = "~/devguard-scanner"
|
|
54
|
+
local_root: str | None = "~/devguard-scanner-data"
|
|
55
55
|
# request log (optional; see devguard.api.traffic)
|
|
56
56
|
mongodb_uri: SecretStr | None = None
|
|
57
57
|
mongodb_db: str = "devguard"
|
|
@@ -108,6 +108,21 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
108
108
|
sv.add_argument("--port", type=int, help="Port (default: $PORT or 8000).")
|
|
109
109
|
sv.add_argument("--profile", choices=["public", "selfhost", "dev"], help="Limits profile (default: selfhost).")
|
|
110
110
|
|
|
111
|
+
c = sub.add_parser("config", help="Create or locate the user config file.")
|
|
112
|
+
csub = c.add_subparsers(dest="config_command", required=True)
|
|
113
|
+
ci = csub.add_parser(
|
|
114
|
+
"init",
|
|
115
|
+
help="Write a config with an AI profile (asks on a terminal; -y or no terminal: defaults/flags).",
|
|
116
|
+
)
|
|
117
|
+
ci.add_argument("--provider", choices=["anthropic", "openai", "local"])
|
|
118
|
+
ci.add_argument("--model", help="Model id (default: claude-opus-5-5, gpt-4o, or llama3.1).")
|
|
119
|
+
ci.add_argument("--key-env", help="Environment variable that holds the API key (default: the provider's usual).")
|
|
120
|
+
ci.add_argument("--base-url", help="Server URL for --provider local (default: Ollama on localhost).")
|
|
121
|
+
ci.add_argument("--path", type=Path, help="Where to write it (default: the user config file).")
|
|
122
|
+
ci.add_argument("--force", action="store_true", help="Overwrite an existing file (kept as .bak).")
|
|
123
|
+
ci.add_argument("-y", "--yes", action="store_true", help="Do not ask; use the flags and defaults.")
|
|
124
|
+
csub.add_parser("path", help="Print the user config file path.")
|
|
125
|
+
|
|
111
126
|
sub.add_parser("doctor", help="Check the installation and configuration.")
|
|
112
127
|
return p
|
|
113
128
|
|
|
@@ -131,6 +146,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
131
146
|
return serve(roots, expose_investigation=args.expose_investigation_tools, config=cfg)
|
|
132
147
|
if args.command == "serve":
|
|
133
148
|
return _serve(args)
|
|
149
|
+
if args.command == "config":
|
|
150
|
+
return _config(args)
|
|
134
151
|
if args.command == "doctor":
|
|
135
152
|
return _doctor()
|
|
136
153
|
except ConfigError as exc:
|
|
@@ -337,15 +354,42 @@ def _ai(args: argparse.Namespace) -> int:
|
|
|
337
354
|
return EXIT_OK if report.ok else EXIT_FINDINGS
|
|
338
355
|
|
|
339
356
|
|
|
357
|
+
def _config(args: argparse.Namespace) -> int:
|
|
358
|
+
from devguard.core.config import user_config_file
|
|
359
|
+
|
|
360
|
+
if args.config_command == "path":
|
|
361
|
+
path = user_config_file()
|
|
362
|
+
print(path if path.is_file() else f"{path} (not created yet: run `devguard config init`)")
|
|
363
|
+
return EXIT_OK
|
|
364
|
+
from devguard.core.config_init import run_init
|
|
365
|
+
|
|
366
|
+
result = run_init(
|
|
367
|
+
path=args.path,
|
|
368
|
+
provider=args.provider,
|
|
369
|
+
model=args.model,
|
|
370
|
+
key_env=args.key_env,
|
|
371
|
+
base_url=args.base_url,
|
|
372
|
+
force=args.force,
|
|
373
|
+
assume_defaults=args.yes,
|
|
374
|
+
)
|
|
375
|
+
print(f"\nWrote {result.path} (profile {result.choice.profile!r}, {result.choice.label}).")
|
|
376
|
+
print("Next:")
|
|
377
|
+
if result.key_env and not os.environ.get(result.key_env):
|
|
378
|
+
print(f" export {result.key_env}=... # your API key; it is not stored in the file")
|
|
379
|
+
print(" devguard ai test # checks the profile with synthetic data only")
|
|
380
|
+
print(" devguard scan ./repo --ai")
|
|
381
|
+
return EXIT_OK
|
|
382
|
+
|
|
383
|
+
|
|
340
384
|
def _doctor() -> int:
|
|
341
385
|
import importlib.util
|
|
342
386
|
import platform
|
|
343
387
|
|
|
344
388
|
from devguard.ai.registry import AIConfig, available_entry_points
|
|
345
|
-
from devguard.core.config import
|
|
389
|
+
from devguard.core.config import user_config_file
|
|
346
390
|
|
|
347
391
|
print(f"devguard {__version__}, ruleset {RULESET_VERSION}, Python {platform.python_version()}")
|
|
348
|
-
cfg_file =
|
|
392
|
+
cfg_file = user_config_file()
|
|
349
393
|
print(f"User config: {cfg_file} ({'found' if cfg_file.is_file() else 'not found'})")
|
|
350
394
|
try:
|
|
351
395
|
cfg = load_config(None, None)
|
|
@@ -51,22 +51,37 @@ class ScanConfig(BaseModel):
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
APP_DIR = "devguard-scanner" # directory name for config, data, and reports; only the command is `devguard`
|
|
55
|
+
_LEGACY_APP_DIR = "devguard" # used up to 0.2.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def user_config_dir(name: str = APP_DIR) -> Path:
|
|
55
59
|
if sys.platform == "win32":
|
|
56
60
|
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
57
|
-
return Path(base) /
|
|
61
|
+
return Path(base) / name
|
|
58
62
|
if sys.platform == "darwin":
|
|
59
|
-
return Path.home() / "Library" / "Application Support" /
|
|
60
|
-
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") /
|
|
63
|
+
return Path.home() / "Library" / "Application Support" / name
|
|
64
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def user_config_file() -> Path:
|
|
68
|
+
"""The user config file, falling back to the pre-0.3 location when only that one exists."""
|
|
69
|
+
current = user_config_dir() / "config.toml"
|
|
70
|
+
legacy = user_config_dir(_LEGACY_APP_DIR) / "config.toml"
|
|
71
|
+
return legacy if not current.is_file() and legacy.is_file() else current
|
|
61
72
|
|
|
62
73
|
|
|
63
74
|
def user_data_dir() -> Path:
|
|
64
75
|
if sys.platform == "win32":
|
|
65
76
|
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
66
|
-
return Path(base) /
|
|
77
|
+
return Path(base) / APP_DIR
|
|
67
78
|
if sys.platform == "darwin":
|
|
68
|
-
return Path.home() / "Library" / "Application Support" /
|
|
69
|
-
return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") /
|
|
79
|
+
return Path.home() / "Library" / "Application Support" / APP_DIR
|
|
80
|
+
return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / APP_DIR
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reports_dir() -> Path:
|
|
84
|
+
return Path.home() / APP_DIR / "reports"
|
|
70
85
|
|
|
71
86
|
|
|
72
87
|
def is_within(path: Path, root: Path) -> bool:
|
|
@@ -80,11 +95,11 @@ def is_within(path: Path, root: Path) -> bool:
|
|
|
80
95
|
def default_output_dir(target: Path) -> Path:
|
|
81
96
|
resolved = target.resolve()
|
|
82
97
|
digest = hashlib.sha256(str(resolved).encode()).hexdigest()[:8]
|
|
83
|
-
return
|
|
98
|
+
return reports_dir() / f"{resolved.name or 'root'}-{digest}"
|
|
84
99
|
|
|
85
100
|
|
|
86
101
|
def remote_output_dir(owner: str, repo: str) -> Path:
|
|
87
|
-
return
|
|
102
|
+
return reports_dir() / f"github-{owner}-{repo}"
|
|
88
103
|
|
|
89
104
|
|
|
90
105
|
def _read_toml(path: Path, target: Path | None) -> dict[str, Any]:
|
|
@@ -108,7 +123,7 @@ def load_config(
|
|
|
108
123
|
overrides: dict[str, Any] | None = None,
|
|
109
124
|
) -> ScanConfig:
|
|
110
125
|
data: dict[str, Any] = {}
|
|
111
|
-
user_file =
|
|
126
|
+
user_file = user_config_file()
|
|
112
127
|
if user_file.is_file():
|
|
113
128
|
data = _merge(data, _read_toml(user_file, target))
|
|
114
129
|
if explicit is not None:
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""`devguard config init`: write a user config with one AI profile.
|
|
2
|
+
|
|
3
|
+
Interactive on a terminal (numbered choices, Enter takes the default); with `--yes` or without a terminal it
|
|
4
|
+
writes the defaults, or whatever the flags say. The API key itself is never written: the profile references
|
|
5
|
+
an environment variable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from devguard.core.config import ConfigError, user_config_dir
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Choice:
|
|
23
|
+
key: str
|
|
24
|
+
label: str
|
|
25
|
+
provider: str
|
|
26
|
+
profile: str
|
|
27
|
+
model: str
|
|
28
|
+
key_env: str | None = None
|
|
29
|
+
base_url: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
CHOICES = [
|
|
33
|
+
Choice("anthropic", "Claude (Anthropic)", "anthropic", "claude", "claude-opus-5-5", key_env="ANTHROPIC_API_KEY"),
|
|
34
|
+
Choice("openai", "OpenAI", "openai", "gpt", "gpt-4o", key_env="OPENAI_API_KEY"),
|
|
35
|
+
Choice(
|
|
36
|
+
"local",
|
|
37
|
+
"Local model via Ollama (code stays on this machine)",
|
|
38
|
+
"openai-compatible",
|
|
39
|
+
"local",
|
|
40
|
+
"llama3.1",
|
|
41
|
+
base_url="http://localhost:11434/v1",
|
|
42
|
+
),
|
|
43
|
+
]
|
|
44
|
+
BY_KEY = {c.key: c for c in CHOICES}
|
|
45
|
+
_MODEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,99}$")
|
|
46
|
+
_ENV = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def default_choice() -> Choice:
|
|
50
|
+
"""Prefer the provider whose key is already in the environment."""
|
|
51
|
+
for c in CHOICES:
|
|
52
|
+
if c.key_env and os.environ.get(c.key_env):
|
|
53
|
+
return c
|
|
54
|
+
return CHOICES[0]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render(choice: Choice, model: str, key_env: str | None, base_url: str | None) -> str:
|
|
58
|
+
q = json.dumps # a JSON string of these validated values is a valid TOML basic string
|
|
59
|
+
lines = [
|
|
60
|
+
"# DevGuard user configuration, written by `devguard config init`.",
|
|
61
|
+
"# All options: https://github.com/yilmazhasan/devguard-scanner/blob/main/docs/ai.md",
|
|
62
|
+
"",
|
|
63
|
+
"[ai]",
|
|
64
|
+
f"default_profile = {q(choice.profile)}",
|
|
65
|
+
"# max_cost_usd = 1.0 # per scan; enforced when the cost is known (see price_* below)",
|
|
66
|
+
"# send_excerpts = true # false: send finding metadata only, no code excerpts",
|
|
67
|
+
"",
|
|
68
|
+
f"[ai.profiles.{choice.profile}]",
|
|
69
|
+
f"provider = {q(choice.provider)}",
|
|
70
|
+
f"model = {q(model)}",
|
|
71
|
+
]
|
|
72
|
+
if base_url:
|
|
73
|
+
lines.append(f"base_url = {q(base_url)}")
|
|
74
|
+
if key_env:
|
|
75
|
+
lines.append(f"secrets = {{ api_key = {q('env:' + key_env)} }} # the key is read from ${key_env}")
|
|
76
|
+
if choice.key == "local":
|
|
77
|
+
lines.append("data_stays_local = true # no consent prompt; nothing leaves this machine")
|
|
78
|
+
if choice.provider == "openai":
|
|
79
|
+
lines += [
|
|
80
|
+
"# USD per 1M tokens for this model, so max_cost_usd is enforced:",
|
|
81
|
+
"# price_input_per_mtok = 0.0",
|
|
82
|
+
"# price_output_per_mtok = 0.0",
|
|
83
|
+
]
|
|
84
|
+
return "\n".join(lines) + "\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class InitResult:
|
|
89
|
+
path: Path
|
|
90
|
+
choice: Choice
|
|
91
|
+
key_env: str | None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_init(
|
|
95
|
+
*,
|
|
96
|
+
path: Path | None,
|
|
97
|
+
provider: str | None,
|
|
98
|
+
model: str | None,
|
|
99
|
+
key_env: str | None,
|
|
100
|
+
base_url: str | None,
|
|
101
|
+
force: bool,
|
|
102
|
+
assume_defaults: bool,
|
|
103
|
+
ask: Callable[[str], str] = input,
|
|
104
|
+
out: Callable[[str], None] = print,
|
|
105
|
+
) -> InitResult:
|
|
106
|
+
interactive = not assume_defaults and sys.stdin.isatty()
|
|
107
|
+
target = (path or user_config_dir() / "config.toml").expanduser()
|
|
108
|
+
|
|
109
|
+
def prompt(question: str, default: str) -> str:
|
|
110
|
+
if not interactive:
|
|
111
|
+
return default
|
|
112
|
+
try:
|
|
113
|
+
answer = ask(f"{question} [{default}]: ").strip()
|
|
114
|
+
except EOFError:
|
|
115
|
+
answer = ""
|
|
116
|
+
return answer or default
|
|
117
|
+
|
|
118
|
+
if target.is_dir():
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
f"{target} is a directory (Docker creates one when you mount a config file that does not exist yet). "
|
|
121
|
+
"Remove it, then run this again."
|
|
122
|
+
)
|
|
123
|
+
if target.exists() and not force:
|
|
124
|
+
if not interactive:
|
|
125
|
+
raise ConfigError(f"{target} already exists; pass --force to overwrite it")
|
|
126
|
+
answer = prompt(f"{target} exists. Overwrite it (the old file is kept as {target.name}.bak)? y/n", "n")
|
|
127
|
+
if answer.lower() not in ("y", "yes"):
|
|
128
|
+
raise ConfigError(f"{target} was not changed")
|
|
129
|
+
|
|
130
|
+
if provider is not None:
|
|
131
|
+
if provider not in BY_KEY:
|
|
132
|
+
raise ConfigError(f"unknown provider {provider!r}; choose one of {', '.join(BY_KEY)}")
|
|
133
|
+
choice = BY_KEY[provider]
|
|
134
|
+
else:
|
|
135
|
+
default = default_choice()
|
|
136
|
+
choice = default
|
|
137
|
+
if interactive:
|
|
138
|
+
out("Which AI provider?")
|
|
139
|
+
for i, c in enumerate(CHOICES, 1):
|
|
140
|
+
out(f" {i}) {c.label}{' (default)' if c is default else ''}")
|
|
141
|
+
while True:
|
|
142
|
+
picked = prompt(f"Choose 1-{len(CHOICES)}", str(CHOICES.index(default) + 1))
|
|
143
|
+
if picked.isdigit() and 1 <= int(picked) <= len(CHOICES):
|
|
144
|
+
choice = CHOICES[int(picked) - 1]
|
|
145
|
+
break
|
|
146
|
+
out(f" Please enter a number from 1 to {len(CHOICES)}.")
|
|
147
|
+
|
|
148
|
+
model = model or prompt("Model", choice.model)
|
|
149
|
+
if not _MODEL.match(model):
|
|
150
|
+
raise ConfigError(f"invalid model id {model!r}")
|
|
151
|
+
if choice.key_env:
|
|
152
|
+
key_env = key_env or prompt("Environment variable that holds the API key", choice.key_env)
|
|
153
|
+
if not _ENV.match(key_env):
|
|
154
|
+
raise ConfigError(f"invalid environment variable name {key_env!r}")
|
|
155
|
+
else:
|
|
156
|
+
key_env = None
|
|
157
|
+
if choice.base_url:
|
|
158
|
+
base_url = base_url or prompt("Server URL", choice.base_url)
|
|
159
|
+
else:
|
|
160
|
+
base_url = None
|
|
161
|
+
|
|
162
|
+
text = render(choice, model, key_env, base_url)
|
|
163
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
if target.exists():
|
|
165
|
+
target.with_name(target.name + ".bak").write_bytes(target.read_bytes())
|
|
166
|
+
target.write_text(text, encoding="utf-8")
|
|
167
|
+
return InitResult(target, choice, key_env)
|
|
@@ -116,3 +116,68 @@ def test_example_config_is_valid(tmp_path: Path) -> None:
|
|
|
116
116
|
ai = AIConfig.model_validate(cfg.ai)
|
|
117
117
|
assert set(ai.profiles) == {"claude", "gpt", "local"}
|
|
118
118
|
assert cfg.mcp["allowed_roots"] == ["~/code"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_user_config_falls_back_to_legacy_dir(tmp_path, monkeypatch) -> None:
|
|
122
|
+
from devguard.core.config import user_config_file
|
|
123
|
+
|
|
124
|
+
monkeypatch.setattr("sys.platform", "linux") # XDG_CONFIG_HOME only applies there
|
|
125
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
126
|
+
legacy = tmp_path / "devguard" / "config.toml"
|
|
127
|
+
legacy.parent.mkdir()
|
|
128
|
+
legacy.write_text("[scan]\n")
|
|
129
|
+
assert user_config_file() == legacy
|
|
130
|
+
current = tmp_path / "devguard-scanner" / "config.toml"
|
|
131
|
+
current.parent.mkdir()
|
|
132
|
+
current.write_text("[scan]\n")
|
|
133
|
+
assert user_config_file() == current
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_config_init_defaults_write_a_loadable_profile(tmp_path, monkeypatch) -> None:
|
|
137
|
+
import tomllib
|
|
138
|
+
|
|
139
|
+
from devguard.ai.registry import AIConfig
|
|
140
|
+
|
|
141
|
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
142
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test") # the provider with a key set is the default
|
|
143
|
+
path = tmp_path / "cfg" / "config.toml"
|
|
144
|
+
assert main(["config", "init", "-y", "--path", str(path)]) == 0
|
|
145
|
+
cfg = AIConfig.model_validate(tomllib.loads(path.read_text())["ai"])
|
|
146
|
+
assert cfg.default_profile == "gpt"
|
|
147
|
+
assert cfg.profiles["gpt"].secrets == {"api_key": "env:OPENAI_API_KEY"}
|
|
148
|
+
assert "sk-test" not in path.read_text()
|
|
149
|
+
# an existing file is kept unless --force; --force leaves a backup
|
|
150
|
+
assert main(["config", "init", "-y", "--path", str(path), "--provider", "local"]) == 2
|
|
151
|
+
assert main(["config", "init", "-y", "--path", str(path), "--provider", "local", "--force"]) == 0
|
|
152
|
+
assert "data_stays_local = true" in path.read_text()
|
|
153
|
+
assert "OPENAI_API_KEY" in (tmp_path / "cfg" / "config.toml.bak").read_text()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_config_init_interactive_choices(tmp_path, monkeypatch) -> None:
|
|
157
|
+
from devguard.core.config_init import run_init
|
|
158
|
+
|
|
159
|
+
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
|
|
160
|
+
answers = iter(["9", "1", "", "MY_KEY"]) # invalid pick, Claude, default model, custom key variable
|
|
161
|
+
result = run_init(
|
|
162
|
+
path=tmp_path / "config.toml",
|
|
163
|
+
provider=None,
|
|
164
|
+
model=None,
|
|
165
|
+
key_env=None,
|
|
166
|
+
base_url=None,
|
|
167
|
+
force=False,
|
|
168
|
+
assume_defaults=False,
|
|
169
|
+
ask=lambda _q: next(answers),
|
|
170
|
+
out=lambda _s: None,
|
|
171
|
+
)
|
|
172
|
+
text = result.path.read_text()
|
|
173
|
+
assert 'model = "claude-opus-5-5"' in text and '"env:MY_KEY"' in text
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_single_profile_needs_no_default() -> None:
|
|
177
|
+
from devguard.ai.registry import AIConfig, ProviderError, load_provider
|
|
178
|
+
|
|
179
|
+
one = AIConfig.model_validate({"profiles": {"only": {"provider": "nonexistent-provider"}}})
|
|
180
|
+
with pytest.raises(ProviderError, match="nonexistent-provider"): # got past profile selection
|
|
181
|
+
load_provider(one, None, None)
|
|
182
|
+
with pytest.raises(ProviderError, match="devguard config init"):
|
|
183
|
+
load_provider(AIConfig(), None, None)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/ai/providers/openai_compat.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/__init__.py
RENAMED
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/ai_assistants.py
RENAMED
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/autoload.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/devcontainer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/analyzers/surfaces/python_pkg.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/ai_instructions.yaml
RENAMED
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/cross_language.yaml
RENAMED
|
File without changes
|
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/javascript.yaml
RENAMED
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/content/obfuscation.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{devguard_scanner-0.2.0 → devguard_scanner-0.4.0}/src/devguard/rules/data/vscode_settings.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|