django-cfg 1.4.86__py3-none-any.whl → 1.4.88__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.
Potentially problematic release.
This version of django-cfg might be problematic. Click here for more details.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/dashboard/permissions.py +48 -0
- django_cfg/apps/dashboard/serializers/__init__.py +8 -1
- django_cfg/apps/dashboard/serializers/commands.py +29 -0
- django_cfg/apps/dashboard/services/commands_security.py +228 -0
- django_cfg/apps/dashboard/services/commands_service.py +208 -9
- django_cfg/apps/dashboard/views/commands_views.py +92 -4
- django_cfg/apps/frontend/views.py +35 -4
- django_cfg/modules/django_client/core/generator/typescript/templates/client/client.ts.jinja +18 -14
- django_cfg/modules/django_client/core/generator/typescript/templates/utils/http.ts.jinja +8 -3
- django_cfg/pyproject.toml +1 -1
- django_cfg/static/frontend/admin/404/index.html +1 -0
- django_cfg/static/frontend/admin/404.html +1 -1
- django_cfg/static/frontend/admin/500/index.html +1 -0
- django_cfg/static/frontend/admin/_next/static/D_d9HRw5Yn7BRHAX5q89_/_buildManifest.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/{19430.fe7bff7372f8a256.js → 19430.c4c95603c23c17fe.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/{43076.55dd23b6cd68edb0.js → 20695.a7d37b6c40ad3f58.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/43076-4be6a9794e9c3e8b.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/50314-5ec79b293c2283dd.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/94141-bc6d47f419b26b21.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/main-d4b1d5245e3e8c42.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/pages/{404-cf71cd7b3cb005e5.js → 404-9c41b6ebfe67f15e.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/{500-ff19c7842e3df415.js → 500-906d92e10e7e65fb.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/_app-1c0fff0f59a6d683.js +272 -0
- django_cfg/static/frontend/admin/_next/static/chunks/pages/{_error-87f3fdc2aa131e77.js → _error-2550e721b11b7946.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/{index-69f737d4802cc5b7.js → index-fe4802f9df0ac052.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{cookies-b39c7f22c066e2c6.js → cookies-97d279800f12aab4.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{privacy-5aedad0cf3a4f80f.js → privacy-1d5e6cd94689247e.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{security-dbd854d0d5d483e2.js → security-55e49700e7a01f5a.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/legal/{terms-f3e1d2b9e5edf12f.js → terms-14c02bb2d3198352.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/private/centrifugo-44a8313fa040e9ad.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/pages/private/{profile-b8045f993287f1a7.js → profile-5cd57aac826857fd.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/private/{ui-373fff8b42878e64.js → ui-383e7f474cf22a18.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/private-d4ccbe1265cbd853.js +1 -0
- django_cfg/static/frontend/admin/_next/static/chunks/{webpack-7c456a65e96eb97e.js → webpack-5a92f81363b62aa7.js} +1 -1
- django_cfg/static/frontend/admin/_next/static/css/3063068f0d5a8a00.css +3 -0
- django_cfg/static/frontend/admin/_next/static/media/19cfc7226ec3afaa-s.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/21350d82a1f187e9-s.p.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/8e9860b6e62d6359-s.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/ba9851c3c22cd980-s.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/c5fe6dc8356a8c31-s.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/df0a9ae256c0569c-s.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/e4af272ccee01ff0-s.p.woff2 +0 -0
- django_cfg/static/frontend/admin/{private/ui.html → auth/index.html} +1 -1
- django_cfg/static/frontend/admin/index.html +1 -1
- django_cfg/static/frontend/admin/legal/cookies/index.html +1 -0
- django_cfg/static/frontend/admin/legal/privacy/index.html +1 -0
- django_cfg/static/frontend/admin/legal/security/index.html +1 -0
- django_cfg/static/frontend/admin/legal/terms/index.html +1 -0
- django_cfg/static/frontend/admin/private/centrifugo/index.html +1 -0
- django_cfg/static/frontend/admin/private/index.html +1 -0
- django_cfg/static/frontend/admin/private/{profile.html → profile/index.html} +1 -1
- django_cfg/static/frontend/admin/private/ui/index.html +92 -0
- django_cfg/templates/admin/index.html +30 -44
- {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/METADATA +1 -1
- {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/RECORD +60 -55
- django_cfg/static/frontend/admin/500.html +0 -1
- django_cfg/static/frontend/admin/_next/static/chunks/23004-faae121bbfecc163.js +0 -1
- django_cfg/static/frontend/admin/_next/static/chunks/50314-3b9d15242191c8bc.js +0 -1
- django_cfg/static/frontend/admin/_next/static/chunks/main-f9b6d451d9991f19.js +0 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/_app-f62e5528fbcbb6b3.js +0 -272
- django_cfg/static/frontend/admin/_next/static/chunks/pages/private/centrifugo-f24beb6ed3955aa8.js +0 -1
- django_cfg/static/frontend/admin/_next/static/chunks/pages/private-fe9faa86ecdb0ce6.js +0 -1
- django_cfg/static/frontend/admin/_next/static/css/5f9a37b6e6a72303.css +0 -3
- django_cfg/static/frontend/admin/_next/static/media/438aa629764e75f3-s.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/4c9affa5bc8f420e-s.p.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/51251f8b9793cdb3-s.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/875ae681bfde4580-s.p.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/cc978ac5ee68c2b6-s.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/media/e857b654a2caa584-s.woff2 +0 -0
- django_cfg/static/frontend/admin/_next/static/wg0mGdXjT00H_1BUxoOSH/_buildManifest.js +0 -1
- django_cfg/static/frontend/admin/auth.html +0 -1
- django_cfg/static/frontend/admin/legal/cookies.html +0 -1
- django_cfg/static/frontend/admin/legal/privacy.html +0 -1
- django_cfg/static/frontend/admin/legal/security.html +0 -1
- django_cfg/static/frontend/admin/legal/terms.html +0 -1
- django_cfg/static/frontend/admin/private/centrifugo.html +0 -1
- django_cfg/static/frontend/admin/private.html +0 -1
- /django_cfg/static/frontend/admin/_next/static/{wg0mGdXjT00H_1BUxoOSH → D_d9HRw5Yn7BRHAX5q89_}/_ssgManifest.js +0 -0
- {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/WHEEL +0 -0
- {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.4.86.dist-info → django_cfg-1.4.88.dist-info}/licenses/LICENSE +0 -0
django_cfg/__init__.py
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dashboard Permissions
|
|
3
|
+
|
|
4
|
+
Custom permission classes for dashboard API endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from rest_framework.permissions import BasePermission
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IsSuperAdmin(BasePermission):
|
|
11
|
+
"""
|
|
12
|
+
Permission that allows access only to superusers.
|
|
13
|
+
|
|
14
|
+
More restrictive than IsAdminUser - requires is_superuser flag.
|
|
15
|
+
Use for sensitive operations like command execution.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def has_permission(self, request, view):
|
|
19
|
+
"""Check if user is authenticated and is a superuser."""
|
|
20
|
+
return bool(
|
|
21
|
+
request.user and
|
|
22
|
+
request.user.is_authenticated and
|
|
23
|
+
request.user.is_superuser
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IsStaffOrReadOnly(BasePermission):
|
|
28
|
+
"""
|
|
29
|
+
Permission that allows read access to staff,
|
|
30
|
+
but write access only to superusers.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def has_permission(self, request, view):
|
|
34
|
+
"""Check permissions based on request method."""
|
|
35
|
+
# Read permissions are allowed to any staff user
|
|
36
|
+
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
|
37
|
+
return bool(
|
|
38
|
+
request.user and
|
|
39
|
+
request.user.is_authenticated and
|
|
40
|
+
request.user.is_staff
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Write permissions are only allowed to superusers
|
|
44
|
+
return bool(
|
|
45
|
+
request.user and
|
|
46
|
+
request.user.is_authenticated and
|
|
47
|
+
request.user.is_superuser
|
|
48
|
+
)
|
|
@@ -15,7 +15,12 @@ from .charts import (
|
|
|
15
15
|
ActivityTrackerDaySerializer,
|
|
16
16
|
RecentUserSerializer,
|
|
17
17
|
)
|
|
18
|
-
from .commands import
|
|
18
|
+
from .commands import (
|
|
19
|
+
CommandSerializer,
|
|
20
|
+
CommandsSummarySerializer,
|
|
21
|
+
CommandExecuteRequestSerializer,
|
|
22
|
+
CommandHelpResponseSerializer,
|
|
23
|
+
)
|
|
19
24
|
from .apizones import APIZoneSerializer, APIZonesSummarySerializer
|
|
20
25
|
|
|
21
26
|
__all__ = [
|
|
@@ -48,6 +53,8 @@ __all__ = [
|
|
|
48
53
|
# Commands
|
|
49
54
|
'CommandSerializer',
|
|
50
55
|
'CommandsSummarySerializer',
|
|
56
|
+
'CommandExecuteRequestSerializer',
|
|
57
|
+
'CommandHelpResponseSerializer',
|
|
51
58
|
|
|
52
59
|
# API Zones
|
|
53
60
|
'APIZoneSerializer',
|
|
@@ -14,6 +14,8 @@ class CommandSerializer(serializers.Serializer):
|
|
|
14
14
|
help = serializers.CharField()
|
|
15
15
|
is_core = serializers.BooleanField()
|
|
16
16
|
is_custom = serializers.BooleanField()
|
|
17
|
+
is_allowed = serializers.BooleanField(required=False)
|
|
18
|
+
risk_level = serializers.CharField(required=False)
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
class CommandsSummarySerializer(serializers.Serializer):
|
|
@@ -24,3 +26,30 @@ class CommandsSummarySerializer(serializers.Serializer):
|
|
|
24
26
|
categories = serializers.ListField(child=serializers.CharField())
|
|
25
27
|
commands = CommandSerializer(many=True)
|
|
26
28
|
categorized = serializers.DictField()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CommandExecuteRequestSerializer(serializers.Serializer):
|
|
32
|
+
"""Request serializer for command execution."""
|
|
33
|
+
command = serializers.CharField(help_text="Name of the Django management command")
|
|
34
|
+
args = serializers.ListField(
|
|
35
|
+
child=serializers.CharField(),
|
|
36
|
+
required=False,
|
|
37
|
+
default=list,
|
|
38
|
+
help_text="Positional arguments for the command"
|
|
39
|
+
)
|
|
40
|
+
options = serializers.DictField(
|
|
41
|
+
required=False,
|
|
42
|
+
default=dict,
|
|
43
|
+
help_text="Named options for the command (e.g., {'verbosity': '2'})"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CommandHelpResponseSerializer(serializers.Serializer):
|
|
48
|
+
"""Response serializer for command help."""
|
|
49
|
+
status = serializers.CharField()
|
|
50
|
+
command = serializers.CharField()
|
|
51
|
+
app = serializers.CharField(required=False)
|
|
52
|
+
help_text = serializers.CharField(required=False)
|
|
53
|
+
is_allowed = serializers.BooleanField(required=False)
|
|
54
|
+
risk_level = serializers.CharField(required=False)
|
|
55
|
+
error = serializers.CharField(required=False)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Commands Security Module
|
|
3
|
+
|
|
4
|
+
Self-contained security logic for Django management commands execution.
|
|
5
|
+
Determines which commands are safe to execute via web interface.
|
|
6
|
+
|
|
7
|
+
TAGS: commands, security, validation, safety
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Dict, Set
|
|
14
|
+
|
|
15
|
+
from django.core.management.base import BaseCommand
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Keywords in docstring/code that indicate command should not be in web UI
|
|
21
|
+
DANGEROUS_KEYWORDS: Set[str] = {
|
|
22
|
+
'questionary', 'input(', 'stdin', 'interactive', # Requires user input
|
|
23
|
+
'destructive', 'dangerous', 'irreversible', # Explicitly marked as dangerous
|
|
24
|
+
'runserver', 'testserver', # Dev servers
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Commands that should NEVER appear (absolute blacklist)
|
|
28
|
+
ABSOLUTE_BLACKLIST: Set[str] = {
|
|
29
|
+
# Destructive database commands
|
|
30
|
+
'flush', 'sqlflush', 'dbshell',
|
|
31
|
+
|
|
32
|
+
# Shell access (security risk)
|
|
33
|
+
'shell', 'shell_plus',
|
|
34
|
+
|
|
35
|
+
# Development server (not for web execution)
|
|
36
|
+
'runserver', 'testserver', 'runserver_ngrok',
|
|
37
|
+
|
|
38
|
+
# Project generation (not applicable in running app)
|
|
39
|
+
'startapp', 'startproject',
|
|
40
|
+
|
|
41
|
+
# SQL commands (direct SQL, potentially dangerous)
|
|
42
|
+
'sqlmigrate', 'sqlsequencereset',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def analyze_command_safety(command_class: BaseCommand, command_name: str) -> Dict[str, Any]:
|
|
47
|
+
"""
|
|
48
|
+
Analyze command to determine if it's safe for web execution.
|
|
49
|
+
|
|
50
|
+
Checks:
|
|
51
|
+
1. Explicit metadata (web_executable, requires_input, is_destructive)
|
|
52
|
+
2. Docstring analysis for dangerous keywords
|
|
53
|
+
3. Source code analysis for interactive input
|
|
54
|
+
4. Required arguments analysis
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Dict with safety analysis results
|
|
58
|
+
"""
|
|
59
|
+
analysis = {
|
|
60
|
+
'is_safe': True,
|
|
61
|
+
'reasons': [],
|
|
62
|
+
'requires_input': False,
|
|
63
|
+
'is_destructive': False,
|
|
64
|
+
'web_executable': None,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Check explicit metadata (highest priority)
|
|
68
|
+
if hasattr(command_class, 'web_executable'):
|
|
69
|
+
analysis['web_executable'] = command_class.web_executable
|
|
70
|
+
if not command_class.web_executable:
|
|
71
|
+
analysis['is_safe'] = False
|
|
72
|
+
analysis['reasons'].append('Command explicitly marked as not web-executable')
|
|
73
|
+
return analysis
|
|
74
|
+
|
|
75
|
+
if hasattr(command_class, 'requires_input'):
|
|
76
|
+
analysis['requires_input'] = command_class.requires_input
|
|
77
|
+
if command_class.requires_input:
|
|
78
|
+
analysis['is_safe'] = False
|
|
79
|
+
analysis['reasons'].append('Command requires interactive input')
|
|
80
|
+
return analysis
|
|
81
|
+
|
|
82
|
+
if hasattr(command_class, 'is_destructive'):
|
|
83
|
+
analysis['is_destructive'] = command_class.is_destructive
|
|
84
|
+
if command_class.is_destructive:
|
|
85
|
+
analysis['is_safe'] = False
|
|
86
|
+
analysis['reasons'].append('Command is marked as destructive')
|
|
87
|
+
return analysis
|
|
88
|
+
|
|
89
|
+
# Analyze docstring for dangerous keywords
|
|
90
|
+
docstring = (inspect.getdoc(command_class) or '').lower()
|
|
91
|
+
for keyword in DANGEROUS_KEYWORDS:
|
|
92
|
+
if keyword in docstring:
|
|
93
|
+
analysis['is_safe'] = False
|
|
94
|
+
analysis['reasons'].append(f'Docstring contains dangerous keyword: {keyword}')
|
|
95
|
+
return analysis
|
|
96
|
+
|
|
97
|
+
# Analyze source code for interactive input
|
|
98
|
+
try:
|
|
99
|
+
source = inspect.getsource(command_class)
|
|
100
|
+
if 'questionary' in source or 'input(' in source:
|
|
101
|
+
analysis['is_safe'] = False
|
|
102
|
+
analysis['requires_input'] = True
|
|
103
|
+
analysis['reasons'].append('Command requires interactive user input')
|
|
104
|
+
return analysis
|
|
105
|
+
except Exception:
|
|
106
|
+
# Can't analyze source, be safe
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
return analysis
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_command_allowed(command_name: str, app_name: str) -> bool:
|
|
113
|
+
"""
|
|
114
|
+
Check if command should be allowed for web execution.
|
|
115
|
+
|
|
116
|
+
Priority:
|
|
117
|
+
1. Custom blacklist from settings (DJANGO_CFG_COMMANDS_BLACKLIST)
|
|
118
|
+
2. Absolute blacklist - always exclude
|
|
119
|
+
3. Custom whitelist from settings (DJANGO_CFG_COMMANDS_WHITELIST)
|
|
120
|
+
4. Command metadata analysis (web_executable, requires_input, etc.)
|
|
121
|
+
5. django_cfg apps - analyze each command
|
|
122
|
+
6. Django core - be selective (only safe utility commands)
|
|
123
|
+
7. Third party - only if explicitly whitelisted
|
|
124
|
+
|
|
125
|
+
You can customize filtering in settings.py:
|
|
126
|
+
|
|
127
|
+
DJANGO_CFG_COMMANDS_BLACKLIST = {'my_dangerous_command'}
|
|
128
|
+
DJANGO_CFG_COMMANDS_WHITELIST = {'my_safe_command'}
|
|
129
|
+
|
|
130
|
+
Or add metadata to your Command class:
|
|
131
|
+
|
|
132
|
+
class Command(BaseCommand):
|
|
133
|
+
web_executable = True # Allow in web UI
|
|
134
|
+
requires_input = False # Doesn't need interactive input
|
|
135
|
+
is_destructive = False # Not destructive
|
|
136
|
+
"""
|
|
137
|
+
from django.conf import settings
|
|
138
|
+
|
|
139
|
+
# Custom blacklist from settings (highest priority)
|
|
140
|
+
custom_blacklist = getattr(settings, 'DJANGO_CFG_COMMANDS_BLACKLIST', set())
|
|
141
|
+
if command_name in custom_blacklist:
|
|
142
|
+
logger.info(f"Command {command_name} blocked by custom blacklist")
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
# Absolute blacklist
|
|
146
|
+
if command_name in ABSOLUTE_BLACKLIST:
|
|
147
|
+
logger.info(f"Command {command_name} blocked by absolute blacklist")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Custom whitelist from settings
|
|
151
|
+
custom_whitelist = getattr(settings, 'DJANGO_CFG_COMMANDS_WHITELIST', set())
|
|
152
|
+
if command_name in custom_whitelist:
|
|
153
|
+
logger.info(f"Command {command_name} allowed by custom whitelist")
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
# Load and analyze command
|
|
157
|
+
try:
|
|
158
|
+
# Determine module path
|
|
159
|
+
if app_name == 'django_cfg':
|
|
160
|
+
module_path = f'django_cfg.management.commands.{command_name}'
|
|
161
|
+
elif app_name.startswith('django.'):
|
|
162
|
+
module_path = f'{app_name}.management.commands.{command_name}'
|
|
163
|
+
else:
|
|
164
|
+
module_path = f'{app_name}.management.commands.{command_name}'
|
|
165
|
+
|
|
166
|
+
command_module = importlib.import_module(module_path)
|
|
167
|
+
if hasattr(command_module, 'Command'):
|
|
168
|
+
command_class = command_module.Command
|
|
169
|
+
|
|
170
|
+
# Analyze command safety
|
|
171
|
+
analysis = analyze_command_safety(command_class, command_name)
|
|
172
|
+
|
|
173
|
+
# If command has explicit web_executable metadata, use it
|
|
174
|
+
if analysis['web_executable'] is not None:
|
|
175
|
+
return analysis['web_executable']
|
|
176
|
+
|
|
177
|
+
# If analysis says it's unsafe, exclude
|
|
178
|
+
if not analysis['is_safe']:
|
|
179
|
+
logger.debug(f"Command {command_name} excluded: {', '.join(analysis['reasons'])}")
|
|
180
|
+
return False
|
|
181
|
+
except Exception as e:
|
|
182
|
+
# Can't load/analyze command
|
|
183
|
+
logger.debug(f"Could not analyze command {command_name}: {e}")
|
|
184
|
+
# Be conservative - if we can't analyze, exclude unless from trusted source
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# Django CFG commands - include if analysis passed
|
|
188
|
+
if app_name == 'django_cfg':
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
# Safe Django core commands (utility/read-only)
|
|
192
|
+
safe_django_core = {
|
|
193
|
+
'check', 'diffsettings', 'showmigrations',
|
|
194
|
+
'createcachetable', 'sendtestemail',
|
|
195
|
+
}
|
|
196
|
+
if app_name.startswith('django.') and command_name in safe_django_core:
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
# Exclude other Django core by default
|
|
200
|
+
if app_name.startswith('django.'):
|
|
201
|
+
logger.debug(f"Command {command_name} excluded (Django core, not in safe list)")
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
# Third-party apps - only whitelisted
|
|
205
|
+
logger.debug(f"Command {command_name} excluded (third-party, not whitelisted)")
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_command_risk_level(command_name: str, app_name: str) -> str:
|
|
210
|
+
"""
|
|
211
|
+
Get risk level for a command.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
'safe', 'caution', or 'dangerous'
|
|
215
|
+
"""
|
|
216
|
+
if command_name in ABSOLUTE_BLACKLIST:
|
|
217
|
+
return 'dangerous'
|
|
218
|
+
|
|
219
|
+
if app_name == 'django_cfg':
|
|
220
|
+
return 'safe'
|
|
221
|
+
|
|
222
|
+
if app_name.startswith('django.'):
|
|
223
|
+
safe_commands = {'check', 'diffsettings', 'showmigrations'}
|
|
224
|
+
if command_name in safe_commands:
|
|
225
|
+
return 'safe'
|
|
226
|
+
return 'caution'
|
|
227
|
+
|
|
228
|
+
return 'caution'
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Commands Service
|
|
3
3
|
|
|
4
|
-
Django management commands discovery and
|
|
4
|
+
Django management commands discovery, documentation, and execution.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
-
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from io import StringIO
|
|
11
|
+
from typing import Any, Dict, Generator, List
|
|
9
12
|
|
|
10
|
-
from django.core.management import get_commands, load_command_class
|
|
13
|
+
from django.core.management import call_command, get_commands, load_command_class
|
|
14
|
+
|
|
15
|
+
from .commands_security import is_command_allowed, get_command_risk_level
|
|
11
16
|
|
|
12
17
|
logger = logging.getLogger(__name__)
|
|
13
18
|
|
|
@@ -26,20 +31,29 @@ class CommandsService:
|
|
|
26
31
|
"""Initialize commands service."""
|
|
27
32
|
self.logger = logger
|
|
28
33
|
|
|
29
|
-
def get_all_commands(self) -> List[Dict[str, Any]]:
|
|
34
|
+
def get_all_commands(self, include_unsafe: bool = False) -> List[Dict[str, Any]]:
|
|
30
35
|
"""
|
|
31
|
-
Get all available Django management commands.
|
|
36
|
+
Get all available Django management commands (filtered for safety by default).
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
include_unsafe: If True, include all commands. If False, only safe commands.
|
|
32
40
|
|
|
33
41
|
Returns:
|
|
34
|
-
List of command dictionaries with name, app, help text
|
|
42
|
+
List of command dictionaries with name, app, help text, safety info
|
|
35
43
|
|
|
36
|
-
%%AI_HINT: Uses Django's get_commands() for command discovery%%
|
|
44
|
+
%%AI_HINT: Uses Django's get_commands() for command discovery with security filtering%%
|
|
37
45
|
"""
|
|
38
46
|
try:
|
|
39
47
|
commands_dict = get_commands()
|
|
40
48
|
commands_list = []
|
|
41
49
|
|
|
42
50
|
for command_name, app_name in commands_dict.items():
|
|
51
|
+
# Check if command is allowed (unless explicitly including unsafe)
|
|
52
|
+
is_allowed = is_command_allowed(command_name, app_name)
|
|
53
|
+
|
|
54
|
+
if not include_unsafe and not is_allowed:
|
|
55
|
+
continue
|
|
56
|
+
|
|
43
57
|
try:
|
|
44
58
|
# Try to load command to get help text
|
|
45
59
|
command = load_command_class(app_name, command_name)
|
|
@@ -47,7 +61,10 @@ class CommandsService:
|
|
|
47
61
|
|
|
48
62
|
# Determine if it's a core Django command or custom
|
|
49
63
|
is_core = app_name.startswith('django.')
|
|
50
|
-
is_custom =
|
|
64
|
+
is_custom = app_name == 'django_cfg'
|
|
65
|
+
|
|
66
|
+
# Get risk level
|
|
67
|
+
risk_level = get_command_risk_level(command_name, app_name)
|
|
51
68
|
|
|
52
69
|
commands_list.append({
|
|
53
70
|
'name': command_name,
|
|
@@ -55,6 +72,8 @@ class CommandsService:
|
|
|
55
72
|
'help': help_text,
|
|
56
73
|
'is_core': is_core,
|
|
57
74
|
'is_custom': is_custom,
|
|
75
|
+
'is_allowed': is_allowed,
|
|
76
|
+
'risk_level': risk_level,
|
|
58
77
|
})
|
|
59
78
|
except Exception as e:
|
|
60
79
|
# If we can't load the command, still include basic info
|
|
@@ -64,7 +83,9 @@ class CommandsService:
|
|
|
64
83
|
'app': app_name,
|
|
65
84
|
'help': 'Description unavailable',
|
|
66
85
|
'is_core': app_name.startswith('django.'),
|
|
67
|
-
'is_custom':
|
|
86
|
+
'is_custom': app_name == 'django_cfg',
|
|
87
|
+
'is_allowed': is_allowed,
|
|
88
|
+
'risk_level': get_command_risk_level(command_name, app_name),
|
|
68
89
|
})
|
|
69
90
|
|
|
70
91
|
# Sort by name
|
|
@@ -140,3 +161,181 @@ class CommandsService:
|
|
|
140
161
|
'commands': [],
|
|
141
162
|
'categorized': {},
|
|
142
163
|
}
|
|
164
|
+
|
|
165
|
+
def get_command_help(self, command_name: str) -> Dict[str, Any]:
|
|
166
|
+
"""
|
|
167
|
+
Get detailed help text for a specific command.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
command_name: Name of the Django management command
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Dictionary with help text and command info
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
commands_dict = get_commands()
|
|
177
|
+
|
|
178
|
+
if command_name not in commands_dict:
|
|
179
|
+
return {
|
|
180
|
+
'status': 'error',
|
|
181
|
+
'error': f"Command '{command_name}' not found",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
app_name = commands_dict[command_name]
|
|
185
|
+
|
|
186
|
+
# Load command class
|
|
187
|
+
command_class = load_command_class(app_name, command_name)
|
|
188
|
+
command_instance = command_class()
|
|
189
|
+
|
|
190
|
+
# Get parser to extract help
|
|
191
|
+
parser = command_instance.create_parser('manage.py', command_name)
|
|
192
|
+
help_text = parser.format_help()
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
'status': 'success',
|
|
196
|
+
'command': command_name,
|
|
197
|
+
'app': app_name,
|
|
198
|
+
'help_text': help_text,
|
|
199
|
+
'is_allowed': is_command_allowed(command_name, app_name),
|
|
200
|
+
'risk_level': get_command_risk_level(command_name, app_name),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
self.logger.error(f"Error getting help for command {command_name}: {e}")
|
|
205
|
+
return {
|
|
206
|
+
'status': 'error',
|
|
207
|
+
'error': str(e),
|
|
208
|
+
'command': command_name,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
def execute_command(
|
|
212
|
+
self,
|
|
213
|
+
command_name: str,
|
|
214
|
+
args: List[str] = None,
|
|
215
|
+
options: Dict[str, Any] = None,
|
|
216
|
+
user=None
|
|
217
|
+
) -> Generator[Dict[str, Any], None, None]:
|
|
218
|
+
"""
|
|
219
|
+
Execute a Django management command and stream output.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
command_name: Name of the command to execute
|
|
223
|
+
args: List of positional arguments
|
|
224
|
+
options: Dictionary of command options
|
|
225
|
+
user: User executing the command (for logging)
|
|
226
|
+
|
|
227
|
+
Yields:
|
|
228
|
+
Dict with execution events (start, output, complete, error)
|
|
229
|
+
|
|
230
|
+
Security:
|
|
231
|
+
- Validates command is allowed via is_command_allowed()
|
|
232
|
+
- Logs all execution attempts
|
|
233
|
+
- Captures and streams output safely
|
|
234
|
+
"""
|
|
235
|
+
args = args or []
|
|
236
|
+
options = options or {}
|
|
237
|
+
start_time = time.time()
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
# Validate command exists
|
|
241
|
+
commands_dict = get_commands()
|
|
242
|
+
if command_name not in commands_dict:
|
|
243
|
+
yield {
|
|
244
|
+
'type': 'error',
|
|
245
|
+
'error': f"Command '{command_name}' not found",
|
|
246
|
+
'available_commands': list(commands_dict.keys())[:20], # First 20 for reference
|
|
247
|
+
}
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
app_name = commands_dict[command_name]
|
|
251
|
+
|
|
252
|
+
# Security check - validate command is allowed
|
|
253
|
+
if not is_command_allowed(command_name, app_name):
|
|
254
|
+
self.logger.warning(
|
|
255
|
+
f"Attempted execution of forbidden command '{command_name}' by user {user}"
|
|
256
|
+
)
|
|
257
|
+
yield {
|
|
258
|
+
'type': 'error',
|
|
259
|
+
'error': f"Command '{command_name}' is not allowed via web interface for security reasons",
|
|
260
|
+
'suggestion': 'Only safe django_cfg commands and whitelisted utilities can be executed via web.',
|
|
261
|
+
'risk_level': get_command_risk_level(command_name, app_name),
|
|
262
|
+
}
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# Send start event
|
|
266
|
+
yield {
|
|
267
|
+
'type': 'start',
|
|
268
|
+
'command': command_name,
|
|
269
|
+
'args': args,
|
|
270
|
+
'options': options,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# Capture command output
|
|
274
|
+
output_buffer = StringIO()
|
|
275
|
+
old_stdout = sys.stdout
|
|
276
|
+
old_stderr = sys.stderr
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
# Redirect stdout/stderr to buffer
|
|
280
|
+
sys.stdout = output_buffer
|
|
281
|
+
sys.stderr = output_buffer
|
|
282
|
+
|
|
283
|
+
# Execute command
|
|
284
|
+
call_command(command_name, *args, **options)
|
|
285
|
+
return_code = 0
|
|
286
|
+
|
|
287
|
+
except Exception as e:
|
|
288
|
+
# Command execution failed
|
|
289
|
+
output_buffer.write(f"\nError: {str(e)}\n")
|
|
290
|
+
return_code = 1
|
|
291
|
+
|
|
292
|
+
finally:
|
|
293
|
+
# Restore stdout/stderr
|
|
294
|
+
sys.stdout = old_stdout
|
|
295
|
+
sys.stderr = old_stderr
|
|
296
|
+
|
|
297
|
+
# Get all output
|
|
298
|
+
output = output_buffer.getvalue()
|
|
299
|
+
|
|
300
|
+
# Stream output line by line
|
|
301
|
+
if output:
|
|
302
|
+
for line in output.split('\n'):
|
|
303
|
+
if line.strip():
|
|
304
|
+
yield {
|
|
305
|
+
'type': 'output',
|
|
306
|
+
'line': line,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
execution_time = time.time() - start_time
|
|
310
|
+
|
|
311
|
+
# Send completion event
|
|
312
|
+
yield {
|
|
313
|
+
'type': 'complete',
|
|
314
|
+
'return_code': return_code,
|
|
315
|
+
'execution_time': round(execution_time, 2),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Log command execution
|
|
319
|
+
if return_code == 0:
|
|
320
|
+
self.logger.info(
|
|
321
|
+
f"Command executed: {command_name} {' '.join(args)} "
|
|
322
|
+
f"by user {user} in {execution_time:.2f}s"
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
self.logger.error(
|
|
326
|
+
f"Command failed: {command_name} {' '.join(args)} "
|
|
327
|
+
f"by user {user} in {execution_time:.2f}s"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
except Exception as e:
|
|
331
|
+
execution_time = time.time() - start_time
|
|
332
|
+
|
|
333
|
+
self.logger.error(
|
|
334
|
+
f"Command execution error: {command_name} by user {user}: {e}"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
yield {
|
|
338
|
+
'type': 'error',
|
|
339
|
+
'error': str(e),
|
|
340
|
+
'execution_time': round(execution_time, 2),
|
|
341
|
+
}
|