devdoctor-cli 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.
- devdoctor/__init__.py +40 -0
- devdoctor/__main__.py +7 -0
- devdoctor/api.py +96 -0
- devdoctor/checks/__init__.py +38 -0
- devdoctor/checks/base.py +69 -0
- devdoctor/checks/dependencies.py +125 -0
- devdoctor/checks/django.py +128 -0
- devdoctor/checks/docker.py +124 -0
- devdoctor/checks/environment.py +146 -0
- devdoctor/checks/fastapi.py +96 -0
- devdoctor/checks/filesystem.py +72 -0
- devdoctor/checks/git.py +151 -0
- devdoctor/checks/imports.py +47 -0
- devdoctor/checks/pip.py +67 -0
- devdoctor/checks/ports.py +90 -0
- devdoctor/checks/postgres.py +65 -0
- devdoctor/checks/python.py +105 -0
- devdoctor/checks/redis.py +70 -0
- devdoctor/checks/virtualenv.py +79 -0
- devdoctor/cli.py +319 -0
- devdoctor/fixer.py +160 -0
- devdoctor/models.py +257 -0
- devdoctor/plugin.py +89 -0
- devdoctor/project.py +223 -0
- devdoctor/reporter.py +193 -0
- devdoctor/scanner.py +98 -0
- devdoctor/utils/__init__.py +1 -0
- devdoctor/utils/ast_imports.py +205 -0
- devdoctor/utils/env_parser.py +57 -0
- devdoctor/utils/platform_info.py +70 -0
- devdoctor/utils/process_utils.py +167 -0
- devdoctor_cli-0.1.0.dist-info/METADATA +382 -0
- devdoctor_cli-0.1.0.dist-info/RECORD +37 -0
- devdoctor_cli-0.1.0.dist-info/WHEEL +5 -0
- devdoctor_cli-0.1.0.dist-info/entry_points.txt +2 -0
- devdoctor_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- devdoctor_cli-0.1.0.dist-info/top_level.txt +1 -0
devdoctor/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""DevDoctor: Deterministic developer machine and project diagnostic doctor."""
|
|
2
|
+
|
|
3
|
+
from devdoctor.api import DevDoctor
|
|
4
|
+
from devdoctor.checks.base import Check, check, get_registered_checks, register_check
|
|
5
|
+
from devdoctor.fixer import FixAction, FixPlan, FixResult, Fixer
|
|
6
|
+
from devdoctor.models import (
|
|
7
|
+
Confidence,
|
|
8
|
+
Diagnostic,
|
|
9
|
+
DiagnosticReport,
|
|
10
|
+
ProjectInfo,
|
|
11
|
+
ScanContext,
|
|
12
|
+
Severity,
|
|
13
|
+
)
|
|
14
|
+
from devdoctor.project import detect_project
|
|
15
|
+
from devdoctor.reporter import Reporter
|
|
16
|
+
from devdoctor.scanner import Scanner
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DevDoctor",
|
|
22
|
+
"Diagnostic",
|
|
23
|
+
"DiagnosticReport",
|
|
24
|
+
"FixAction",
|
|
25
|
+
"FixPlan",
|
|
26
|
+
"FixResult",
|
|
27
|
+
"Fixer",
|
|
28
|
+
"ProjectInfo",
|
|
29
|
+
"ScanContext",
|
|
30
|
+
"Severity",
|
|
31
|
+
"Confidence",
|
|
32
|
+
"Check",
|
|
33
|
+
"check",
|
|
34
|
+
"register_check",
|
|
35
|
+
"get_registered_checks",
|
|
36
|
+
"detect_project",
|
|
37
|
+
"Reporter",
|
|
38
|
+
"Scanner",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
devdoctor/__main__.py
ADDED
devdoctor/api.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Public Python API for DevDoctor programmatic integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Set, Union
|
|
7
|
+
|
|
8
|
+
from devdoctor.checks.base import Check, check, get_registered_checks, register_check
|
|
9
|
+
from devdoctor.fixer import FixResult, Fixer
|
|
10
|
+
from devdoctor.models import (
|
|
11
|
+
Confidence,
|
|
12
|
+
Diagnostic,
|
|
13
|
+
DiagnosticReport,
|
|
14
|
+
FixAction,
|
|
15
|
+
ProjectInfo,
|
|
16
|
+
ScanContext,
|
|
17
|
+
Severity,
|
|
18
|
+
)
|
|
19
|
+
from devdoctor.project import detect_project
|
|
20
|
+
from devdoctor.reporter import Reporter
|
|
21
|
+
from devdoctor.scanner import Scanner
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DevDoctor:
|
|
25
|
+
"""High-level Python API for running DevDoctor scans, fixes, and inspections programmatically."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
project_path: Union[str, Path] = ".",
|
|
30
|
+
custom_checks_dir: Optional[Union[str, Path]] = None,
|
|
31
|
+
):
|
|
32
|
+
self.project_path = Path(project_path).resolve()
|
|
33
|
+
self.custom_checks_dir = Path(custom_checks_dir).resolve() if custom_checks_dir else None
|
|
34
|
+
self.scanner = Scanner(self.project_path, self.custom_checks_dir)
|
|
35
|
+
self.reporter = Reporter()
|
|
36
|
+
self.fixer = Fixer()
|
|
37
|
+
|
|
38
|
+
def detect_project(self) -> ProjectInfo:
|
|
39
|
+
"""Analyze project files and detect framework/technologies."""
|
|
40
|
+
return detect_project(self.project_path)
|
|
41
|
+
|
|
42
|
+
def scan(
|
|
43
|
+
self,
|
|
44
|
+
categories: Optional[List[str]] = None,
|
|
45
|
+
check_names: Optional[List[str]] = None,
|
|
46
|
+
include_frameworks: bool = True,
|
|
47
|
+
) -> DiagnosticReport:
|
|
48
|
+
"""Run diagnostic scan and return structured report."""
|
|
49
|
+
cat_set = set(categories) if categories else None
|
|
50
|
+
chk_set = set(check_names) if check_names else None
|
|
51
|
+
return self.scanner.scan(
|
|
52
|
+
categories=cat_set,
|
|
53
|
+
check_names=chk_set,
|
|
54
|
+
include_frameworks=include_frameworks,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def fix(
|
|
58
|
+
self,
|
|
59
|
+
safe_only: bool = False,
|
|
60
|
+
dry_run: bool = False,
|
|
61
|
+
auto_yes: bool = True,
|
|
62
|
+
interactive: bool = False,
|
|
63
|
+
) -> FixResult:
|
|
64
|
+
"""Generate and execute fixes for current project diagnostics."""
|
|
65
|
+
report = self.scan()
|
|
66
|
+
context = self.scanner.create_context()
|
|
67
|
+
plan = self.fixer.build_plan(report)
|
|
68
|
+
return self.fixer.execute_plan(
|
|
69
|
+
plan=plan,
|
|
70
|
+
context=context,
|
|
71
|
+
safe_only=safe_only,
|
|
72
|
+
dry_run=dry_run,
|
|
73
|
+
auto_yes=auto_yes,
|
|
74
|
+
interactive=interactive,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def free_port(self, port: int, auto_yes: bool = True) -> bool:
|
|
78
|
+
"""Terminate process occupying the specified port."""
|
|
79
|
+
return self.fixer.free_port_interactive(port, auto_yes=auto_yes)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"DevDoctor",
|
|
84
|
+
"Diagnostic",
|
|
85
|
+
"DiagnosticReport",
|
|
86
|
+
"FixAction",
|
|
87
|
+
"ProjectInfo",
|
|
88
|
+
"ScanContext",
|
|
89
|
+
"Severity",
|
|
90
|
+
"Confidence",
|
|
91
|
+
"Check",
|
|
92
|
+
"check",
|
|
93
|
+
"register_check",
|
|
94
|
+
"get_registered_checks",
|
|
95
|
+
"FixResult",
|
|
96
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""DevDoctor built-in diagnostic checks."""
|
|
2
|
+
|
|
3
|
+
from devdoctor.checks.base import Check, check, get_registered_checks, register_check
|
|
4
|
+
from devdoctor.checks.dependencies import DependencyConflictsCheck
|
|
5
|
+
from devdoctor.checks.django import DjangoCheck
|
|
6
|
+
from devdoctor.checks.docker import DockerCheck
|
|
7
|
+
from devdoctor.checks.environment import EnvironmentCheck
|
|
8
|
+
from devdoctor.checks.fastapi import FastAPICheck
|
|
9
|
+
from devdoctor.checks.filesystem import FilesystemCheck
|
|
10
|
+
from devdoctor.checks.git import GitCheck
|
|
11
|
+
from devdoctor.checks.imports import BrokenImportsCheck
|
|
12
|
+
from devdoctor.checks.pip import PipAvailabilityCheck
|
|
13
|
+
from devdoctor.checks.ports import PortConflictsCheck
|
|
14
|
+
from devdoctor.checks.postgres import PostgresCheck
|
|
15
|
+
from devdoctor.checks.python import PythonVersionCheck
|
|
16
|
+
from devdoctor.checks.redis import RedisCheck
|
|
17
|
+
from devdoctor.checks.virtualenv import VirtualenvCheck
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Check",
|
|
21
|
+
"check",
|
|
22
|
+
"register_check",
|
|
23
|
+
"get_registered_checks",
|
|
24
|
+
"PythonVersionCheck",
|
|
25
|
+
"PipAvailabilityCheck",
|
|
26
|
+
"VirtualenvCheck",
|
|
27
|
+
"GitCheck",
|
|
28
|
+
"EnvironmentCheck",
|
|
29
|
+
"PortConflictsCheck",
|
|
30
|
+
"FilesystemCheck",
|
|
31
|
+
"BrokenImportsCheck",
|
|
32
|
+
"DependencyConflictsCheck",
|
|
33
|
+
"DjangoCheck",
|
|
34
|
+
"FastAPICheck",
|
|
35
|
+
"DockerCheck",
|
|
36
|
+
"PostgresCheck",
|
|
37
|
+
"RedisCheck",
|
|
38
|
+
]
|
devdoctor/checks/base.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Base Check interface and check decorator for DevDoctor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
|
6
|
+
|
|
7
|
+
from devdoctor.models import Diagnostic, ScanContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Check:
|
|
11
|
+
"""Base class for all DevDoctor diagnostic checks."""
|
|
12
|
+
name: str = "base"
|
|
13
|
+
title: str = "Base Check"
|
|
14
|
+
description: str = ""
|
|
15
|
+
category: str = "system" # system, environment, git, network, dependencies, framework, filesystem
|
|
16
|
+
enabled: bool = True
|
|
17
|
+
|
|
18
|
+
def run(self, context: ScanContext) -> Union[Diagnostic, List[Diagnostic], None]:
|
|
19
|
+
"""Execute the check and return one or more Diagnostics."""
|
|
20
|
+
raise NotImplementedError("Subclasses must implement run(context)")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_GLOBAL_CHECK_REGISTRY: Dict[str, Type[Check]] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_check(check_cls: Type[Check]) -> Type[Check]:
|
|
27
|
+
"""Register a Check class in the global registry."""
|
|
28
|
+
if hasattr(check_cls, "name") and check_cls.name:
|
|
29
|
+
_GLOBAL_CHECK_REGISTRY[check_cls.name] = check_cls
|
|
30
|
+
return check_cls
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_registered_checks() -> List[Check]:
|
|
34
|
+
"""Return instantiated instances of all registered checks."""
|
|
35
|
+
return [cls() for cls in _GLOBAL_CHECK_REGISTRY.values() if cls.enabled]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check(
|
|
39
|
+
name: str,
|
|
40
|
+
title: Optional[str] = None,
|
|
41
|
+
category: str = "custom",
|
|
42
|
+
description: str = "",
|
|
43
|
+
) -> Callable[[Callable[[ScanContext], Any]], Type[Check]]:
|
|
44
|
+
"""Decorator to convert a simple function into a registered DevDoctor Check.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
@check("my_api", title="My API Check")
|
|
48
|
+
def check_my_api(context: ScanContext) -> Diagnostic | None:
|
|
49
|
+
...
|
|
50
|
+
"""
|
|
51
|
+
def decorator(fn: Callable[[ScanContext], Any]) -> Type[Check]:
|
|
52
|
+
check_name = name
|
|
53
|
+
check_title = title or name.replace("_", " ").title()
|
|
54
|
+
check_category = category
|
|
55
|
+
check_desc = description or (fn.__doc__ or "")
|
|
56
|
+
|
|
57
|
+
class FunctionalCheck(Check):
|
|
58
|
+
def run(self, context: ScanContext) -> Any:
|
|
59
|
+
return fn(context)
|
|
60
|
+
|
|
61
|
+
FunctionalCheck.name = check_name
|
|
62
|
+
FunctionalCheck.title = check_title
|
|
63
|
+
FunctionalCheck.category = check_category
|
|
64
|
+
FunctionalCheck.description = check_desc
|
|
65
|
+
|
|
66
|
+
register_check(FunctionalCheck)
|
|
67
|
+
return FunctionalCheck
|
|
68
|
+
|
|
69
|
+
return decorator
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Check Python dependency conflicts and package requirements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
12
|
+
from packaging.version import InvalidVersion, Version
|
|
13
|
+
|
|
14
|
+
from devdoctor.checks.base import Check, register_check
|
|
15
|
+
from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@register_check
|
|
19
|
+
class DependencyConflictsCheck(Check):
|
|
20
|
+
name = "dependency_conflicts"
|
|
21
|
+
title = "Dependency Conflicts"
|
|
22
|
+
category = "dependencies"
|
|
23
|
+
description = "Checks for package version conflicts, broken requirements, and pip check incompatibilities."
|
|
24
|
+
|
|
25
|
+
def run(self, context: ScanContext) -> List[Diagnostic]:
|
|
26
|
+
diagnostics: List[Diagnostic] = []
|
|
27
|
+
|
|
28
|
+
# 1. Run pip check for active environment consistency
|
|
29
|
+
try:
|
|
30
|
+
res = subprocess.run(
|
|
31
|
+
[sys.executable, "-m", "pip", "check"],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
timeout=10,
|
|
35
|
+
)
|
|
36
|
+
if res.returncode != 0 and res.stdout.strip():
|
|
37
|
+
for line in res.stdout.strip().splitlines():
|
|
38
|
+
line = line.strip()
|
|
39
|
+
if not line:
|
|
40
|
+
continue
|
|
41
|
+
# Format: "pkgA 1.0.0 has requirement pkgB<3.0, but you have pkgB 3.1.0."
|
|
42
|
+
match = re.match(r"^(\S+)\s+(\S+)\s+has requirement\s+([^,]+),\s+but you have\s+(\S+)\s+(\S+)\.?", line)
|
|
43
|
+
if match:
|
|
44
|
+
parent_pkg = match.group(1)
|
|
45
|
+
req_spec = match.group(3).strip()
|
|
46
|
+
child_pkg = match.group(4)
|
|
47
|
+
installed_ver = match.group(5).rstrip(".")
|
|
48
|
+
|
|
49
|
+
# Suggested action
|
|
50
|
+
cmd = f'pip install "{req_spec}"'
|
|
51
|
+
action = f"Install a compatible version of {child_pkg} matching {req_spec}."
|
|
52
|
+
|
|
53
|
+
evidence = (
|
|
54
|
+
f"{parent_pkg} requires:\n {req_spec}\n\n "
|
|
55
|
+
f"Installed:\n {child_pkg} {installed_ver}\n\n "
|
|
56
|
+
f"Suggested action:\n {action}\n\n "
|
|
57
|
+
f"Run:\n {cmd}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
diagnostics.append(Diagnostic(
|
|
61
|
+
title="Dependency conflict",
|
|
62
|
+
severity=Severity.ERROR,
|
|
63
|
+
message=f"{parent_pkg} requires {req_spec}, but {child_pkg} {installed_ver} is installed.",
|
|
64
|
+
why=f"Incompatible version of {child_pkg} installed in the environment.",
|
|
65
|
+
evidence=evidence,
|
|
66
|
+
fix=f"Run:\n {cmd}",
|
|
67
|
+
command=cmd,
|
|
68
|
+
confidence=Confidence.HIGH,
|
|
69
|
+
fix_action=FixAction(
|
|
70
|
+
description=f"Align {child_pkg} with {req_spec}",
|
|
71
|
+
command=cmd,
|
|
72
|
+
safe=False,
|
|
73
|
+
),
|
|
74
|
+
check_id=f"{self.name}_{parent_pkg}_{child_pkg}",
|
|
75
|
+
metadata={"parent": parent_pkg, "child": child_pkg, "spec": req_spec, "installed": installed_ver},
|
|
76
|
+
))
|
|
77
|
+
else:
|
|
78
|
+
diagnostics.append(Diagnostic(
|
|
79
|
+
title="Dependency conflict",
|
|
80
|
+
severity=Severity.ERROR,
|
|
81
|
+
message=line,
|
|
82
|
+
why="pip check detected an unsatisfied package requirement.",
|
|
83
|
+
evidence=line,
|
|
84
|
+
fix="Resolve dependencies using pip install.",
|
|
85
|
+
confidence=Confidence.HIGH,
|
|
86
|
+
check_id=f"{self.name}_pip_check",
|
|
87
|
+
))
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
# 2. Check project declared requirements against installed packages
|
|
92
|
+
if context.project_info and context.project_info.dependencies:
|
|
93
|
+
for pkg, spec_str in context.project_info.dependencies.items():
|
|
94
|
+
if spec_str == "*":
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
installed_ver_str = importlib.metadata.version(pkg)
|
|
98
|
+
installed_ver = Version(installed_ver_str)
|
|
99
|
+
spec = SpecifierSet(spec_str)
|
|
100
|
+
if installed_ver not in spec:
|
|
101
|
+
cmd = f'pip install "{pkg}{spec_str}"'
|
|
102
|
+
evidence = f"{pkg} declared as: {spec_str}\nInstalled: {installed_ver_str}"
|
|
103
|
+
diagnostics.append(Diagnostic(
|
|
104
|
+
title=f"Mismatched package version: {pkg}",
|
|
105
|
+
severity=Severity.ERROR,
|
|
106
|
+
message=f"{pkg} requires {spec_str}, but installed version is {installed_ver_str}.",
|
|
107
|
+
why=f"The project configuration specifies '{pkg}{spec_str}', but the active environment has {installed_ver_str}.",
|
|
108
|
+
evidence=evidence,
|
|
109
|
+
fix=f"Reinstall compatible version:\n {cmd}",
|
|
110
|
+
command=cmd,
|
|
111
|
+
confidence=Confidence.HIGH,
|
|
112
|
+
fix_action=FixAction(
|
|
113
|
+
description=f"Install {pkg}{spec_str}",
|
|
114
|
+
command=cmd,
|
|
115
|
+
safe=False,
|
|
116
|
+
),
|
|
117
|
+
check_id=f"{self.name}_spec_{pkg}",
|
|
118
|
+
))
|
|
119
|
+
except importlib.metadata.PackageNotFoundError:
|
|
120
|
+
# Missing package will also be caught or highlighted here
|
|
121
|
+
pass
|
|
122
|
+
except (InvalidSpecifier, InvalidVersion):
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
return diagnostics
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Django framework diagnostic checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from devdoctor.checks.base import Check, register_check
|
|
12
|
+
from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register_check
|
|
16
|
+
class DjangoCheck(Check):
|
|
17
|
+
name = "django"
|
|
18
|
+
title = "Django Framework Diagnostics"
|
|
19
|
+
category = "framework"
|
|
20
|
+
description = "Checks Django security settings, secret key, allowed hosts, and migration state."
|
|
21
|
+
|
|
22
|
+
def run(self, context: ScanContext) -> List[Diagnostic]:
|
|
23
|
+
diagnostics: List[Diagnostic] = []
|
|
24
|
+
root = context.project_path
|
|
25
|
+
|
|
26
|
+
manage_py = root / "manage.py"
|
|
27
|
+
is_django = manage_py.is_file() or (context.project_info and "Django" in context.project_info.frameworks)
|
|
28
|
+
|
|
29
|
+
if not is_django:
|
|
30
|
+
return diagnostics
|
|
31
|
+
|
|
32
|
+
# Search for settings.py
|
|
33
|
+
settings_files = list(root.glob("**/settings.py"))
|
|
34
|
+
# Filter out venvs
|
|
35
|
+
settings_files = [f for f in settings_files if not any(x in f.parts for x in [".venv", "venv", "build"])]
|
|
36
|
+
|
|
37
|
+
for sf in settings_files:
|
|
38
|
+
try:
|
|
39
|
+
content = sf.read_text(encoding="utf-8", errors="replace")
|
|
40
|
+
rel_path = sf.relative_to(root)
|
|
41
|
+
|
|
42
|
+
# 1. DEBUG = True check
|
|
43
|
+
if re.search(r"^\s*DEBUG\s*=\s*True", content, re.MULTILINE):
|
|
44
|
+
diagnostics.append(Diagnostic(
|
|
45
|
+
title="Django DEBUG is True",
|
|
46
|
+
severity=Severity.WARNING,
|
|
47
|
+
message="DEBUG is set to True in Django settings.",
|
|
48
|
+
why="Running with DEBUG=True in production exposes sensitive tracebacks, settings, and database schema to users.",
|
|
49
|
+
evidence=f"Found in:\n {rel_path}",
|
|
50
|
+
fix="Set DEBUG via environment variable:\n DEBUG = os.getenv('DJANGO_DEBUG', 'False').lower() in ('true', '1')",
|
|
51
|
+
confidence=Confidence.HIGH,
|
|
52
|
+
check_id=f"{self.name}_debug_true",
|
|
53
|
+
metadata={"file": str(rel_path)},
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
# 2. Insecure SECRET_KEY check
|
|
57
|
+
if "django-insecure-" in content:
|
|
58
|
+
diagnostics.append(Diagnostic(
|
|
59
|
+
title="Insecure Django SECRET_KEY",
|
|
60
|
+
severity=Severity.WARNING,
|
|
61
|
+
message="SECRET_KEY appears to be using the default development value.",
|
|
62
|
+
why="Using a hardcoded or django-insecure secret key compromises session signing and CSRF tokens.",
|
|
63
|
+
evidence=f"Found in:\n {rel_path}",
|
|
64
|
+
fix="Load SECRET_KEY from environment:\n SECRET_KEY = os.environ['DJANGO_SECRET_KEY']",
|
|
65
|
+
confidence=Confidence.HIGH,
|
|
66
|
+
check_id=f"{self.name}_secret_key",
|
|
67
|
+
metadata={"file": str(rel_path)},
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
# 3. ALLOWED_HOSTS check
|
|
71
|
+
allowed_match = re.search(r"^\s*ALLOWED_HOSTS\s*=\s*\[(.*?)\]", content, re.MULTILINE | re.DOTALL)
|
|
72
|
+
if allowed_match:
|
|
73
|
+
hosts_inner = allowed_match.group(1).strip()
|
|
74
|
+
if not hosts_inner:
|
|
75
|
+
diagnostics.append(Diagnostic(
|
|
76
|
+
title="Django ALLOWED_HOSTS is empty",
|
|
77
|
+
severity=Severity.WARNING,
|
|
78
|
+
message="ALLOWED_HOSTS is empty.",
|
|
79
|
+
why="When DEBUG is False, an empty ALLOWED_HOSTS list will block all incoming HTTP requests.",
|
|
80
|
+
evidence=f"Found in:\n {rel_path}",
|
|
81
|
+
fix="Configure ALLOWED_HOSTS:\n ALLOWED_HOSTS = ['localhost', '127.0.0.1']",
|
|
82
|
+
confidence=Confidence.HIGH,
|
|
83
|
+
check_id=f"{self.name}_allowed_hosts",
|
|
84
|
+
metadata={"file": str(rel_path)},
|
|
85
|
+
))
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
# 4. Check migrations if manage.py is present
|
|
90
|
+
if manage_py.is_file():
|
|
91
|
+
try:
|
|
92
|
+
res = subprocess.run(
|
|
93
|
+
[sys.executable, str(manage_py), "showmigrations", "--plan"],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=8,
|
|
97
|
+
cwd=str(root),
|
|
98
|
+
)
|
|
99
|
+
if res.returncode == 0:
|
|
100
|
+
unapplied = [line for line in res.stdout.splitlines() if "[ ]" in line]
|
|
101
|
+
if unapplied:
|
|
102
|
+
diagnostics.append(Diagnostic(
|
|
103
|
+
title="Unapplied Django migrations",
|
|
104
|
+
severity=Severity.WARNING,
|
|
105
|
+
message=f"{len(unapplied)} unapplied database migration(s) detected.",
|
|
106
|
+
why="Running without applied migrations causes database errors or missing tables.",
|
|
107
|
+
evidence="\n ".join(unapplied[:3]),
|
|
108
|
+
fix="Apply migrations:\n python manage.py migrate",
|
|
109
|
+
command=f"{sys.executable} manage.py migrate",
|
|
110
|
+
confidence=Confidence.HIGH,
|
|
111
|
+
fix_action=FixAction(
|
|
112
|
+
description="Run python manage.py migrate",
|
|
113
|
+
command=f"{sys.executable} manage.py migrate",
|
|
114
|
+
safe=False,
|
|
115
|
+
),
|
|
116
|
+
check_id=f"{self.name}_migrations",
|
|
117
|
+
))
|
|
118
|
+
else:
|
|
119
|
+
diagnostics.append(Diagnostic(
|
|
120
|
+
title="Django migrations",
|
|
121
|
+
severity=Severity.OK,
|
|
122
|
+
message="Migrations are up to date.",
|
|
123
|
+
check_id=f"{self.name}_migrations_ok",
|
|
124
|
+
))
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
return diagnostics
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Docker daemon, container health, and port collision diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
from devdoctor.checks.base import Check, register_check
|
|
10
|
+
from devdoctor.models import Confidence, Diagnostic, ScanContext, Severity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@register_check
|
|
14
|
+
class DockerCheck(Check):
|
|
15
|
+
name = "docker"
|
|
16
|
+
title = "Docker Diagnostics"
|
|
17
|
+
category = "framework"
|
|
18
|
+
description = "Checks Docker daemon accessibility, exited container states, and compose service ports."
|
|
19
|
+
|
|
20
|
+
def run(self, context: ScanContext) -> List[Diagnostic]:
|
|
21
|
+
diagnostics: List[Diagnostic] = []
|
|
22
|
+
is_docker_project = context.project_info and "Docker" in context.project_info.technologies
|
|
23
|
+
|
|
24
|
+
# 1. Check docker CLI
|
|
25
|
+
try:
|
|
26
|
+
res_ver = subprocess.run(["docker", "--version"], capture_output=True, text=True, timeout=4)
|
|
27
|
+
if res_ver.returncode != 0:
|
|
28
|
+
if is_docker_project:
|
|
29
|
+
diagnostics.append(Diagnostic(
|
|
30
|
+
title="Docker CLI unavailable",
|
|
31
|
+
severity=Severity.ERROR,
|
|
32
|
+
message="Project uses Docker, but 'docker' command is not available in PATH.",
|
|
33
|
+
why="Dockerfile or docker-compose is present, but Docker CLI is not installed.",
|
|
34
|
+
fix="Install Docker Desktop from https://www.docker.com/products/docker-desktop/.",
|
|
35
|
+
confidence=Confidence.HIGH,
|
|
36
|
+
check_id=f"{self.name}_cli_missing",
|
|
37
|
+
))
|
|
38
|
+
return diagnostics
|
|
39
|
+
|
|
40
|
+
match = re.search(r"Docker version\s+([0-9.]+)", res_ver.stdout)
|
|
41
|
+
ver = match.group(1) if match else "available"
|
|
42
|
+
diagnostics.append(Diagnostic(
|
|
43
|
+
title=f"Docker {ver}",
|
|
44
|
+
severity=Severity.OK,
|
|
45
|
+
message=f"Docker CLI is installed and available (version {ver}).",
|
|
46
|
+
check_id=f"{self.name}_version",
|
|
47
|
+
))
|
|
48
|
+
|
|
49
|
+
except Exception:
|
|
50
|
+
if is_docker_project:
|
|
51
|
+
diagnostics.append(Diagnostic(
|
|
52
|
+
title="Docker CLI unavailable",
|
|
53
|
+
severity=Severity.ERROR,
|
|
54
|
+
message="Docker CLI is not found in PATH.",
|
|
55
|
+
fix="Install Docker Desktop and ensure it is in your PATH.",
|
|
56
|
+
confidence=Confidence.HIGH,
|
|
57
|
+
check_id=f"{self.name}_cli_missing",
|
|
58
|
+
))
|
|
59
|
+
return diagnostics
|
|
60
|
+
|
|
61
|
+
# 2. Check Docker daemon running
|
|
62
|
+
try:
|
|
63
|
+
res_info = subprocess.run(["docker", "info"], capture_output=True, text=True, timeout=5)
|
|
64
|
+
if res_info.returncode != 0:
|
|
65
|
+
diagnostics.append(Diagnostic(
|
|
66
|
+
title="Docker daemon not running",
|
|
67
|
+
severity=Severity.ERROR,
|
|
68
|
+
message="Docker daemon is stopped or inaccessible.",
|
|
69
|
+
why="The docker client cannot communicate with the Docker daemon engine.",
|
|
70
|
+
evidence=res_info.stderr.strip()[:200] if res_info.stderr else "Connection refused",
|
|
71
|
+
fix="Start Docker Desktop or run 'sudo systemctl start docker'.",
|
|
72
|
+
confidence=Confidence.HIGH,
|
|
73
|
+
check_id=f"{self.name}_daemon_stopped",
|
|
74
|
+
))
|
|
75
|
+
return diagnostics
|
|
76
|
+
else:
|
|
77
|
+
diagnostics.append(Diagnostic(
|
|
78
|
+
title="Docker daemon running",
|
|
79
|
+
severity=Severity.OK,
|
|
80
|
+
message="Docker engine is healthy and accepting commands.",
|
|
81
|
+
check_id=f"{self.name}_daemon_ok",
|
|
82
|
+
))
|
|
83
|
+
except Exception:
|
|
84
|
+
diagnostics.append(Diagnostic(
|
|
85
|
+
title="Docker daemon unreachable",
|
|
86
|
+
severity=Severity.WARNING,
|
|
87
|
+
message="Timed out communicating with Docker daemon.",
|
|
88
|
+
fix="Ensure Docker Desktop is active.",
|
|
89
|
+
check_id=f"{self.name}_daemon_timeout",
|
|
90
|
+
))
|
|
91
|
+
return diagnostics
|
|
92
|
+
|
|
93
|
+
# 3. Check for crashed or failed exited containers
|
|
94
|
+
try:
|
|
95
|
+
res_ps = subprocess.run(
|
|
96
|
+
["docker", "ps", "-a", "--filter", "status=exited", "--format", "{{.Names}}\t{{.Status}}"],
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=5,
|
|
100
|
+
)
|
|
101
|
+
if res_ps.returncode == 0 and res_ps.stdout.strip():
|
|
102
|
+
for line in res_ps.stdout.strip().splitlines():
|
|
103
|
+
parts = line.split("\t")
|
|
104
|
+
if len(parts) >= 2:
|
|
105
|
+
name, status = parts[0], parts[1]
|
|
106
|
+
# e.g. "Exited (1) 2 minutes ago"
|
|
107
|
+
match_code = re.search(r"Exited\s+\((\d+)\)", status)
|
|
108
|
+
if match_code and match_code.group(1) != "0":
|
|
109
|
+
code = match_code.group(1)
|
|
110
|
+
diagnostics.append(Diagnostic(
|
|
111
|
+
title=f"Container {name} crashed",
|
|
112
|
+
severity=Severity.ERROR,
|
|
113
|
+
message=f"Container '{name}' exited with error code {code}.",
|
|
114
|
+
why=f"Container process terminated abnormally ({status}).",
|
|
115
|
+
evidence=f"Container: {name} | Status: {status}",
|
|
116
|
+
fix=f"Inspect container logs:\n docker logs {name}",
|
|
117
|
+
command=f"docker logs {name}",
|
|
118
|
+
confidence=Confidence.HIGH,
|
|
119
|
+
check_id=f"{self.name}_container_{name}",
|
|
120
|
+
))
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
return diagnostics
|