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.
Files changed (89) hide show
  1. appmanager/__init__.py +52 -0
  2. appmanager/admin/__init__.py +3 -0
  3. appmanager/admin/app_installer.py +739 -0
  4. appmanager/admin/members.py +95 -0
  5. appmanager/admin/registry.py +372 -0
  6. appmanager/admin/routes.py +900 -0
  7. appmanager/api/__init__.py +5 -0
  8. appmanager/api/routes.py +373 -0
  9. appmanager/app_config.py +101 -0
  10. appmanager/auth/__init__.py +3 -0
  11. appmanager/auth/oauth.py +77 -0
  12. appmanager/auth/routes.py +224 -0
  13. appmanager/auth/utils.py +175 -0
  14. appmanager/bridge.py +90 -0
  15. appmanager/cli.py +1171 -0
  16. appmanager/config.py +58 -0
  17. appmanager/database.py +120 -0
  18. appmanager/extension.py +198 -0
  19. appmanager/extensions.py +225 -0
  20. appmanager/health.py +133 -0
  21. appmanager/hooks.py +187 -0
  22. appmanager/middleware.py +256 -0
  23. appmanager/models.py +307 -0
  24. appmanager/py.typed +1 -0
  25. appmanager/sdk.py +30 -0
  26. appmanager/security.py +227 -0
  27. appmanager/security_scanner.py +582 -0
  28. appmanager/signals.py +21 -0
  29. appmanager/static/css/app.css +808 -0
  30. appmanager/static/vendor/fonts/fonts.css +288 -0
  31. appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  32. appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  33. appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  34. appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  35. appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  36. appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  37. appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  38. appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  39. appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  40. appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  41. appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  42. appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  43. appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  44. appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  45. appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  46. appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  47. appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  48. appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  49. appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  50. appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  51. appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  52. appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  53. appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  54. appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  55. appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  56. appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  57. appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  58. appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  59. appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  60. appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  61. appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  62. appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  63. appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  64. appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  65. appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  66. appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  67. appmanager/static/vendor/htmx/htmx.min.js +1 -0
  68. appmanager/templates/admin/_member_drawer.html +98 -0
  69. appmanager/templates/admin/_members_table.html +108 -0
  70. appmanager/templates/admin/app_detail.html +136 -0
  71. appmanager/templates/admin/dashboard.html +902 -0
  72. appmanager/templates/admin/flairs.html +90 -0
  73. appmanager/templates/admin/permissions.html +88 -0
  74. appmanager/templates/admin/roles.html +119 -0
  75. appmanager/templates/admin/users.html +156 -0
  76. appmanager/templates/auth/login.html +58 -0
  77. appmanager/templates/auth/profile.html +44 -0
  78. appmanager/templates/base.html +76 -0
  79. appmanager/templates/errors/401.html +19 -0
  80. appmanager/templates/errors/403.html +19 -0
  81. appmanager/templates/errors/404.html +18 -0
  82. appmanager/templates/errors/500.html +19 -0
  83. appmanager/templates/index.html +54 -0
  84. appmanager_server-0.2.0.dist-info/METADATA +320 -0
  85. appmanager_server-0.2.0.dist-info/RECORD +89 -0
  86. appmanager_server-0.2.0.dist-info/WHEEL +5 -0
  87. appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
  88. appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
  89. 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")