devguard-scanner 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. devguard/__init__.py +5 -0
  2. devguard/ai/__init__.py +0 -0
  3. devguard/ai/investigator.py +380 -0
  4. devguard/ai/prompts.py +113 -0
  5. devguard/ai/providers/__init__.py +0 -0
  6. devguard/ai/providers/anthropic.py +163 -0
  7. devguard/ai/providers/openai_compat.py +157 -0
  8. devguard/ai/registry.py +160 -0
  9. devguard/ai/run.py +119 -0
  10. devguard/ai/sdk.py +102 -0
  11. devguard/ai/testing.py +126 -0
  12. devguard/ai/tools.py +389 -0
  13. devguard/analyzers/__init__.py +0 -0
  14. devguard/analyzers/base.py +135 -0
  15. devguard/analyzers/carriers.py +345 -0
  16. devguard/analyzers/command.py +343 -0
  17. devguard/analyzers/content.py +240 -0
  18. devguard/analyzers/filesystem.py +142 -0
  19. devguard/analyzers/hidden.py +273 -0
  20. devguard/analyzers/imports.py +102 -0
  21. devguard/analyzers/jsonc.py +221 -0
  22. devguard/analyzers/surfaces/__init__.py +0 -0
  23. devguard/analyzers/surfaces/ai_assistants.py +318 -0
  24. devguard/analyzers/surfaces/autoload.py +142 -0
  25. devguard/analyzers/surfaces/common.py +242 -0
  26. devguard/analyzers/surfaces/deps.py +159 -0
  27. devguard/analyzers/surfaces/devcontainer.py +195 -0
  28. devguard/analyzers/surfaces/env.py +53 -0
  29. devguard/analyzers/surfaces/git.py +236 -0
  30. devguard/analyzers/surfaces/npm.py +304 -0
  31. devguard/analyzers/surfaces/python_pkg.py +138 -0
  32. devguard/analyzers/surfaces/vscode.py +515 -0
  33. devguard/cli.py +302 -0
  34. devguard/core/__init__.py +0 -0
  35. devguard/core/config.py +147 -0
  36. devguard/core/filetype.py +313 -0
  37. devguard/core/models.py +195 -0
  38. devguard/core/redact.py +72 -0
  39. devguard/core/safefs.py +280 -0
  40. devguard/core/scoring.py +60 -0
  41. devguard/core/service.py +211 -0
  42. devguard/correlate/__init__.py +0 -0
  43. devguard/correlate/chains.py +160 -0
  44. devguard/mcp/__init__.py +0 -0
  45. devguard/mcp/server.py +442 -0
  46. devguard/report/__init__.py +0 -0
  47. devguard/report/escape.py +31 -0
  48. devguard/report/markdown.py +242 -0
  49. devguard/report/sarif.py +96 -0
  50. devguard/report/terminal.py +47 -0
  51. devguard/report/writer.py +52 -0
  52. devguard/rules/__init__.py +0 -0
  53. devguard/rules/catalog.py +96 -0
  54. devguard/rules/content/ai_instructions.yaml +111 -0
  55. devguard/rules/content/cross_language.yaml +228 -0
  56. devguard/rules/content/docs.yaml +63 -0
  57. devguard/rules/content/javascript.yaml +171 -0
  58. devguard/rules/content/obfuscation.yaml +138 -0
  59. devguard/rules/content/python.yaml +144 -0
  60. devguard/rules/content/shell.yaml +109 -0
  61. devguard/rules/content/windows.yaml +95 -0
  62. devguard/rules/data/vscode_settings.yaml +81 -0
  63. devguard_scanner-0.1.0.dist-info/METADATA +84 -0
  64. devguard_scanner-0.1.0.dist-info/RECORD +67 -0
  65. devguard_scanner-0.1.0.dist-info/WHEEL +4 -0
  66. devguard_scanner-0.1.0.dist-info/entry_points.txt +7 -0
  67. devguard_scanner-0.1.0.dist-info/licenses/LICENSE +201 -0
devguard/cli.py ADDED
@@ -0,0 +1,302 @@
1
+ """DevGuard command-line interface. Files in the scanned project are analyzed as data and never executed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from devguard import RULESET_VERSION, __version__
11
+ from devguard.core.config import ConfigError, default_output_dir, is_within, load_config, parse_size
12
+ from devguard.core.models import Severity
13
+
14
+ EXIT_OK, EXIT_FINDINGS, EXIT_USAGE, EXIT_PARTIAL, EXIT_AI = 0, 1, 2, 3, 4
15
+
16
+ DESCRIPTION = (
17
+ "DevGuard scans an untrusted code workspace BEFORE you open, install, or run it. "
18
+ "Files are analyzed as data and are never executed."
19
+ )
20
+
21
+
22
+ def _parser() -> argparse.ArgumentParser:
23
+ p = argparse.ArgumentParser(prog="devguard", description=DESCRIPTION)
24
+ p.add_argument("--version", action="version", version=f"devguard {__version__} (ruleset {RULESET_VERSION})")
25
+ sub = p.add_subparsers(dest="command", required=True)
26
+
27
+ s = sub.add_parser("scan", help="Scan a directory (read-only; nothing is executed).", description=DESCRIPTION)
28
+ s.add_argument("path", type=Path)
29
+ s.add_argument("--output-dir", type=Path, help="Report directory (default: per-user data dir, outside the target).")
30
+ s.add_argument(
31
+ "--format",
32
+ action="append",
33
+ choices=["md", "json", "sarif"],
34
+ dest="formats",
35
+ help="Report format; repeatable (default: md and json).",
36
+ )
37
+ s.add_argument("--json", action="store_true", help="Print the JSON report to stdout (summary goes to stderr).")
38
+ s.add_argument("--config", type=Path, help="Config file (must be outside the target).")
39
+ s.add_argument("--exclude", action="append", default=[], help="Glob to exclude; repeatable.")
40
+ s.add_argument("--include-vendored", action="store_true", help="Deep-scan node_modules, vendor, virtualenvs.")
41
+ s.add_argument("--max-file-size", help="Per-file read limit, e.g. 2M.")
42
+ s.add_argument("--max-files", type=int)
43
+ s.add_argument("--timeout", type=float, help="Scan time budget in seconds.")
44
+ s.add_argument("--fail-on", choices=[v.value for v in Severity if v is not Severity.INFO])
45
+ s.add_argument(
46
+ "--allow-output-in-target",
47
+ action="store_true",
48
+ help="Permit an output directory inside the target (it is then excluded from the scan).",
49
+ )
50
+ s.add_argument("-v", "--verbose", action="store_true")
51
+ s.add_argument("-q", "--quiet", action="store_true")
52
+ ai = s.add_argument_group("AI investigation (optional, off by default)")
53
+ ai.add_argument("--ai", action="store_true", help="Run the AI investigator after the deterministic scan.")
54
+ ai.add_argument("--ai-profile", help="AI profile name from your user config.")
55
+ ai.add_argument("--no-ai-tools", action="store_true", help="AI may only annotate findings; no file access.")
56
+ ai.add_argument("--yes-send-code", action="store_true", help="Consent to sending redacted excerpts (CI).")
57
+ ai.add_argument("--ai-required", action="store_true", help="Exit 4 if the AI stage cannot complete.")
58
+ ai.add_argument("--no-ai-cache", action="store_true")
59
+
60
+ r = sub.add_parser("rules", help="List the rule catalogue.")
61
+ r.add_argument("--json", action="store_true")
62
+ e = sub.add_parser("explain", help="Explain a rule.")
63
+ e.add_argument("rule_id")
64
+
65
+ a = sub.add_parser("ai", help="AI provider utilities.")
66
+ asub = a.add_subparsers(dest="ai_command", required=True)
67
+ asub.add_parser("providers", help="List installed providers and configured profiles.")
68
+ t = asub.add_parser("test", help="Run plugin contract checks against a profile (synthetic data only).")
69
+ t.add_argument("--ai-profile")
70
+ t.add_argument("--config", type=Path)
71
+
72
+ m = sub.add_parser("mcp", help="Model Context Protocol server.")
73
+ msub = m.add_subparsers(dest="mcp_command", required=True)
74
+ serve = msub.add_parser("serve", help="Serve DevGuard tools over MCP (stdio).")
75
+ serve.add_argument("--root", action="append", type=Path, default=[], help="Allowed scan root; repeatable.")
76
+ serve.add_argument(
77
+ "--expose-investigation-tools",
78
+ action="store_true",
79
+ help="Also expose read_file/search/decode/file_info (read-only).",
80
+ )
81
+ serve.add_argument("--config", type=Path)
82
+
83
+ sub.add_parser("doctor", help="Check the installation and configuration.")
84
+ return p
85
+
86
+
87
+ def main(argv: list[str] | None = None) -> int:
88
+ args = _parser().parse_args(argv)
89
+ try:
90
+ if args.command == "scan":
91
+ return _scan(args)
92
+ if args.command == "rules":
93
+ return _rules(args)
94
+ if args.command == "explain":
95
+ return _explain(args)
96
+ if args.command == "ai":
97
+ return _ai(args)
98
+ if args.command == "mcp":
99
+ from devguard.mcp.server import serve
100
+
101
+ cfg = load_config(args.config, None)
102
+ roots = args.root or [Path(p) for p in cfg.mcp.get("allowed_roots", [])]
103
+ return serve(roots, expose_investigation=args.expose_investigation_tools, config=cfg)
104
+ if args.command == "doctor":
105
+ return _doctor()
106
+ except ConfigError as exc:
107
+ print(f"devguard: configuration error: {exc}", file=sys.stderr)
108
+ return EXIT_USAGE
109
+ return EXIT_USAGE
110
+
111
+
112
+ def _scan(args: argparse.Namespace) -> int:
113
+ from devguard.core.service import ScanError, ScanRequest, scan_detailed
114
+ from devguard.report.terminal import print_summary
115
+ from devguard.report.writer import render, write_reports
116
+
117
+ target: Path = args.path
118
+ if not target.exists() or not target.is_dir():
119
+ print(f"devguard: not a directory: {target}", file=sys.stderr)
120
+ return EXIT_USAGE
121
+ limits = {
122
+ "max_file_size": parse_size(args.max_file_size) if args.max_file_size else None,
123
+ "max_files": args.max_files,
124
+ "timeout_s": args.timeout,
125
+ }
126
+ overrides = {
127
+ "exclude": args.exclude or None,
128
+ "include_vendored": args.include_vendored or None,
129
+ "fail_on": args.fail_on,
130
+ "output_dir": args.output_dir,
131
+ "formats": args.formats,
132
+ "limits": limits,
133
+ }
134
+ out_dir = args.output_dir or None
135
+ if out_dir is not None and is_within(out_dir, target) and not args.allow_output_in_target:
136
+ print(
137
+ "devguard: --output-dir is inside the scan target; pass --allow-output-in-target to permit it",
138
+ file=sys.stderr,
139
+ )
140
+ return EXIT_USAGE
141
+ if out_dir is not None and is_within(out_dir, target):
142
+ overrides["output_dir"] = None # validated above; config check would refuse it
143
+ cfg = load_config(args.config, target, overrides)
144
+ out_dir = out_dir or cfg.output_dir or default_output_dir(target)
145
+ skip = [out_dir] if is_within(out_dir, target) else []
146
+ try:
147
+ outcome = scan_detailed(ScanRequest(target=target, config=cfg, skip_dirs=skip))
148
+ except ScanError as exc:
149
+ print(f"devguard: {exc}", file=sys.stderr)
150
+ return EXIT_USAGE
151
+ result = outcome.result
152
+ if args.ai:
153
+ from devguard.ai.run import interactive_ask, run_ai
154
+
155
+ result.ai = run_ai(
156
+ outcome,
157
+ cfg.ai,
158
+ profile=args.ai_profile,
159
+ tools_enabled=not args.no_ai_tools,
160
+ assume_consent=args.yes_send_code,
161
+ use_cache=not args.no_ai_cache,
162
+ ask=interactive_ask,
163
+ )
164
+ formats = cfg.formats or ["md", "json"]
165
+ paths = write_reports(result, out_dir, formats, cfg.keep_runs)
166
+ summary_stream = sys.stderr if args.json else sys.stdout
167
+ if args.json:
168
+ sys.stdout.write(render(result, "json"))
169
+ if not args.quiet:
170
+ print_summary(result, paths, summary_stream, verbose=args.verbose)
171
+ if args.ai and args.ai_required and result.ai.status != "completed":
172
+ return EXIT_AI
173
+ if any(f.severity.rank >= cfg.fail_on.rank for f in result.findings):
174
+ return EXIT_FINDINGS
175
+ if result.status != "complete":
176
+ return EXIT_PARTIAL
177
+ return EXIT_OK
178
+
179
+
180
+ def _rules(args: argparse.Namespace) -> int:
181
+ from devguard.core.service import default_analyzers
182
+ from devguard.rules.catalog import CATALOG
183
+
184
+ default_analyzers() # registers every rule
185
+ import devguard.correlate.chains # noqa: F401 - registers chain rules
186
+
187
+ metas = sorted(CATALOG.values(), key=lambda m: m.id)
188
+ if args.json:
189
+ print(
190
+ json.dumps(
191
+ [
192
+ {
193
+ "id": m.id,
194
+ "title": m.title,
195
+ "severity": m.severity.value,
196
+ "confidence": m.confidence.value,
197
+ "category": m.category,
198
+ "threats": list(m.threat_refs),
199
+ }
200
+ for m in metas
201
+ ],
202
+ indent=2,
203
+ )
204
+ )
205
+ return EXIT_OK
206
+ for m in metas:
207
+ print(f"{m.id:<36} {m.severity.value:<8} {m.title}")
208
+ print(f"\n{len(metas)} rules (ruleset {RULESET_VERSION})")
209
+ return EXIT_OK
210
+
211
+
212
+ def _explain(args: argparse.Namespace) -> int:
213
+ from devguard.core.service import default_analyzers
214
+ from devguard.rules.catalog import CATALOG
215
+
216
+ default_analyzers()
217
+ import devguard.correlate.chains # noqa: F401
218
+
219
+ m = CATALOG.get(args.rule_id)
220
+ if m is None:
221
+ print(f"devguard: unknown rule {args.rule_id}", file=sys.stderr)
222
+ return EXIT_USAGE
223
+ print(f"{m.id}: {m.title}\n")
224
+ print(f"Severity: {m.severity.value} Confidence: {m.confidence.value} Category: {m.category}")
225
+ print(f"Threat catalog: {', '.join(m.threat_refs)} Defined in: {m.origin}\n")
226
+ print(f"Why it matters:\n {m.explanation}\n")
227
+ if m.benign_context:
228
+ print(f"Could be benign if:\n {m.benign_context}\n")
229
+ print(f"Next step:\n {m.next_step}")
230
+ return EXIT_OK
231
+
232
+
233
+ def _ai(args: argparse.Namespace) -> int:
234
+ from devguard.ai.registry import AIConfig, ProviderError, available_entry_points, load_provider
235
+
236
+ cfg = load_config(getattr(args, "config", None), None)
237
+ try:
238
+ ai_cfg = AIConfig.model_validate(cfg.ai)
239
+ except Exception as exc:
240
+ print(f"devguard: invalid [ai] configuration: {exc}", file=sys.stderr)
241
+ return EXIT_USAGE
242
+ if args.ai_command == "providers":
243
+ print("Installed providers:")
244
+ for name, origin in sorted(available_entry_points().items()):
245
+ print(f" {name:<20} {origin}")
246
+ print("\nConfigured profiles:")
247
+ if not ai_cfg.profiles:
248
+ print(" none (add [ai.profiles.NAME] to your user config)")
249
+ for name, prof in sorted(ai_cfg.profiles.items()):
250
+ default = " (default)" if name == ai_cfg.default_profile else ""
251
+ where = prof.plugin if prof.provider == "python" else prof.provider
252
+ print(f" {name:<20} {where} model={prof.model or '-'}{default}")
253
+ return EXIT_OK
254
+ from devguard.ai.testing import run_contract
255
+
256
+ try:
257
+ loaded = load_provider(ai_cfg, args.ai_profile, None)
258
+ except ProviderError as exc:
259
+ print(f"devguard: {exc}", file=sys.stderr)
260
+ return EXIT_USAGE
261
+ print(f"Testing profile {loaded.profile_name} ({loaded.origin}) with synthetic data only…")
262
+ report = run_contract(loaded.provider, loaded.profile.model)
263
+ for p in report.passed:
264
+ print(f" PASS {p}")
265
+ for n in report.notes:
266
+ print(f" NOTE {n}")
267
+ for f in report.failures:
268
+ print(f" FAIL {f}")
269
+ return EXIT_OK if report.ok else EXIT_FINDINGS
270
+
271
+
272
+ def _doctor() -> int:
273
+ import importlib.util
274
+ import platform
275
+
276
+ from devguard.ai.registry import AIConfig, available_entry_points
277
+ from devguard.core.config import user_config_dir
278
+
279
+ print(f"devguard {__version__}, ruleset {RULESET_VERSION}, Python {platform.python_version()}")
280
+ cfg_file = user_config_dir() / "config.toml"
281
+ print(f"User config: {cfg_file} ({'found' if cfg_file.is_file() else 'not found'})")
282
+ try:
283
+ cfg = load_config(None, None)
284
+ ai_cfg = AIConfig.model_validate(cfg.ai)
285
+ print(f"AI profiles: {', '.join(sorted(ai_cfg.profiles)) or 'none'}")
286
+ import os
287
+
288
+ for name, prof in sorted(ai_cfg.profiles.items()):
289
+ for key, ref in prof.secrets.items():
290
+ state = "set" if ref.startswith("env:") and os.environ.get(ref[4:]) else "NOT SET"
291
+ print(f" {name}.{key} ({ref.split(':')[0]}): {state}")
292
+ except Exception as exc:
293
+ print(f"Configuration problem: {exc}")
294
+ for mod, extra in (("anthropic", "anthropic"), ("openai", "openai"), ("mcp", "mcp")):
295
+ ok = importlib.util.find_spec(mod) is not None
296
+ print(f"Optional extra [{extra}]: {'installed' if ok else 'not installed'}")
297
+ print("Providers: " + ", ".join(sorted(available_entry_points())))
298
+ return EXIT_OK
299
+
300
+
301
+ if __name__ == "__main__": # pragma: no cover
302
+ sys.exit(main())
File without changes
@@ -0,0 +1,147 @@
1
+ """Scan configuration.
2
+
3
+ Sources, lowest to highest precedence: defaults → user config file → --config file → CLI flags.
4
+ Files inside the scan target are never read as configuration (SAFE-011, CFG-001).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import sys
12
+ import tomllib
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from devguard.core.models import Severity
19
+
20
+
21
+ class ConfigError(Exception):
22
+ """Invalid configuration or a configuration path that violates policy."""
23
+
24
+
25
+ class Limits(BaseModel):
26
+ max_file_size: int = 2 * 1024 * 1024
27
+ max_files: int = 50_000
28
+ max_total_bytes: int = 500 * 1024 * 1024
29
+ max_depth: int = 40
30
+ timeout_s: float = 300.0
31
+ max_regex_text: int = 1024 * 1024
32
+
33
+
34
+ class ScanConfig(BaseModel):
35
+ limits: Limits = Field(default_factory=Limits)
36
+ exclude: list[str] = Field(default_factory=list)
37
+ include_vendored: bool = False
38
+ fail_on: Severity = Severity.HIGH
39
+ output_dir: Path | None = None
40
+ formats: list[str] = Field(default_factory=lambda: ["md", "json"])
41
+ keep_runs: bool = True
42
+ ai: dict[str, Any] = Field(default_factory=dict)
43
+ mcp: dict[str, Any] = Field(default_factory=dict)
44
+
45
+ def summary(self) -> dict[str, Any]:
46
+ return {
47
+ "limits": self.limits.model_dump(),
48
+ "user_exclusions": list(self.exclude),
49
+ "include_vendored": self.include_vendored,
50
+ "fail_on": self.fail_on.value,
51
+ }
52
+
53
+
54
+ def user_config_dir() -> Path:
55
+ if sys.platform == "win32":
56
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
57
+ return Path(base) / "devguard"
58
+ if sys.platform == "darwin":
59
+ return Path.home() / "Library" / "Application Support" / "devguard"
60
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "devguard"
61
+
62
+
63
+ def user_data_dir() -> Path:
64
+ if sys.platform == "win32":
65
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
66
+ return Path(base) / "devguard"
67
+ if sys.platform == "darwin":
68
+ return Path.home() / "Library" / "Application Support" / "devguard"
69
+ return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "devguard"
70
+
71
+
72
+ def is_within(path: Path, root: Path) -> bool:
73
+ try:
74
+ path.resolve().relative_to(root.resolve())
75
+ except ValueError:
76
+ return False
77
+ return True
78
+
79
+
80
+ def default_output_dir(target: Path) -> Path:
81
+ resolved = target.resolve()
82
+ digest = hashlib.sha256(str(resolved).encode()).hexdigest()[:8]
83
+ return user_data_dir() / "reports" / f"{resolved.name or 'root'}-{digest}"
84
+
85
+
86
+ def _read_toml(path: Path, target: Path | None) -> dict[str, Any]:
87
+ if target is not None and is_within(path, target):
88
+ raise ConfigError(
89
+ f"Refusing to read configuration from inside the scan target: {path}. "
90
+ "A scanned repository must not be able to change DevGuard's policy."
91
+ )
92
+ try:
93
+ with path.open("rb") as fh:
94
+ return tomllib.load(fh)
95
+ except FileNotFoundError as exc:
96
+ raise ConfigError(f"Config file not found: {path}") from exc
97
+ except tomllib.TOMLDecodeError as exc:
98
+ raise ConfigError(f"Invalid TOML in {path}: {exc}") from exc
99
+
100
+
101
+ def load_config(
102
+ explicit: Path | None,
103
+ target: Path | None,
104
+ overrides: dict[str, Any] | None = None,
105
+ ) -> ScanConfig:
106
+ data: dict[str, Any] = {}
107
+ user_file = user_config_dir() / "config.toml"
108
+ if user_file.is_file():
109
+ data = _merge(data, _read_toml(user_file, target))
110
+ if explicit is not None:
111
+ data = _merge(data, _read_toml(explicit.expanduser(), target))
112
+ scan_section = data.get("scan", {})
113
+ merged: dict[str, Any] = {
114
+ **scan_section,
115
+ "ai": data.get("ai", {}),
116
+ "mcp": data.get("mcp", {}),
117
+ }
118
+ if overrides:
119
+ limits_over = overrides.pop("limits", None)
120
+ merged = _merge(merged, {k: v for k, v in overrides.items() if v is not None})
121
+ if limits_over:
122
+ merged["limits"] = _merge(merged.get("limits", {}), {k: v for k, v in limits_over.items() if v})
123
+ try:
124
+ cfg = ScanConfig.model_validate(merged)
125
+ except Exception as exc:
126
+ raise ConfigError(f"Invalid configuration: {exc}") from exc
127
+ if target is not None and cfg.output_dir is not None and is_within(cfg.output_dir, target):
128
+ raise ConfigError("Output directory must be outside the scan target.")
129
+ return cfg
130
+
131
+
132
+ def _merge(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
133
+ out = dict(base)
134
+ for key, value in extra.items():
135
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
136
+ out[key] = _merge(out[key], value)
137
+ else:
138
+ out[key] = value
139
+ return out
140
+
141
+
142
+ def parse_size(text: str) -> int:
143
+ units = {"k": 1024, "m": 1024**2, "g": 1024**3}
144
+ t = text.strip().lower().removesuffix("b")
145
+ if t and t[-1] in units:
146
+ return int(float(t[:-1]) * units[t[-1]])
147
+ return int(t)