appmanager-server 0.2.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.
- appmanager/__init__.py +52 -0
- appmanager/admin/__init__.py +3 -0
- appmanager/admin/app_installer.py +739 -0
- appmanager/admin/members.py +95 -0
- appmanager/admin/registry.py +372 -0
- appmanager/admin/routes.py +900 -0
- appmanager/api/__init__.py +5 -0
- appmanager/api/routes.py +373 -0
- appmanager/app_config.py +101 -0
- appmanager/auth/__init__.py +3 -0
- appmanager/auth/oauth.py +77 -0
- appmanager/auth/routes.py +224 -0
- appmanager/auth/utils.py +175 -0
- appmanager/bridge.py +90 -0
- appmanager/cli.py +1171 -0
- appmanager/config.py +58 -0
- appmanager/database.py +120 -0
- appmanager/extension.py +198 -0
- appmanager/extensions.py +225 -0
- appmanager/health.py +133 -0
- appmanager/hooks.py +187 -0
- appmanager/middleware.py +256 -0
- appmanager/models.py +307 -0
- appmanager/py.typed +1 -0
- appmanager/sdk.py +30 -0
- appmanager/security.py +227 -0
- appmanager/security_scanner.py +582 -0
- appmanager/signals.py +21 -0
- appmanager/static/css/app.css +808 -0
- appmanager/static/vendor/fonts/fonts.css +288 -0
- appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- appmanager/static/vendor/htmx/htmx.min.js +1 -0
- appmanager/templates/admin/_member_drawer.html +98 -0
- appmanager/templates/admin/_members_table.html +108 -0
- appmanager/templates/admin/app_detail.html +136 -0
- appmanager/templates/admin/dashboard.html +902 -0
- appmanager/templates/admin/flairs.html +90 -0
- appmanager/templates/admin/permissions.html +88 -0
- appmanager/templates/admin/roles.html +119 -0
- appmanager/templates/admin/users.html +156 -0
- appmanager/templates/auth/login.html +58 -0
- appmanager/templates/auth/profile.html +44 -0
- appmanager/templates/base.html +76 -0
- appmanager/templates/errors/401.html +19 -0
- appmanager/templates/errors/403.html +19 -0
- appmanager/templates/errors/404.html +18 -0
- appmanager/templates/errors/500.html +19 -0
- appmanager/templates/index.html +54 -0
- appmanager_server-0.2.0.dist-info/METADATA +320 -0
- appmanager_server-0.2.0.dist-info/RECORD +89 -0
- appmanager_server-0.2.0.dist-info/WHEEL +5 -0
- appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
- appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
- appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from appmanager.security import (
|
|
8
|
+
validate_entrypoint_path,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# Forbidden binary and script file extensions in sub-app packages
|
|
12
|
+
FORBIDDEN_EXTENSIONS = {
|
|
13
|
+
".exe",
|
|
14
|
+
".dll",
|
|
15
|
+
".so",
|
|
16
|
+
".dylib",
|
|
17
|
+
".bin",
|
|
18
|
+
".sh",
|
|
19
|
+
".bash",
|
|
20
|
+
".zsh",
|
|
21
|
+
".bat",
|
|
22
|
+
".cmd",
|
|
23
|
+
".ps1",
|
|
24
|
+
".vbs",
|
|
25
|
+
".jar",
|
|
26
|
+
".war",
|
|
27
|
+
".class",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
DANGEROUS_CALLS = {
|
|
31
|
+
# High/Critical code & command execution
|
|
32
|
+
"eval": (
|
|
33
|
+
"CRITICAL",
|
|
34
|
+
"Code Injection",
|
|
35
|
+
"Direct use of eval() allows arbitrary Python code execution.",
|
|
36
|
+
),
|
|
37
|
+
"exec": (
|
|
38
|
+
"CRITICAL",
|
|
39
|
+
"Code Injection",
|
|
40
|
+
"Direct use of exec() allows arbitrary dynamic code execution.",
|
|
41
|
+
),
|
|
42
|
+
"compile": ("HIGH", "Code Injection", "Use of compile() dynamically compiles code strings."),
|
|
43
|
+
"__import__": ("HIGH", "Dynamic Import", "Direct dynamic module import via __import__()."),
|
|
44
|
+
"globals": ("MEDIUM", "Namespace Access", "Direct access to global symbol namespace."),
|
|
45
|
+
"locals": ("LOW", "Namespace Access", "Access to local symbol namespace."),
|
|
46
|
+
"posix.system": (
|
|
47
|
+
"CRITICAL",
|
|
48
|
+
"Command Injection",
|
|
49
|
+
"Direct OS system execution via posix.system.",
|
|
50
|
+
),
|
|
51
|
+
"pty.spawn": (
|
|
52
|
+
"CRITICAL",
|
|
53
|
+
"Interactive Shell / Reverse Shell",
|
|
54
|
+
"pty.spawn() can spawn arbitrary interactive shells.",
|
|
55
|
+
),
|
|
56
|
+
"pty.fork": ("CRITICAL", "Process Forking", "pty.fork() forks process with pseudo-terminal."),
|
|
57
|
+
# Dangerous deserialization
|
|
58
|
+
"pickle.loads": (
|
|
59
|
+
"CRITICAL",
|
|
60
|
+
"Insecure Deserialization",
|
|
61
|
+
"pickle.loads() can execute arbitrary code during deserialization.",
|
|
62
|
+
),
|
|
63
|
+
"pickle.load": (
|
|
64
|
+
"CRITICAL",
|
|
65
|
+
"Insecure Deserialization",
|
|
66
|
+
"pickle.load() can execute arbitrary code during deserialization.",
|
|
67
|
+
),
|
|
68
|
+
"_pickle.loads": (
|
|
69
|
+
"CRITICAL",
|
|
70
|
+
"Insecure Deserialization",
|
|
71
|
+
"_pickle.loads() can execute arbitrary code during deserialization.",
|
|
72
|
+
),
|
|
73
|
+
"_pickle.load": (
|
|
74
|
+
"CRITICAL",
|
|
75
|
+
"Insecure Deserialization",
|
|
76
|
+
"_pickle.load() can execute arbitrary code during deserialization.",
|
|
77
|
+
),
|
|
78
|
+
"marshal.loads": (
|
|
79
|
+
"HIGH",
|
|
80
|
+
"Insecure Deserialization",
|
|
81
|
+
"marshal.loads() deserializes arbitrary Python bytecode.",
|
|
82
|
+
),
|
|
83
|
+
"shelve.open": (
|
|
84
|
+
"MEDIUM",
|
|
85
|
+
"Insecure Deserialization",
|
|
86
|
+
"shelve.open() uses pickle under the hood.",
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
DANGEROUS_MODULES = {
|
|
91
|
+
"os.system": (
|
|
92
|
+
"CRITICAL",
|
|
93
|
+
"Command Injection",
|
|
94
|
+
"os.system() executes arbitrary shell commands.",
|
|
95
|
+
),
|
|
96
|
+
"os.popen": (
|
|
97
|
+
"CRITICAL",
|
|
98
|
+
"Command Injection",
|
|
99
|
+
"os.popen() opens a pipe to/from a shell command.",
|
|
100
|
+
),
|
|
101
|
+
"os.spawnl": ("CRITICAL", "Process Execution", "os.spawnl() spawns arbitrary OS processes."),
|
|
102
|
+
"os.spawnle": ("CRITICAL", "Process Execution", "os.spawnle() spawns arbitrary OS processes."),
|
|
103
|
+
"os.spawnlp": ("CRITICAL", "Process Execution", "os.spawnlp() spawns arbitrary OS processes."),
|
|
104
|
+
"os.spawnlpe": (
|
|
105
|
+
"CRITICAL",
|
|
106
|
+
"Process Execution",
|
|
107
|
+
"os.spawnlpe() spawns arbitrary OS processes.",
|
|
108
|
+
),
|
|
109
|
+
"os.spawnv": ("CRITICAL", "Process Execution", "os.spawnv() spawns arbitrary OS processes."),
|
|
110
|
+
"os.spawnve": ("CRITICAL", "Process Execution", "os.spawnve() spawns arbitrary OS processes."),
|
|
111
|
+
"os.spawnvp": ("CRITICAL", "Process Execution", "os.spawnvp() spawns arbitrary OS processes."),
|
|
112
|
+
"os.spawnvpe": (
|
|
113
|
+
"CRITICAL",
|
|
114
|
+
"Process Execution",
|
|
115
|
+
"os.spawnvpe() spawns arbitrary OS processes.",
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
SUBPROCESS_FUNCS = {
|
|
120
|
+
"subprocess.Popen",
|
|
121
|
+
"subprocess.call",
|
|
122
|
+
"subprocess.check_call",
|
|
123
|
+
"subprocess.check_output",
|
|
124
|
+
"subprocess.run",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
SUSPICIOUS_REQUIREMENTS_FLAGS = [
|
|
128
|
+
("--extra-index-url", "HIGH", "Custom package index repository overrides default PyPI index."),
|
|
129
|
+
("--index-url", "HIGH", "Direct package index override."),
|
|
130
|
+
("--find-links", "MEDIUM", "Package links to external directory or URL."),
|
|
131
|
+
("--trusted-host", "HIGH", "Disables SSL verification for pip installations."),
|
|
132
|
+
("-e", "MEDIUM", "Editable package installation flag."),
|
|
133
|
+
("--editable", "MEDIUM", "Editable package installation flag."),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class SecurityFinding:
|
|
139
|
+
severity: str # 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'
|
|
140
|
+
category: str
|
|
141
|
+
message: str
|
|
142
|
+
file: Optional[str] = None
|
|
143
|
+
line: Optional[int] = None
|
|
144
|
+
snippet: Optional[str] = None
|
|
145
|
+
|
|
146
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
147
|
+
return asdict(self)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class SecurityScanReport:
|
|
152
|
+
is_safe: bool
|
|
153
|
+
risk_level: str # 'CLEAN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'
|
|
154
|
+
files_scanned: int = 0
|
|
155
|
+
py_files_scanned: int = 0
|
|
156
|
+
findings: List[SecurityFinding] = field(default_factory=list)
|
|
157
|
+
manifest_info: Dict[str, Any] = field(default_factory=dict)
|
|
158
|
+
summary: str = ""
|
|
159
|
+
|
|
160
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
161
|
+
return {
|
|
162
|
+
"is_safe": self.is_safe,
|
|
163
|
+
"risk_level": self.risk_level,
|
|
164
|
+
"files_scanned": self.files_scanned,
|
|
165
|
+
"py_files_scanned": self.py_files_scanned,
|
|
166
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
167
|
+
"manifest_info": self.manifest_info,
|
|
168
|
+
"summary": self.summary,
|
|
169
|
+
"counts": {
|
|
170
|
+
"critical": sum(1 for f in self.findings if f.severity == "CRITICAL"),
|
|
171
|
+
"high": sum(1 for f in self.findings if f.severity == "HIGH"),
|
|
172
|
+
"medium": sum(1 for f in self.findings if f.severity == "MEDIUM"),
|
|
173
|
+
"low": sum(1 for f in self.findings if f.severity == "LOW"),
|
|
174
|
+
"info": sum(1 for f in self.findings if f.severity == "INFO"),
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class CodeSecurityVisitor(ast.NodeVisitor):
|
|
180
|
+
"""
|
|
181
|
+
Statically analyzes Python AST nodes to identify potentially malicious code patterns
|
|
182
|
+
without executing or importing untrusted modules.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(self, filepath: str, source_lines: List[str]):
|
|
186
|
+
self.filepath = filepath
|
|
187
|
+
self.source_lines = source_lines
|
|
188
|
+
self.findings: List[SecurityFinding] = []
|
|
189
|
+
self.imports: Dict[str, str] = {} # alias -> full_name
|
|
190
|
+
|
|
191
|
+
def _get_snippet(self, lineno: int) -> str:
|
|
192
|
+
if 1 <= lineno <= len(self.source_lines):
|
|
193
|
+
return self.source_lines[lineno - 1].strip()
|
|
194
|
+
return ""
|
|
195
|
+
|
|
196
|
+
def visit_Import(self, node: ast.Import):
|
|
197
|
+
for alias in node.names:
|
|
198
|
+
name = alias.name
|
|
199
|
+
asname = alias.asname or name
|
|
200
|
+
self.imports[asname] = name
|
|
201
|
+
if name in ("pty", "telnetlib"):
|
|
202
|
+
self.findings.append(
|
|
203
|
+
SecurityFinding(
|
|
204
|
+
severity="MEDIUM",
|
|
205
|
+
category="Suspicious Module Import",
|
|
206
|
+
message=f"Importing module '{name}' which is commonly used for shell access.",
|
|
207
|
+
file=self.filepath,
|
|
208
|
+
line=node.lineno,
|
|
209
|
+
snippet=self._get_snippet(node.lineno),
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
self.generic_visit(node)
|
|
213
|
+
|
|
214
|
+
def visit_ImportFrom(self, node: ast.ImportFrom):
|
|
215
|
+
module = node.module or ""
|
|
216
|
+
for alias in node.names:
|
|
217
|
+
full_name = f"{module}.{alias.name}" if module else alias.name
|
|
218
|
+
asname = alias.asname or alias.name
|
|
219
|
+
self.imports[asname] = full_name
|
|
220
|
+
if full_name in DANGEROUS_CALLS or full_name in DANGEROUS_MODULES:
|
|
221
|
+
info = DANGEROUS_CALLS.get(full_name) or DANGEROUS_MODULES.get(full_name)
|
|
222
|
+
sev, cat, desc = info
|
|
223
|
+
self.findings.append(
|
|
224
|
+
SecurityFinding(
|
|
225
|
+
severity=sev,
|
|
226
|
+
category=cat,
|
|
227
|
+
message=f"Import of dangerous function '{full_name}': {desc}",
|
|
228
|
+
file=self.filepath,
|
|
229
|
+
line=node.lineno,
|
|
230
|
+
snippet=self._get_snippet(node.lineno),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
self.generic_visit(node)
|
|
234
|
+
|
|
235
|
+
def _resolve_func_name(self, node: ast.AST) -> Optional[str]:
|
|
236
|
+
if isinstance(node, ast.Name):
|
|
237
|
+
return self.imports.get(node.id, node.id)
|
|
238
|
+
elif isinstance(node, ast.Attribute):
|
|
239
|
+
val_name = self._resolve_func_name(node.value)
|
|
240
|
+
if val_name:
|
|
241
|
+
resolved_base = self.imports.get(val_name, val_name)
|
|
242
|
+
return f"{resolved_base}.{node.attr}"
|
|
243
|
+
return node.attr
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def visit_Call(self, node: ast.Call):
|
|
247
|
+
func_name = self._resolve_func_name(node.func)
|
|
248
|
+
lineno = getattr(node, "lineno", 1)
|
|
249
|
+
snippet = self._get_snippet(lineno)
|
|
250
|
+
|
|
251
|
+
if func_name:
|
|
252
|
+
# Check dangerous standalone or qualified calls
|
|
253
|
+
if func_name in DANGEROUS_CALLS:
|
|
254
|
+
sev, cat, desc = DANGEROUS_CALLS[func_name]
|
|
255
|
+
self.findings.append(
|
|
256
|
+
SecurityFinding(
|
|
257
|
+
severity=sev,
|
|
258
|
+
category=cat,
|
|
259
|
+
message=desc,
|
|
260
|
+
file=self.filepath,
|
|
261
|
+
line=lineno,
|
|
262
|
+
snippet=snippet,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
elif func_name in DANGEROUS_MODULES:
|
|
266
|
+
sev, cat, desc = DANGEROUS_MODULES[func_name]
|
|
267
|
+
self.findings.append(
|
|
268
|
+
SecurityFinding(
|
|
269
|
+
severity=sev,
|
|
270
|
+
category=cat,
|
|
271
|
+
message=desc,
|
|
272
|
+
file=self.filepath,
|
|
273
|
+
line=lineno,
|
|
274
|
+
snippet=snippet,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
elif func_name in SUBPROCESS_FUNCS:
|
|
278
|
+
# Inspect subprocess invocation
|
|
279
|
+
shell_kw = any(
|
|
280
|
+
kw.arg == "shell"
|
|
281
|
+
and isinstance(kw.value, ast.Constant)
|
|
282
|
+
and kw.value.value is True
|
|
283
|
+
for kw in node.keywords
|
|
284
|
+
)
|
|
285
|
+
if shell_kw:
|
|
286
|
+
self.findings.append(
|
|
287
|
+
SecurityFinding(
|
|
288
|
+
severity="CRITICAL",
|
|
289
|
+
category="Command Injection Risk",
|
|
290
|
+
message=f"{func_name}() called with shell=True, which is susceptible to OS command injection.",
|
|
291
|
+
file=self.filepath,
|
|
292
|
+
line=lineno,
|
|
293
|
+
snippet=snippet,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
self.findings.append(
|
|
298
|
+
SecurityFinding(
|
|
299
|
+
severity="MEDIUM",
|
|
300
|
+
category="Process Execution",
|
|
301
|
+
message=f"{func_name}() spawns external OS processes. Verify arguments are sanitized.",
|
|
302
|
+
file=self.filepath,
|
|
303
|
+
line=lineno,
|
|
304
|
+
snippet=snippet,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
elif func_name in ("yaml.load", "ruamel.yaml.load"):
|
|
308
|
+
# Check for Loader=SafeLoader
|
|
309
|
+
has_safe_loader = any(
|
|
310
|
+
(
|
|
311
|
+
kw.arg == "Loader"
|
|
312
|
+
and isinstance(kw.value, ast.Attribute)
|
|
313
|
+
and "SafeLoader" in kw.value.attr
|
|
314
|
+
)
|
|
315
|
+
or (
|
|
316
|
+
kw.arg == "Loader"
|
|
317
|
+
and isinstance(kw.value, ast.Name)
|
|
318
|
+
and "SafeLoader" in kw.value.id
|
|
319
|
+
)
|
|
320
|
+
for kw in node.keywords
|
|
321
|
+
)
|
|
322
|
+
if not has_safe_loader:
|
|
323
|
+
self.findings.append(
|
|
324
|
+
SecurityFinding(
|
|
325
|
+
severity="HIGH",
|
|
326
|
+
category="Insecure Deserialization",
|
|
327
|
+
message=f"{func_name}() called without SafeLoader. Use yaml.safe_load() to prevent code execution.",
|
|
328
|
+
file=self.filepath,
|
|
329
|
+
line=lineno,
|
|
330
|
+
snippet=snippet,
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
elif func_name.endswith(".connect") and "socket" in func_name:
|
|
334
|
+
self.findings.append(
|
|
335
|
+
SecurityFinding(
|
|
336
|
+
severity="MEDIUM",
|
|
337
|
+
category="Outbound Network Socket",
|
|
338
|
+
message=f"Raw network socket connection '{func_name}()'. Verify outbound destination.",
|
|
339
|
+
file=self.filepath,
|
|
340
|
+
line=lineno,
|
|
341
|
+
snippet=snippet,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
self.generic_visit(node)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class SecurityScanner:
|
|
349
|
+
"""
|
|
350
|
+
Performs comprehensive static security checks on an app directory or archive
|
|
351
|
+
before installation.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def scan_python_file(filepath: str, rel_path: str) -> List[SecurityFinding]:
|
|
356
|
+
findings: List[SecurityFinding] = []
|
|
357
|
+
try:
|
|
358
|
+
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
359
|
+
content = f.read()
|
|
360
|
+
lines = content.splitlines()
|
|
361
|
+
tree = ast.parse(content, filename=rel_path)
|
|
362
|
+
visitor = CodeSecurityVisitor(filepath=rel_path, source_lines=lines)
|
|
363
|
+
visitor.visit(tree)
|
|
364
|
+
findings.extend(visitor.findings)
|
|
365
|
+
except SyntaxError as e:
|
|
366
|
+
findings.append(
|
|
367
|
+
SecurityFinding(
|
|
368
|
+
severity="HIGH",
|
|
369
|
+
category="Syntax Error",
|
|
370
|
+
message=f"Python syntax error: {str(e)}",
|
|
371
|
+
file=rel_path,
|
|
372
|
+
line=e.lineno or 1,
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
findings.append(
|
|
377
|
+
SecurityFinding(
|
|
378
|
+
severity="MEDIUM",
|
|
379
|
+
category="File Parse Warning",
|
|
380
|
+
message=f"Could not parse file: {str(e)}",
|
|
381
|
+
file=rel_path,
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
return findings
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def scan_requirements(req_path: str, rel_path: str) -> List[SecurityFinding]:
|
|
388
|
+
findings: List[SecurityFinding] = []
|
|
389
|
+
if not os.path.exists(req_path):
|
|
390
|
+
return findings
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
with open(req_path, "r", encoding="utf-8", errors="replace") as f:
|
|
394
|
+
lines = f.readlines()
|
|
395
|
+
|
|
396
|
+
for idx, line in enumerate(lines, start=1):
|
|
397
|
+
clean = line.strip()
|
|
398
|
+
if not clean or clean.startswith("#"):
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
for flag, severity, msg in SUSPICIOUS_REQUIREMENTS_FLAGS:
|
|
402
|
+
if clean.startswith(flag) or f" {flag}" in clean:
|
|
403
|
+
findings.append(
|
|
404
|
+
SecurityFinding(
|
|
405
|
+
severity=severity,
|
|
406
|
+
category="Dependency Injection Risk",
|
|
407
|
+
message=f"requirements.txt contains '{flag}': {msg}",
|
|
408
|
+
file=rel_path,
|
|
409
|
+
line=idx,
|
|
410
|
+
snippet=clean,
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Check for direct git / http URLs in dependencies
|
|
415
|
+
if re.match(r"^(git\+|http://|https://|ssh://)", clean):
|
|
416
|
+
findings.append(
|
|
417
|
+
SecurityFinding(
|
|
418
|
+
severity="MEDIUM",
|
|
419
|
+
category="Remote Dependency URL",
|
|
420
|
+
message=f"Package uses direct remote repository URL '{clean}'.",
|
|
421
|
+
file=rel_path,
|
|
422
|
+
line=idx,
|
|
423
|
+
snippet=clean,
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
except Exception as e:
|
|
427
|
+
findings.append(
|
|
428
|
+
SecurityFinding(
|
|
429
|
+
severity="LOW",
|
|
430
|
+
category="Requirements File Parse Error",
|
|
431
|
+
message=f"Could not read requirements.txt: {e}",
|
|
432
|
+
file=rel_path,
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
return findings
|
|
436
|
+
|
|
437
|
+
@classmethod
|
|
438
|
+
def scan_directory(cls, target_dir: str) -> SecurityScanReport:
|
|
439
|
+
findings: List[SecurityFinding] = []
|
|
440
|
+
files_scanned = 0
|
|
441
|
+
py_files_scanned = 0
|
|
442
|
+
|
|
443
|
+
abs_target = os.path.abspath(target_dir)
|
|
444
|
+
|
|
445
|
+
# 1. Walk directory and scan files
|
|
446
|
+
for root, dirs, files in os.walk(target_dir):
|
|
447
|
+
# Skip virtual environments, git dirs, and OS metadata
|
|
448
|
+
dirs[:] = [
|
|
449
|
+
d
|
|
450
|
+
for d in dirs
|
|
451
|
+
if d
|
|
452
|
+
not in (".git", "__pycache__", ".pytest_cache", "venv", "env", ".env", "__MACOSX")
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
for filename in files:
|
|
456
|
+
# Skip OS metadata and resource fork files
|
|
457
|
+
if filename in (".DS_Store", "Thumbs.db") or filename.startswith("._"):
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
files_scanned += 1
|
|
461
|
+
filepath = os.path.join(root, filename)
|
|
462
|
+
rel_path = os.path.relpath(filepath, abs_target)
|
|
463
|
+
_, ext = os.path.splitext(filename.lower())
|
|
464
|
+
|
|
465
|
+
# Check for forbidden binary and executable script formats
|
|
466
|
+
if ext in FORBIDDEN_EXTENSIONS:
|
|
467
|
+
findings.append(
|
|
468
|
+
SecurityFinding(
|
|
469
|
+
severity="CRITICAL",
|
|
470
|
+
category="Forbidden Binary / Executable",
|
|
471
|
+
message=f"Forbidden file extension '{ext}'. Sub-app packages must not contain executable binaries or scripts.",
|
|
472
|
+
file=rel_path,
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
# Scan Python files
|
|
478
|
+
if ext == ".py":
|
|
479
|
+
py_files_scanned += 1
|
|
480
|
+
file_findings = cls.scan_python_file(filepath, rel_path)
|
|
481
|
+
findings.extend(file_findings)
|
|
482
|
+
|
|
483
|
+
# Scan requirements files
|
|
484
|
+
elif filename in ("requirements.txt", "requirements-dev.txt"):
|
|
485
|
+
req_findings = cls.scan_requirements(filepath, rel_path)
|
|
486
|
+
findings.extend(req_findings)
|
|
487
|
+
|
|
488
|
+
# 2. Check manifest.json or discovery
|
|
489
|
+
from appmanager.admin.app_installer import parse_manifest
|
|
490
|
+
|
|
491
|
+
manifest_data = parse_manifest(target_dir) or {}
|
|
492
|
+
|
|
493
|
+
slug = manifest_data.get("slug", "")
|
|
494
|
+
if slug and not re.match(r"^[a-z0-9\-_]+$", slug):
|
|
495
|
+
findings.append(
|
|
496
|
+
SecurityFinding(
|
|
497
|
+
severity="HIGH",
|
|
498
|
+
category="Invalid Slug Identifier",
|
|
499
|
+
message=f"Slug '{slug}' contains invalid characters. Slugs must only contain lowercase alphanumeric characters, dashes, and underscores.",
|
|
500
|
+
file="manifest.json",
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# Check entrypoint
|
|
505
|
+
entry_point = manifest_data.get("entry_point", "")
|
|
506
|
+
if entry_point:
|
|
507
|
+
is_safe, ep_msg = validate_entrypoint_path(target_dir, entry_point)
|
|
508
|
+
if not is_safe:
|
|
509
|
+
findings.append(
|
|
510
|
+
SecurityFinding(
|
|
511
|
+
severity="CRITICAL",
|
|
512
|
+
category="Entrypoint Path Traversal",
|
|
513
|
+
message=ep_msg,
|
|
514
|
+
file="manifest.json",
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
# 3. Determine Overall Risk Level
|
|
519
|
+
has_critical = any(f.severity == "CRITICAL" for f in findings)
|
|
520
|
+
has_high = any(f.severity == "HIGH" for f in findings)
|
|
521
|
+
has_medium = any(f.severity == "MEDIUM" for f in findings)
|
|
522
|
+
has_low = any(f.severity == "LOW" for f in findings)
|
|
523
|
+
|
|
524
|
+
if has_critical:
|
|
525
|
+
risk_level = "CRITICAL"
|
|
526
|
+
is_safe = False
|
|
527
|
+
elif has_high:
|
|
528
|
+
risk_level = "HIGH"
|
|
529
|
+
is_safe = False
|
|
530
|
+
elif has_medium:
|
|
531
|
+
risk_level = "MEDIUM"
|
|
532
|
+
is_safe = True # Allowed with warning/confirmation
|
|
533
|
+
elif has_low or findings:
|
|
534
|
+
risk_level = "LOW"
|
|
535
|
+
is_safe = True
|
|
536
|
+
else:
|
|
537
|
+
risk_level = "CLEAN"
|
|
538
|
+
is_safe = True
|
|
539
|
+
|
|
540
|
+
critical_cnt = sum(1 for f in findings if f.severity == "CRITICAL")
|
|
541
|
+
high_cnt = sum(1 for f in findings if f.severity == "HIGH")
|
|
542
|
+
med_cnt = sum(1 for f in findings if f.severity == "MEDIUM")
|
|
543
|
+
|
|
544
|
+
if not findings:
|
|
545
|
+
summary = f"Passed all checks cleanly. {files_scanned} files inspected ({py_files_scanned} Python modules)."
|
|
546
|
+
else:
|
|
547
|
+
summary = (
|
|
548
|
+
f"Security scan found {len(findings)} potential issue(s): "
|
|
549
|
+
f"{critical_cnt} critical, {high_cnt} high, {med_cnt} medium. "
|
|
550
|
+
f"Total files scanned: {files_scanned}."
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
return SecurityScanReport(
|
|
554
|
+
is_safe=is_safe,
|
|
555
|
+
risk_level=risk_level,
|
|
556
|
+
files_scanned=files_scanned,
|
|
557
|
+
py_files_scanned=py_files_scanned,
|
|
558
|
+
findings=findings,
|
|
559
|
+
manifest_info=manifest_data,
|
|
560
|
+
summary=summary,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def run_security_scan(target_path: str) -> SecurityScanReport:
|
|
565
|
+
"""
|
|
566
|
+
Main entry point for running the security scanner on a directory.
|
|
567
|
+
"""
|
|
568
|
+
if not os.path.exists(target_path):
|
|
569
|
+
return SecurityScanReport(
|
|
570
|
+
is_safe=False,
|
|
571
|
+
risk_level="CRITICAL",
|
|
572
|
+
summary=f"Target path does not exist: {target_path}",
|
|
573
|
+
findings=[
|
|
574
|
+
SecurityFinding(
|
|
575
|
+
severity="CRITICAL",
|
|
576
|
+
category="Missing Files",
|
|
577
|
+
message=f"Target path '{target_path}' does not exist.",
|
|
578
|
+
)
|
|
579
|
+
],
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
return SecurityScanner.scan_directory(target_path)
|
appmanager/signals.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AppManager Signals
|
|
3
|
+
Provides blinker-based signal hooks for lifecycle and event monitoring.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from blinker import Namespace
|
|
7
|
+
|
|
8
|
+
_signals = Namespace()
|
|
9
|
+
|
|
10
|
+
# Sub-App Lifecycle Signals
|
|
11
|
+
subapp_installed = _signals.signal("subapp-installed")
|
|
12
|
+
subapp_uninstalled = _signals.signal("subapp-uninstalled")
|
|
13
|
+
subapp_reloaded = _signals.signal("subapp-reloaded")
|
|
14
|
+
|
|
15
|
+
# Health Check Signals
|
|
16
|
+
health_check_completed = _signals.signal("health-check-completed")
|
|
17
|
+
health_check_failed = _signals.signal("health-check-failed")
|
|
18
|
+
|
|
19
|
+
# Telemetry & Auth Signals
|
|
20
|
+
telemetry_received = _signals.signal("telemetry-received")
|
|
21
|
+
user_logged_in = _signals.signal("user-logged-in")
|