wraith-cli 1.2.0__tar.gz → 1.3.0__tar.gz
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.
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.env.example +2 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.gitea/workflows/pages.yml +1 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.gitignore +1 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.pre-commit-config.yaml +7 -4
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/CHANGELOG.md +2 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/PKG-INFO +5 -5
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/README.md +3 -3
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/mkdocs.yml +2 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/pyproject.toml +5 -17
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/src/wraith_cli/main.py +88 -53
- wraith_cli-1.3.0/src/wraith_cli/shield.py +187 -0
- wraith_cli-1.3.0/tests/test_cli.py +301 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/tests/test_qol.py +2 -1
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/tests/test_repo_make.py +33 -0
- wraith_cli-1.3.0/tests/test_shield.py +236 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/uv.lock +27 -124
- wraith_cli-1.2.0/tests/test_cli.py +0 -143
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.cz.yaml +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.gitea/CODEOWNERS.md +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.gitea/PULL_REQUEST_TEMPLATE.md +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.gitea/workflows/release.yml +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.gitea/workflows/test.yml +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/.secrets.baseline +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/CONTRIBUTING.md +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/LICENSE +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/bin/build.sh +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/bin/run_tests.sh +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/bin/setup_venv.sh +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/docs/architecture.md +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/docs/index.md +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/docs/reference.md +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/docs/security.md +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/docs/usage.md +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/src/wraith_cli/__init__.py +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/src/wraith_cli/qol.py +0 -0
- {wraith_cli-1.2.0 → wraith_cli-1.3.0}/src/wraith_cli/repo_make.py +0 -0
|
@@ -14,11 +14,14 @@ repos:
|
|
|
14
14
|
args: ["--fix"]
|
|
15
15
|
- id: ruff-format
|
|
16
16
|
|
|
17
|
-
- repo:
|
|
18
|
-
rev: v1.15.0
|
|
17
|
+
- repo: local
|
|
19
18
|
hooks:
|
|
20
|
-
- id:
|
|
21
|
-
|
|
19
|
+
- id: ty
|
|
20
|
+
name: ty
|
|
21
|
+
entry: uv run ty check
|
|
22
|
+
language: system
|
|
23
|
+
types: [python]
|
|
24
|
+
pass_filenames: false
|
|
22
25
|
|
|
23
26
|
- repo: https://github.com/Yelp/detect-secrets
|
|
24
27
|
rev: v1.5.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wraith-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Sovereign Command Centre for a Ghost Stack
|
|
5
5
|
Project-URL: Homepage, https://git.thomaspeoples.com/thomaspeoples/wraith-cli
|
|
6
6
|
Project-URL: Documentation, https://www.thomaspeoples.com/gitea-repos/wraith-cli/
|
|
@@ -42,19 +42,19 @@ Requires-Dist: detect-secrets; extra == 'dev'
|
|
|
42
42
|
Requires-Dist: genbadge[coverage]>=1.1.1; extra == 'dev'
|
|
43
43
|
Requires-Dist: mkdocs-material<=10.0; extra == 'dev'
|
|
44
44
|
Requires-Dist: mkdocs<=2.0; extra == 'dev'
|
|
45
|
-
Requires-Dist: mypy; extra == 'dev'
|
|
46
45
|
Requires-Dist: pre-commit; extra == 'dev'
|
|
47
46
|
Requires-Dist: pymdown-extensions>=10.7.0; extra == 'dev'
|
|
48
47
|
Requires-Dist: pytest; extra == 'dev'
|
|
49
48
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
50
49
|
Requires-Dist: ruff; extra == 'dev'
|
|
50
|
+
Requires-Dist: ty; extra == 'dev'
|
|
51
51
|
Requires-Dist: types-requests; extra == 'dev'
|
|
52
52
|
Description-Content-Type: text/markdown
|
|
53
53
|
|
|
54
54
|
[](https://www.thomaspeoples.com/gitea-repos/wraith-cli/)
|
|
55
|
-

|
|
56
|
+

|
|
57
|
+

|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
# 👻 Wraith-CLI
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[](https://www.thomaspeoples.com/gitea-repos/wraith-cli/)
|
|
2
|
-

|
|
3
|
+

|
|
4
|
+

|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
# 👻 Wraith-CLI
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wraith-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.3.0"
|
|
8
8
|
description = "Sovereign Command Centre for a Ghost Stack"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -41,11 +41,11 @@ wraith = "wraith_cli.main:app"
|
|
|
41
41
|
[project.optional-dependencies]
|
|
42
42
|
dev = [
|
|
43
43
|
"ruff",
|
|
44
|
+
"ty",
|
|
44
45
|
"pytest",
|
|
45
46
|
"pytest-cov",
|
|
46
47
|
"pre-commit",
|
|
47
48
|
"commitizen",
|
|
48
|
-
"mypy",
|
|
49
49
|
"types-requests",
|
|
50
50
|
"detect-secrets",
|
|
51
51
|
"mkdocs<=2.0",
|
|
@@ -56,34 +56,22 @@ dev = [
|
|
|
56
56
|
|
|
57
57
|
[tool.pytest.ini_options]
|
|
58
58
|
xfail_strict = true
|
|
59
|
-
addopts = ["--cov=
|
|
59
|
+
addopts = ["--cov=wraith_cli", "--cov-report=term-missing", "--tb=short"]
|
|
60
60
|
pythonpath = ["src"]
|
|
61
61
|
|
|
62
62
|
[tool.ruff]
|
|
63
63
|
line-length = 79
|
|
64
64
|
target-version = "py312"
|
|
65
|
+
preview = true
|
|
65
66
|
|
|
66
67
|
[tool.ruff.lint]
|
|
67
|
-
select = ["E", "F", "I"]
|
|
68
|
+
select = ["E", "F", "I", "N", "UP", "B"]
|
|
68
69
|
ignore = []
|
|
69
70
|
|
|
70
71
|
[tool.ruff.format]
|
|
71
72
|
quote-style = "double"
|
|
72
73
|
indent-style = "space"
|
|
73
74
|
|
|
74
|
-
[tool.mypy]
|
|
75
|
-
# The "Strict" setting enforces type hints everywhere
|
|
76
|
-
strict = true
|
|
77
|
-
python_version = "3.12"
|
|
78
|
-
|
|
79
|
-
# Don't moan about missing stubs for libraries that don't have them
|
|
80
|
-
ignore_missing_imports = true
|
|
81
|
-
|
|
82
|
-
# Make sure we don't accidentally leave 'any' types in the code
|
|
83
|
-
disallow_untyped_defs = true
|
|
84
|
-
disallow_incomplete_defs = true
|
|
85
|
-
warn_unused_ignores = true
|
|
86
|
-
|
|
87
75
|
[tool.hatch.build.targets.wheel]
|
|
88
76
|
packages = ["src/wraith_cli"]
|
|
89
77
|
|
|
@@ -2,7 +2,7 @@ import os
|
|
|
2
2
|
import subprocess
|
|
3
3
|
from importlib.metadata import version as get_local_version
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import
|
|
5
|
+
from typing import Annotated
|
|
6
6
|
|
|
7
7
|
import requests
|
|
8
8
|
import typer
|
|
@@ -12,7 +12,22 @@ from rich.markdown import Markdown
|
|
|
12
12
|
from rich.panel import Panel
|
|
13
13
|
from rich.text import Text
|
|
14
14
|
|
|
15
|
-
from wraith_cli import qol, repo_make
|
|
15
|
+
from wraith_cli import qol, repo_make, shield
|
|
16
|
+
|
|
17
|
+
SOVEREIGN_ADVISORY = """
|
|
18
|
+
## 🛡️ Sovereign Security Advisory – Please Read
|
|
19
|
+
|
|
20
|
+
**Wraith-CLI** is your stack’s nervous system and has sharp edges.
|
|
21
|
+
The commands in this toolkit can wipe runner data (`runner-reset`),
|
|
22
|
+
view across all containers (`ps`), and expose sensitive stack paths.
|
|
23
|
+
|
|
24
|
+
**A bad config or unintended command can destabilise your core stack.**
|
|
25
|
+
|
|
26
|
+
Recommended baseline:
|
|
27
|
+
- Use strong passphrases on private keys if exposed to Wraith.
|
|
28
|
+
- Sandboxed execution where possible for third-party modules.
|
|
29
|
+
- Audit environment variable scope (`GITEA_COMPOSE_PATH`, etc.)
|
|
30
|
+
"""
|
|
16
31
|
|
|
17
32
|
# Load environment variables from .env
|
|
18
33
|
load_dotenv()
|
|
@@ -42,22 +57,6 @@ def version_callback(value: bool):
|
|
|
42
57
|
raise typer.Exit()
|
|
43
58
|
|
|
44
59
|
|
|
45
|
-
SECURITY_WARNING = """
|
|
46
|
-
## 🛡️ Sovereign Security Advisory – Please Read
|
|
47
|
-
|
|
48
|
-
**Wraith-CLI** is your stack’s nervous system and has sharp edges.
|
|
49
|
-
The commands in this toolkit can wipe runner data (`runner-reset`),
|
|
50
|
-
view across all containers (`ps`), and expose sensitive stack paths.
|
|
51
|
-
|
|
52
|
-
**A bad config or unintended command can destabilise your core stack.**
|
|
53
|
-
|
|
54
|
-
Recommended baseline:
|
|
55
|
-
- Use strong passphrases on private keys if exposed to Wraith.
|
|
56
|
-
- Sandboxed execution where possible for third-party modules.
|
|
57
|
-
- Audit environment variable scope (`GITEA_COMPOSE_PATH`, etc.)
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
|
|
61
60
|
def print_wraith_welcome(): # pragma: no cover
|
|
62
61
|
"""Renders the definitive OpenClaw-inspired Sovereign splash."""
|
|
63
62
|
console = qol.console
|
|
@@ -85,27 +84,14 @@ def print_wraith_welcome(): # pragma: no cover
|
|
|
85
84
|
)
|
|
86
85
|
console.print("\n")
|
|
87
86
|
|
|
88
|
-
SOVEREIGN_ADVISORY = """
|
|
89
|
-
## Sovereign Security Advisory – Please Read
|
|
90
|
-
|
|
91
|
-
**Wraith-CLI** is your stack’s nervous system and has sharp edges.
|
|
92
|
-
The commands in this toolkit can wipe runner data (`runner-reset`),
|
|
93
|
-
view across all containers (`ps`), and expose sensitive stack paths.
|
|
94
|
-
|
|
95
|
-
**A bad config or unintended command can destabilise your core stack.**
|
|
96
|
-
|
|
97
|
-
Recommended baseline:
|
|
98
|
-
- Use strong passphrases on private keys if exposed to Wraith.
|
|
99
|
-
- Sandboxed execution where possible for third-party modules.
|
|
100
|
-
- Audit environment variable scope (`GITEA_COMPOSE_PATH`, etc.)
|
|
101
|
-
"""
|
|
102
87
|
md = Markdown(SOVEREIGN_ADVISORY)
|
|
103
88
|
|
|
104
89
|
panel = Panel(
|
|
105
90
|
md,
|
|
106
|
-
title=
|
|
107
|
-
Wraith Orchestrator –
|
|
108
|
-
""
|
|
91
|
+
title=(
|
|
92
|
+
"Wraith Orchestrator – "
|
|
93
|
+
f"[bright_black]v{local_v} Advisory[/bright_black]"
|
|
94
|
+
),
|
|
109
95
|
border_style="bright_black",
|
|
110
96
|
padding=(1, 2),
|
|
111
97
|
)
|
|
@@ -117,13 +103,15 @@ Wraith Orchestrator – [bright_black]v{local_v} Advisory[/bright_black]
|
|
|
117
103
|
@app.callback(invoke_without_command=True)
|
|
118
104
|
def main(
|
|
119
105
|
ctx: typer.Context,
|
|
120
|
-
version:
|
|
121
|
-
None,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
106
|
+
version: Annotated[
|
|
107
|
+
bool | None,
|
|
108
|
+
typer.Option(
|
|
109
|
+
"--version",
|
|
110
|
+
callback=version_callback,
|
|
111
|
+
is_eager=True,
|
|
112
|
+
help="Show version and exit.",
|
|
113
|
+
),
|
|
114
|
+
] = None,
|
|
127
115
|
):
|
|
128
116
|
"""
|
|
129
117
|
[bold green]Wraith-CLI[/bold green]
|
|
@@ -174,7 +162,9 @@ def update():
|
|
|
174
162
|
|
|
175
163
|
@app.command()
|
|
176
164
|
def ps(
|
|
177
|
-
all:
|
|
165
|
+
all: Annotated[
|
|
166
|
+
bool, typer.Option("--all", "-a", help="Show all containers")
|
|
167
|
+
] = False,
|
|
178
168
|
):
|
|
179
169
|
"""View Docker process list with Sovereign styling."""
|
|
180
170
|
cmd = ["docker", "ps"]
|
|
@@ -198,13 +188,15 @@ def tail(
|
|
|
198
188
|
service: str = typer.Argument(
|
|
199
189
|
..., help="Service name (e.g., ollama, gitea)"
|
|
200
190
|
),
|
|
201
|
-
path:
|
|
202
|
-
None,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
191
|
+
path: Annotated[
|
|
192
|
+
Path | None,
|
|
193
|
+
typer.Option(
|
|
194
|
+
"--path",
|
|
195
|
+
"-p",
|
|
196
|
+
envvar="WRAITH_COMPOSE_PATH",
|
|
197
|
+
help="Path to directory with docker-compose.yml.",
|
|
198
|
+
),
|
|
199
|
+
] = None,
|
|
208
200
|
):
|
|
209
201
|
"""Tail logs for any service in your stack.
|
|
210
202
|
Priority:
|
|
@@ -270,6 +262,10 @@ def spawn(
|
|
|
270
262
|
)
|
|
271
263
|
raise typer.Exit(1)
|
|
272
264
|
|
|
265
|
+
assert GITEA_API_URL is not None
|
|
266
|
+
assert GITEA_TOKEN is not None
|
|
267
|
+
assert GITEA_TEMPLATE_URL is not None
|
|
268
|
+
|
|
273
269
|
target_dir = Path.cwd() / repo_name
|
|
274
270
|
typer.echo(f"👻 Spawning '{repo_name}' from Repo Factory...")
|
|
275
271
|
|
|
@@ -294,11 +290,50 @@ def spawn(
|
|
|
294
290
|
|
|
295
291
|
except FileExistsError as e:
|
|
296
292
|
typer.secho(f"❌ Error: {e}", fg="red")
|
|
297
|
-
raise typer.Exit(1)
|
|
293
|
+
raise typer.Exit(1) from e
|
|
298
294
|
except Exception as e:
|
|
299
295
|
typer.secho(f"❌ Critical Failure: {e}", fg="red")
|
|
300
|
-
raise typer.Exit(1)
|
|
296
|
+
raise typer.Exit(1) from e
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.command()
|
|
300
|
+
def logs(
|
|
301
|
+
health: Annotated[
|
|
302
|
+
bool,
|
|
303
|
+
typer.Option(
|
|
304
|
+
"--health", help="Pull SMART data summary from Scrutiny API"
|
|
305
|
+
),
|
|
306
|
+
] = False,
|
|
307
|
+
):
|
|
308
|
+
"""View stack logs or bare-metal health status."""
|
|
309
|
+
if health:
|
|
310
|
+
typer.echo("🔍 Polling Bare-Metal SMART data via Scrutiny...")
|
|
311
|
+
health_table = shield.get_scrutiny_health()
|
|
312
|
+
qol.console.print(health_table)
|
|
313
|
+
else:
|
|
314
|
+
typer.echo(
|
|
315
|
+
"Use 'wraith tail <service>' for container logs. Pass --health for drive health."
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@app.command()
|
|
320
|
+
def mesh():
|
|
321
|
+
"""Checks Tailscale status and lists active peers in the Ghost Mesh."""
|
|
322
|
+
typer.echo("🌐 Querying Ghost Mesh (Tailscale)...")
|
|
323
|
+
mesh_table = shield.get_tailscale_mesh()
|
|
324
|
+
qol.console.print(mesh_table)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.command()
|
|
328
|
+
def audit():
|
|
329
|
+
"""
|
|
330
|
+
Checks for containers running as root or exposed ports not in your
|
|
331
|
+
Registry Spec.
|
|
332
|
+
"""
|
|
333
|
+
typer.echo("🛡️ Initiating Sovereign Security Audit...")
|
|
334
|
+
audit_table = shield.run_security_audit()
|
|
335
|
+
qol.console.print(audit_table)
|
|
301
336
|
|
|
302
337
|
|
|
303
|
-
if __name__ == "__main__":
|
|
338
|
+
if __name__ == "__main__": # pragma: no cover
|
|
304
339
|
app()
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import requests
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
# Pull from .env, fallback to the Ghost Stack default port
|
|
8
|
+
SCRUTINY_URL = os.getenv("SCRUTINY_URL", "http://127.0.0.1:8090")
|
|
9
|
+
|
|
10
|
+
def get_scrutiny_health() -> Table:
|
|
11
|
+
"""Fetches SMART data from the Scrutiny API and formats it.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
Table: A rich Table object containing device health information.
|
|
15
|
+
"""
|
|
16
|
+
table = Table(
|
|
17
|
+
title="Scrutiny SMART Health",
|
|
18
|
+
border_style="bright_black",
|
|
19
|
+
header_style="bold green",
|
|
20
|
+
)
|
|
21
|
+
table.add_column("Device")
|
|
22
|
+
table.add_column("Capacity")
|
|
23
|
+
table.add_column("Temp")
|
|
24
|
+
table.add_column("Status")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
# Scrutiny summary API endpoint
|
|
28
|
+
response = requests.get(f"{SCRUTINY_URL}/api/summary", timeout=5)
|
|
29
|
+
response.raise_for_status()
|
|
30
|
+
data = response.json()
|
|
31
|
+
|
|
32
|
+
devices = data.get("data", {}).get("summary", {})
|
|
33
|
+
if not devices:
|
|
34
|
+
table.add_row("No devices found.", "-", "-", "-")
|
|
35
|
+
return table
|
|
36
|
+
|
|
37
|
+
for wwn, device in devices.items():
|
|
38
|
+
name = device.get("device", {}).get("name", "Unknown")
|
|
39
|
+
capacity_bytes = device.get("device", {}).get("capacity", 0)
|
|
40
|
+
capacity_tb = f"{(capacity_bytes / (10**12)):.1f} TB" if capacity_bytes else "Unknown"
|
|
41
|
+
|
|
42
|
+
temp = str(device.get("smart", {}).get("temp", "N/A")) + "°C"
|
|
43
|
+
status = device.get("smart", {}).get("status", "Unknown")
|
|
44
|
+
|
|
45
|
+
# Sovereign styling for status
|
|
46
|
+
status_color = "green" if status.lower() == "passed" else "red"
|
|
47
|
+
formatted_status = f"[{status_color}]{status.upper()}[/{status_color}]"
|
|
48
|
+
|
|
49
|
+
table.add_row(name, capacity_tb, temp, formatted_status)
|
|
50
|
+
|
|
51
|
+
except requests.exceptions.RequestException as e:
|
|
52
|
+
table.add_row(f"[red]API Error: {e}[/red]", "-", "-", "-")
|
|
53
|
+
|
|
54
|
+
return table
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_tailscale_mesh() -> Table:
|
|
58
|
+
"""Pulls Tailscale status via JSON and formats active peers.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Table: A rich Table object containing Tailscale mesh peer status.
|
|
62
|
+
"""
|
|
63
|
+
table = Table(
|
|
64
|
+
title="Ghost Mesh (Tailscale)",
|
|
65
|
+
border_style="bright_black",
|
|
66
|
+
header_style="bold green",
|
|
67
|
+
)
|
|
68
|
+
table.add_column("Hostname")
|
|
69
|
+
table.add_column("Tailscale IP")
|
|
70
|
+
table.add_column("OS")
|
|
71
|
+
table.add_column("Status")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
["tailscale", "status", "--json"],
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
check=True,
|
|
79
|
+
)
|
|
80
|
+
data = json.loads(result.stdout)
|
|
81
|
+
|
|
82
|
+
peers = data.get("Peer", {})
|
|
83
|
+
for peer_id, peer_data in peers.items():
|
|
84
|
+
hostname = peer_data.get("HostName", "Unknown")
|
|
85
|
+
ip = peer_data.get("TailscaleIPs", ["Unknown"])[0]
|
|
86
|
+
os_name = peer_data.get("OS", "Unknown")
|
|
87
|
+
is_online = peer_data.get("Online", False)
|
|
88
|
+
|
|
89
|
+
status_str = (
|
|
90
|
+
"[green]Online[/green]"
|
|
91
|
+
if is_online
|
|
92
|
+
else "[bright_black]Offline[/bright_black]"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
table.add_row(hostname, ip, os_name, status_str)
|
|
96
|
+
|
|
97
|
+
except Exception as e:
|
|
98
|
+
table.add_row(f"[red]Mesh Error: {e}[/red]", "-", "-", "-")
|
|
99
|
+
|
|
100
|
+
return table
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_security_audit() -> Table:
|
|
104
|
+
"""Checks for containers running as root or highly exposed ports.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Table: A rich Table object with security audit results.
|
|
108
|
+
"""
|
|
109
|
+
table = Table(
|
|
110
|
+
title="Sovereign Security Audit",
|
|
111
|
+
border_style="bright_black",
|
|
112
|
+
header_style="bold green",
|
|
113
|
+
)
|
|
114
|
+
table.add_column("Container")
|
|
115
|
+
table.add_column("User")
|
|
116
|
+
table.add_column("Exposed Ports")
|
|
117
|
+
table.add_column("Verdict")
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
# Get all running container IDs
|
|
121
|
+
ps_result = subprocess.run(
|
|
122
|
+
["docker", "ps", "-q"],
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True,
|
|
125
|
+
check=True,
|
|
126
|
+
)
|
|
127
|
+
container_ids = [cid for cid in ps_result.stdout.split("\n") if cid]
|
|
128
|
+
|
|
129
|
+
if not container_ids:
|
|
130
|
+
table.add_row("No running containers.", "-", "-", "-")
|
|
131
|
+
return table
|
|
132
|
+
|
|
133
|
+
# Inspect all at once
|
|
134
|
+
inspect_result = subprocess.run(
|
|
135
|
+
["docker", "inspect"] + container_ids,
|
|
136
|
+
capture_output=True,
|
|
137
|
+
text=True,
|
|
138
|
+
check=True,
|
|
139
|
+
)
|
|
140
|
+
containers = json.loads(inspect_result.stdout)
|
|
141
|
+
|
|
142
|
+
for c in containers:
|
|
143
|
+
name = c["Name"].lstrip("/")
|
|
144
|
+
|
|
145
|
+
# Check User
|
|
146
|
+
user = c["Config"].get("User", "")
|
|
147
|
+
if not user or user == "0" or user == "root":
|
|
148
|
+
user_disp = "[red]ROOT[/red]"
|
|
149
|
+
is_root = True
|
|
150
|
+
else:
|
|
151
|
+
user_disp = f"[green]{user}[/green]"
|
|
152
|
+
is_root = False
|
|
153
|
+
|
|
154
|
+
# Check Ports (Simplified: grabs bindings)
|
|
155
|
+
ports = c["NetworkSettings"].get("Ports", {})
|
|
156
|
+
exposed = []
|
|
157
|
+
for port, bindings in ports.items():
|
|
158
|
+
if bindings:
|
|
159
|
+
for b in bindings:
|
|
160
|
+
host_ip = b.get("HostIp", "")
|
|
161
|
+
host_port = b.get("HostPort", "")
|
|
162
|
+
# Flag if exposed globally (0.0.0.0) vs. locally.
|
|
163
|
+
if host_ip == "0.0.0.0" or host_ip == "":
|
|
164
|
+
exposed.append(
|
|
165
|
+
f"[yellow]{host_port}->{port} (Global)[/yellow]"
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
exposed.append(f"{host_port}->{port}")
|
|
169
|
+
|
|
170
|
+
port_disp = ", ".join(exposed) if exposed else "Internal Only"
|
|
171
|
+
|
|
172
|
+
# Determine Verdict
|
|
173
|
+
if is_root and "Global" in port_disp:
|
|
174
|
+
verdict = "[red]CRITICAL: Root + Global Port[/red]"
|
|
175
|
+
elif is_root:
|
|
176
|
+
verdict = "[yellow]WARNING: Root Context[/yellow]"
|
|
177
|
+
elif "Global" in port_disp:
|
|
178
|
+
verdict = "[yellow]WARNING: Global Exposure[/yellow]"
|
|
179
|
+
else:
|
|
180
|
+
verdict = "[green]SECURE[/green]"
|
|
181
|
+
|
|
182
|
+
table.add_row(name, user_disp, port_disp, verdict)
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
table.add_row(f"[red]Audit Error: {e}[/red]", "-", "-", "-")
|
|
186
|
+
|
|
187
|
+
return table
|