cirdanops 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cirdan/__init__.py +3 -0
- cirdan/access/__init__.py +3 -0
- cirdan/access/context.py +221 -0
- cirdan/access/redaction.py +56 -0
- cirdan/actions/__init__.py +3 -0
- cirdan/actions/executor.py +165 -0
- cirdan/adapters/__init__.py +3 -0
- cirdan/adapters/aws.py +141 -0
- cirdan/adapters/base.py +127 -0
- cirdan/adapters/common.py +133 -0
- cirdan/adapters/docker.py +221 -0
- cirdan/adapters/docker_compose.py +124 -0
- cirdan/adapters/github_actions.py +76 -0
- cirdan/adapters/helm.py +84 -0
- cirdan/adapters/k8s_manifests.py +182 -0
- cirdan/adapters/kubernetes.py +374 -0
- cirdan/adapters/local_files.py +47 -0
- cirdan/adapters/nginx.py +69 -0
- cirdan/adapters/prometheus.py +70 -0
- cirdan/adapters/registry.py +69 -0
- cirdan/adapters/sql_schema.py +54 -0
- cirdan/adapters/systemd.py +87 -0
- cirdan/adapters/systemd_units.py +54 -0
- cirdan/adapters/terraform.py +161 -0
- cirdan/agents/__init__.py +3 -0
- cirdan/agents/installer.py +156 -0
- cirdan/api/__init__.py +0 -0
- cirdan/api/http.py +197 -0
- cirdan/audit.py +37 -0
- cirdan/cli/__init__.py +0 -0
- cirdan/cli/main.py +418 -0
- cirdan/config.py +102 -0
- cirdan/daemon/__init__.py +3 -0
- cirdan/daemon/server.py +132 -0
- cirdan/engine.py +323 -0
- cirdan/fingerprint/__init__.py +3 -0
- cirdan/fingerprint/engine.py +141 -0
- cirdan/graph/__init__.py +21 -0
- cirdan/graph/builder.py +98 -0
- cirdan/graph/diff.py +97 -0
- cirdan/graph/export.py +101 -0
- cirdan/graph/queries.py +144 -0
- cirdan/graph/schema.py +169 -0
- cirdan/graph/store.py +359 -0
- cirdan/incidents/__init__.py +4 -0
- cirdan/incidents/detector.py +131 -0
- cirdan/incidents/reports.py +82 -0
- cirdan/incidents/store.py +90 -0
- cirdan/mcp/__init__.py +0 -0
- cirdan/mcp/server.py +256 -0
- cirdan/query.py +178 -0
- cirdan/reports/__init__.py +3 -0
- cirdan/reports/infra_report.py +130 -0
- cirdan/telemetry/__init__.py +4 -0
- cirdan/telemetry/clusters.py +69 -0
- cirdan/telemetry/events.py +148 -0
- cirdan/ui/__init__.py +4 -0
- cirdan/ui/render.py +109 -0
- cirdan/ui/router.py +232 -0
- cirdan/ui/templates/view.html.j2 +218 -0
- cirdan/ui/view_spec.py +67 -0
- cirdan/util.py +80 -0
- cirdan/verify/__init__.py +3 -0
- cirdan/verify/checks.py +95 -0
- cirdanops-0.1.0.dist-info/METADATA +379 -0
- cirdanops-0.1.0.dist-info/RECORD +69 -0
- cirdanops-0.1.0.dist-info/WHEEL +4 -0
- cirdanops-0.1.0.dist-info/entry_points.txt +3 -0
- cirdanops-0.1.0.dist-info/licenses/LICENSE +201 -0
cirdan/__init__.py
ADDED
cirdan/access/context.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Access-context detection.
|
|
2
|
+
|
|
3
|
+
Cirdan is not a permission manager: this module is a mirror of what the
|
|
4
|
+
current agent/session/process can already do. Every probe has a hard timeout
|
|
5
|
+
so detection never hangs in restricted sandboxes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from cirdan.config import CirdanConfig
|
|
18
|
+
from cirdan.util import now_iso, run_cmd, which
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AccessContext(BaseModel):
|
|
22
|
+
detected_at: str = Field(default_factory=now_iso)
|
|
23
|
+
mode: str = "inherited-agent-access"
|
|
24
|
+
source: dict = Field(default_factory=dict)
|
|
25
|
+
capabilities: dict[str, bool] = Field(default_factory=dict)
|
|
26
|
+
details: dict = Field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
def can(self, capability: str) -> bool:
|
|
29
|
+
return bool(self.capabilities.get(capability))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _detect_agent() -> str:
|
|
33
|
+
env = os.environ
|
|
34
|
+
if env.get("CLAUDECODE") or env.get("CLAUDE_CODE_ENTRYPOINT"):
|
|
35
|
+
return "claude-code"
|
|
36
|
+
if env.get("CODEX_HOME") or env.get("CODEX_SANDBOX"):
|
|
37
|
+
return "codex"
|
|
38
|
+
if env.get("CURSOR_TRACE_ID") or env.get("CURSOR_SESSION_ID"):
|
|
39
|
+
return "cursor"
|
|
40
|
+
if env.get("GEMINI_CLI"):
|
|
41
|
+
return "gemini-cli"
|
|
42
|
+
return "shell"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _probe_files(root: Path) -> tuple[bool, bool]:
|
|
46
|
+
readable = os.access(root, os.R_OK)
|
|
47
|
+
writable = False
|
|
48
|
+
if os.access(root, os.W_OK):
|
|
49
|
+
probe = root / ".cirdan-write-probe"
|
|
50
|
+
try:
|
|
51
|
+
probe.write_text("")
|
|
52
|
+
probe.unlink()
|
|
53
|
+
writable = True
|
|
54
|
+
except OSError:
|
|
55
|
+
writable = False
|
|
56
|
+
return readable, writable
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _probe_docker(caps: dict, details: dict) -> None:
|
|
60
|
+
sock = Path("/var/run/docker.sock")
|
|
61
|
+
caps["docker_socket"] = sock.exists() and stat.S_ISSOCK(sock.stat().st_mode) and os.access(sock, os.R_OK | os.W_OK)
|
|
62
|
+
caps["docker_cli"] = which("docker")
|
|
63
|
+
caps["docker_read"] = False
|
|
64
|
+
if caps["docker_cli"]:
|
|
65
|
+
res = run_cmd(["docker", "version", "--format", "{{.Server.Version}}"], timeout=4)
|
|
66
|
+
caps["docker_read"] = res.ok
|
|
67
|
+
if res.ok:
|
|
68
|
+
details["docker_server_version"] = res.stdout.strip()
|
|
69
|
+
# With Docker there is no finer-grained authz: daemon access implies write.
|
|
70
|
+
caps["docker_write"] = caps["docker_read"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _probe_kubernetes(caps: dict, details: dict) -> None:
|
|
74
|
+
kubeconfig = os.environ.get("KUBECONFIG")
|
|
75
|
+
has_kubeconfig = bool(kubeconfig and Path(kubeconfig).is_file()) or (Path.home() / ".kube" / "config").is_file()
|
|
76
|
+
in_cluster = bool(os.environ.get("KUBERNETES_SERVICE_HOST"))
|
|
77
|
+
caps["kubeconfig"] = has_kubeconfig
|
|
78
|
+
caps["kubernetes_in_cluster"] = in_cluster
|
|
79
|
+
caps["kubectl_cli"] = which("kubectl")
|
|
80
|
+
caps["kubernetes_read"] = False
|
|
81
|
+
caps["kubernetes_write"] = False
|
|
82
|
+
if not caps["kubectl_cli"] or not (has_kubeconfig or in_cluster):
|
|
83
|
+
return
|
|
84
|
+
res = run_cmd(["kubectl", "get", "namespaces", "-o", "name", "--request-timeout=3s"], timeout=6)
|
|
85
|
+
if res.ok:
|
|
86
|
+
caps["kubernetes_read"] = True
|
|
87
|
+
namespaces = [line.split("/", 1)[-1] for line in res.stdout.splitlines() if line.strip()]
|
|
88
|
+
details["kubernetes_namespaces"] = namespaces
|
|
89
|
+
ctx = run_cmd(["kubectl", "config", "current-context"], timeout=3)
|
|
90
|
+
if ctx.ok:
|
|
91
|
+
details["kubernetes_context"] = ctx.stdout.strip()
|
|
92
|
+
can_i = run_cmd(
|
|
93
|
+
["kubectl", "auth", "can-i", "patch", "deployments", "--request-timeout=3s"], timeout=6
|
|
94
|
+
)
|
|
95
|
+
caps["kubernetes_write"] = can_i.ok and can_i.stdout.strip().lower().startswith("yes")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _probe_aws(caps: dict, details: dict) -> None:
|
|
99
|
+
caps["aws_cli"] = which("aws")
|
|
100
|
+
env_creds = bool(os.environ.get("AWS_ACCESS_KEY_ID") or os.environ.get("AWS_PROFILE"))
|
|
101
|
+
file_creds = (Path.home() / ".aws" / "credentials").is_file() or (Path.home() / ".aws" / "config").is_file()
|
|
102
|
+
caps["aws_credentials"] = env_creds or file_creds
|
|
103
|
+
caps["aws_read"] = False
|
|
104
|
+
if caps["aws_cli"] and caps["aws_credentials"]:
|
|
105
|
+
res = run_cmd(["aws", "sts", "get-caller-identity", "--output", "json"], timeout=8)
|
|
106
|
+
caps["aws_read"] = res.ok
|
|
107
|
+
if res.ok:
|
|
108
|
+
from cirdan.util import parse_json
|
|
109
|
+
|
|
110
|
+
ident = parse_json(res.stdout)
|
|
111
|
+
if isinstance(ident, dict):
|
|
112
|
+
details["aws_account"] = ident.get("Account")
|
|
113
|
+
details["aws_arn"] = ident.get("Arn")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _probe_cloud_cli(caps: dict) -> None:
|
|
117
|
+
caps["azure_cli"] = which("az")
|
|
118
|
+
caps["azure_config"] = (Path.home() / ".azure").is_dir()
|
|
119
|
+
caps["gcloud_cli"] = which("gcloud")
|
|
120
|
+
caps["gcloud_config"] = (Path.home() / ".config" / "gcloud").is_dir()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _probe_systemd(caps: dict) -> None:
|
|
124
|
+
caps["systemd"] = which("systemctl") and Path("/run/systemd/system").exists()
|
|
125
|
+
caps["journald"] = which("journalctl")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _probe_prometheus(caps: dict, details: dict, config: CirdanConfig) -> None:
|
|
129
|
+
caps["prometheus_read"] = False
|
|
130
|
+
candidates = []
|
|
131
|
+
if config.telemetry.prometheus_url:
|
|
132
|
+
candidates.append(config.telemetry.prometheus_url)
|
|
133
|
+
if os.environ.get("PROMETHEUS_URL"):
|
|
134
|
+
candidates.append(os.environ["PROMETHEUS_URL"])
|
|
135
|
+
candidates.append("http://localhost:9090")
|
|
136
|
+
for url in candidates:
|
|
137
|
+
try:
|
|
138
|
+
resp = httpx.get(url.rstrip("/") + "/-/ready", timeout=1.5)
|
|
139
|
+
if resp.status_code < 500:
|
|
140
|
+
caps["prometheus_read"] = True
|
|
141
|
+
details["prometheus_url"] = url.rstrip("/")
|
|
142
|
+
return
|
|
143
|
+
except httpx.HTTPError:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def detect_access(config: CirdanConfig) -> AccessContext:
|
|
148
|
+
root = config.root_path
|
|
149
|
+
caps: dict[str, bool] = {}
|
|
150
|
+
details: dict = {}
|
|
151
|
+
|
|
152
|
+
caps["file_read"], caps["file_write"] = _probe_files(root)
|
|
153
|
+
caps["shell"] = which("sh") or which("bash")
|
|
154
|
+
_probe_docker(caps, details)
|
|
155
|
+
_probe_kubernetes(caps, details)
|
|
156
|
+
_probe_aws(caps, details)
|
|
157
|
+
_probe_cloud_cli(caps)
|
|
158
|
+
_probe_systemd(caps)
|
|
159
|
+
_probe_prometheus(caps, details, config)
|
|
160
|
+
|
|
161
|
+
return AccessContext(
|
|
162
|
+
source={
|
|
163
|
+
"agent": _detect_agent(),
|
|
164
|
+
"workspace": str(root),
|
|
165
|
+
"user": os.environ.get("USER", "unknown"),
|
|
166
|
+
},
|
|
167
|
+
capabilities=caps,
|
|
168
|
+
details=details,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def render_access_report(ctx: AccessContext) -> str:
|
|
173
|
+
caps = ctx.capabilities
|
|
174
|
+
|
|
175
|
+
def yn(key: str) -> str:
|
|
176
|
+
return "yes" if caps.get(key) else "no"
|
|
177
|
+
|
|
178
|
+
lines = [
|
|
179
|
+
"Cirdan Access Report",
|
|
180
|
+
"",
|
|
181
|
+
f"Source: {ctx.source.get('agent')} (workspace {ctx.source.get('workspace')})",
|
|
182
|
+
f"Mode: {ctx.mode}",
|
|
183
|
+
"",
|
|
184
|
+
"Files:",
|
|
185
|
+
f" repo read: {yn('file_read')}",
|
|
186
|
+
f" repo write: {yn('file_write')}",
|
|
187
|
+
"",
|
|
188
|
+
"Shell:",
|
|
189
|
+
f" available: {yn('shell')}",
|
|
190
|
+
"",
|
|
191
|
+
"Docker:",
|
|
192
|
+
f" docker socket: {yn('docker_socket')}",
|
|
193
|
+
f" docker CLI: {yn('docker_cli')}",
|
|
194
|
+
f" daemon reachable: {yn('docker_read')}",
|
|
195
|
+
"",
|
|
196
|
+
"Kubernetes:",
|
|
197
|
+
f" kubeconfig: {yn('kubeconfig')}",
|
|
198
|
+
f" in-cluster service account: {yn('kubernetes_in_cluster')}",
|
|
199
|
+
f" read access: {yn('kubernetes_read')}",
|
|
200
|
+
f" write access: {yn('kubernetes_write')}",
|
|
201
|
+
]
|
|
202
|
+
namespaces = ctx.details.get("kubernetes_namespaces")
|
|
203
|
+
if namespaces:
|
|
204
|
+
lines.append(f" namespaces visible: {', '.join(namespaces)}")
|
|
205
|
+
lines += [
|
|
206
|
+
"",
|
|
207
|
+
"Cloud:",
|
|
208
|
+
f" AWS identity: {'detected' if caps.get('aws_read') else 'not detected'}",
|
|
209
|
+
f" Azure identity: {'possible' if caps.get('azure_config') else 'not detected'}",
|
|
210
|
+
f" GCP identity: {'possible' if caps.get('gcloud_config') else 'not detected'}",
|
|
211
|
+
"",
|
|
212
|
+
"Telemetry:",
|
|
213
|
+
f" prometheus: {ctx.details.get('prometheus_url', 'not detected')}",
|
|
214
|
+
f" journald: {yn('journald')}",
|
|
215
|
+
"",
|
|
216
|
+
"Runtime:",
|
|
217
|
+
f" systemd: {yn('systemd')}",
|
|
218
|
+
f" docker: {yn('docker_read')}",
|
|
219
|
+
f" kubernetes: {yn('kubernetes_read')}",
|
|
220
|
+
]
|
|
221
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Scrub secret-shaped values before anything is written to artifacts or logs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
REDACTED = "[REDACTED]"
|
|
8
|
+
|
|
9
|
+
SECRET_KEY_RE = re.compile(
|
|
10
|
+
r"(secret|token|password|passwd|credential|api[_-]?key|access[_-]?key|private[_-]?key|auth)",
|
|
11
|
+
re.IGNORECASE,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_PATTERNS = [
|
|
15
|
+
# user:password@ in URLs
|
|
16
|
+
re.compile(r"(?<=://)([^/\s:@]+):([^/\s@]+)(?=@)"),
|
|
17
|
+
# AWS access key ids and session-ish tokens
|
|
18
|
+
re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"),
|
|
19
|
+
# Bearer tokens
|
|
20
|
+
re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{16,}"),
|
|
21
|
+
# key=value pairs where the key looks secret
|
|
22
|
+
re.compile(
|
|
23
|
+
r"(?i)\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY)[A-Z0-9_]*)\s*[=:]\s*([^\s,;\"']+)"
|
|
24
|
+
),
|
|
25
|
+
# PEM blocks
|
|
26
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def redact_text(text: str) -> str:
|
|
31
|
+
if not text:
|
|
32
|
+
return text
|
|
33
|
+
out = text
|
|
34
|
+
out = _PATTERNS[0].sub(REDACTED, out)
|
|
35
|
+
out = _PATTERNS[1].sub(REDACTED, out)
|
|
36
|
+
out = _PATTERNS[2].sub(f"Bearer {REDACTED}", out)
|
|
37
|
+
out = _PATTERNS[3].sub(lambda m: f"{m.group(1)}={REDACTED}", out)
|
|
38
|
+
out = _PATTERNS[4].sub(REDACTED, out)
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def redact_obj(obj: object) -> object:
|
|
43
|
+
"""Recursively redact strings; values under secret-shaped keys are dropped entirely."""
|
|
44
|
+
if isinstance(obj, dict):
|
|
45
|
+
out = {}
|
|
46
|
+
for key, value in obj.items():
|
|
47
|
+
if isinstance(key, str) and SECRET_KEY_RE.search(key) and isinstance(value, str) and value:
|
|
48
|
+
out[key] = REDACTED
|
|
49
|
+
else:
|
|
50
|
+
out[key] = redact_obj(value)
|
|
51
|
+
return out
|
|
52
|
+
if isinstance(obj, list):
|
|
53
|
+
return [redact_obj(v) for v in obj]
|
|
54
|
+
if isinstance(obj, str):
|
|
55
|
+
return redact_text(obj)
|
|
56
|
+
return obj
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Action discovery and execution through inherited session access.
|
|
2
|
+
|
|
3
|
+
Cirdan never escalates: an action exists only if the adapter that owns it can
|
|
4
|
+
already run the underlying command with the session's credentials. Every
|
|
5
|
+
execution is recorded — pre-state, command, output, post-state — in the
|
|
6
|
+
actions table, the audit log, and the graph itself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from cirdan.access.redaction import redact_text
|
|
17
|
+
from cirdan.adapters.base import ActionResult, ActionSpec
|
|
18
|
+
from cirdan.adapters.registry import get_adapters
|
|
19
|
+
from cirdan.engine import CirdanEngine
|
|
20
|
+
from cirdan.graph.schema import Confidence, Edge, Node, NodeType, Origin, Relation
|
|
21
|
+
from cirdan.util import now_iso
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ActionRecord(BaseModel):
|
|
25
|
+
record_id: str
|
|
26
|
+
spec: ActionSpec
|
|
27
|
+
status: str = "executed" # executed | verify_passed | verify_failed
|
|
28
|
+
pre_state: dict = Field(default_factory=dict)
|
|
29
|
+
post_state: dict = Field(default_factory=dict)
|
|
30
|
+
result: ActionResult | None = None
|
|
31
|
+
verification: dict = Field(default_factory=dict)
|
|
32
|
+
created_at: str = Field(default_factory=now_iso)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _live_adapters(engine: CirdanEngine) -> dict:
|
|
36
|
+
return {a.name: a for a in get_adapters(engine.config, engine.access, kind="live")}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def list_actions(engine: CirdanEngine, node_ref: str) -> list[ActionSpec]:
|
|
40
|
+
node = engine.store.resolve(node_ref)
|
|
41
|
+
if node is None:
|
|
42
|
+
return []
|
|
43
|
+
specs: list[ActionSpec] = []
|
|
44
|
+
for adapter in _live_adapters(engine).values():
|
|
45
|
+
try:
|
|
46
|
+
specs.extend(adapter.actions(node))
|
|
47
|
+
except Exception:
|
|
48
|
+
continue
|
|
49
|
+
# Workload nodes inherit actions from the runtime resources they create.
|
|
50
|
+
for edge in engine.store.edges_for(node.id, direction="out"):
|
|
51
|
+
if edge.relation != Relation.CREATES:
|
|
52
|
+
continue
|
|
53
|
+
child = engine.store.get_node(edge.target)
|
|
54
|
+
if child:
|
|
55
|
+
for adapter in _live_adapters(engine).values():
|
|
56
|
+
try:
|
|
57
|
+
specs.extend(adapter.actions(child))
|
|
58
|
+
except Exception:
|
|
59
|
+
continue
|
|
60
|
+
seen: set[str] = set()
|
|
61
|
+
unique = []
|
|
62
|
+
for spec in specs:
|
|
63
|
+
if spec.id not in seen:
|
|
64
|
+
seen.add(spec.id)
|
|
65
|
+
unique.append(spec)
|
|
66
|
+
return unique
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find_action(engine: CirdanEngine, action_id: str) -> ActionSpec | None:
|
|
70
|
+
"""Resolve an action id like docker.restart:my-container back to a live spec."""
|
|
71
|
+
if ":" not in action_id:
|
|
72
|
+
return None
|
|
73
|
+
target = action_id.split(":", 1)[1]
|
|
74
|
+
for ref in (target, target.split("/")[-1]):
|
|
75
|
+
for spec in list_actions(engine, ref):
|
|
76
|
+
if spec.id == action_id:
|
|
77
|
+
return spec
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def execute_action(engine: CirdanEngine, spec: ActionSpec) -> ActionRecord:
|
|
82
|
+
adapter = _live_adapters(engine).get(spec.adapter)
|
|
83
|
+
if adapter is None:
|
|
84
|
+
raise RuntimeError(f"adapter '{spec.adapter}' is not available in this session")
|
|
85
|
+
record = ActionRecord(record_id=f"act-{uuid.uuid4().hex[:8]}", spec=spec)
|
|
86
|
+
try:
|
|
87
|
+
record.pre_state = adapter.current_state(spec.node_id)
|
|
88
|
+
except Exception:
|
|
89
|
+
record.pre_state = {}
|
|
90
|
+
result = adapter.execute(spec)
|
|
91
|
+
result.stdout = redact_text(result.stdout)
|
|
92
|
+
result.stderr = redact_text(result.stderr)
|
|
93
|
+
record.result = result
|
|
94
|
+
try:
|
|
95
|
+
record.post_state = adapter.current_state(spec.node_id)
|
|
96
|
+
except Exception:
|
|
97
|
+
record.post_state = {}
|
|
98
|
+
_persist(engine, record)
|
|
99
|
+
_record_in_graph(engine, record)
|
|
100
|
+
_attach_to_incidents(engine, record)
|
|
101
|
+
engine.audit.write(
|
|
102
|
+
"action",
|
|
103
|
+
f"executed {spec.id} ({'ok' if result.ok else 'failed rc=' + str(result.returncode)})",
|
|
104
|
+
record_id=record.record_id, command=" ".join(spec.argv), writes=spec.writes,
|
|
105
|
+
)
|
|
106
|
+
return record
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _persist(engine: CirdanEngine, record: ActionRecord) -> None:
|
|
110
|
+
with engine.store.lock:
|
|
111
|
+
engine.store.conn.execute(
|
|
112
|
+
"INSERT OR REPLACE INTO actions (id, status, created_at, data) VALUES (?,?,?,?)",
|
|
113
|
+
(record.record_id, record.status, record.created_at, record.model_dump_json()),
|
|
114
|
+
)
|
|
115
|
+
engine.store.conn.commit()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_record(engine: CirdanEngine, record_id: str) -> ActionRecord | None:
|
|
119
|
+
with engine.store.lock:
|
|
120
|
+
row = engine.store.conn.execute(
|
|
121
|
+
"SELECT data FROM actions WHERE id=? OR id LIKE ?", (record_id, f"{record_id}%")
|
|
122
|
+
).fetchone()
|
|
123
|
+
return ActionRecord.model_validate_json(row["data"]) if row else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def update_record(engine: CirdanEngine, record: ActionRecord) -> None:
|
|
127
|
+
_persist(engine, record)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def list_records(engine: CirdanEngine, limit: int = 50) -> list[ActionRecord]:
|
|
131
|
+
with engine.store.lock:
|
|
132
|
+
rows = engine.store.conn.execute(
|
|
133
|
+
"SELECT data FROM actions ORDER BY created_at DESC LIMIT ?", (limit,)
|
|
134
|
+
).fetchall()
|
|
135
|
+
return [ActionRecord.model_validate_json(r["data"]) for r in rows]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _record_in_graph(engine: CirdanEngine, record: ActionRecord) -> None:
|
|
139
|
+
spec, result = record.spec, record.result
|
|
140
|
+
engine.store.upsert_node(
|
|
141
|
+
Node(
|
|
142
|
+
id=f"action:{record.record_id}", type=NodeType.ACTION.value,
|
|
143
|
+
name=f"{spec.name} {spec.node_id}", origin=Origin.DERIVED, source_adapter="actions",
|
|
144
|
+
confidence=Confidence.EXTRACTED,
|
|
145
|
+
evidence=[f"command: {' '.join(spec.argv)}",
|
|
146
|
+
f"exit code {result.returncode if result else '?'} at {record.created_at}"],
|
|
147
|
+
attrs={"ok": bool(result and result.ok), "writes": spec.writes,
|
|
148
|
+
"record_id": record.record_id},
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
if engine.store.get_node(spec.node_id):
|
|
152
|
+
engine.store.upsert_edge(
|
|
153
|
+
Edge(source=f"action:{record.record_id}", target=spec.node_id,
|
|
154
|
+
relation=Relation.AFFECTS, confidence=Confidence.EXTRACTED,
|
|
155
|
+
evidence=[f"action executed against {spec.node_id}"])
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _attach_to_incidents(engine: CirdanEngine, record: ActionRecord) -> None:
|
|
160
|
+
for incident in engine.incidents.list(include_resolved=False):
|
|
161
|
+
if record.spec.node_id in incident.affected_nodes:
|
|
162
|
+
incident.actions.append(record.record_id)
|
|
163
|
+
if record.spec.writes:
|
|
164
|
+
incident.transition("verifying", f"action {record.spec.id} executed")
|
|
165
|
+
engine.incidents.upsert(incident)
|
cirdan/adapters/aws.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Live adapter: read-only AWS discovery via the aws CLI and credentials already present.
|
|
2
|
+
|
|
3
|
+
Every call is best-effort: a missing permission simply means that surface
|
|
4
|
+
stays invisible, mirroring exactly what the current session could see by hand.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from cirdan.adapters.base import Adapter, Signal
|
|
10
|
+
from cirdan.graph.schema import Confidence, DiscoveryResult, Edge, Node, NodeType, Origin, Relation
|
|
11
|
+
from cirdan.util import parse_json, run_cmd
|
|
12
|
+
|
|
13
|
+
AWS_TIMEOUT = 15
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _aws_json(args: list[str]) -> dict | list | None:
|
|
17
|
+
res = run_cmd(["aws", *args, "--output", "json"], timeout=AWS_TIMEOUT)
|
|
18
|
+
return parse_json(res.stdout) if res.ok else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AwsAdapter(Adapter):
|
|
22
|
+
name = "aws"
|
|
23
|
+
kind = "live"
|
|
24
|
+
|
|
25
|
+
def available(self) -> bool:
|
|
26
|
+
return self.access.can("aws_read")
|
|
27
|
+
|
|
28
|
+
def fingerprint(self) -> list[Signal]:
|
|
29
|
+
if self.access.details.get("aws_account"):
|
|
30
|
+
return [Signal(system="aws", weight=0.5,
|
|
31
|
+
evidence=f"AWS account {self.access.details['aws_account']} reachable")]
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
def discover(self) -> DiscoveryResult:
|
|
35
|
+
result = DiscoveryResult(adapter=self.name)
|
|
36
|
+
account = self.access.details.get("aws_account", "unknown")
|
|
37
|
+
account_id = f"aws-account:{account}"
|
|
38
|
+
result.nodes.append(
|
|
39
|
+
Node(id=account_id, type=NodeType.CLOUD_ACCOUNT.value, name=f"aws {account}",
|
|
40
|
+
origin=Origin.LIVE, source_adapter=self.name,
|
|
41
|
+
evidence=["aws sts get-caller-identity"],
|
|
42
|
+
attrs={"provider": "aws", "account": account})
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def contain(nid: str, evidence: str) -> None:
|
|
46
|
+
result.edges.append(
|
|
47
|
+
Edge(source=account_id, target=nid, relation=Relation.CONTAINS,
|
|
48
|
+
confidence=Confidence.EXTRACTED, evidence=[evidence])
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
rds = _aws_json(["rds", "describe-db-instances"])
|
|
52
|
+
for db in (rds or {}).get("DBInstances", []) if isinstance(rds, dict) else []:
|
|
53
|
+
ident = db.get("DBInstanceIdentifier", "unknown")
|
|
54
|
+
nid = f"database:{ident}"
|
|
55
|
+
result.nodes.append(
|
|
56
|
+
Node(id=nid, type=NodeType.DATABASE.value, name=ident,
|
|
57
|
+
origin=Origin.LIVE, source_adapter=self.name,
|
|
58
|
+
evidence=["aws rds describe-db-instances"],
|
|
59
|
+
attrs={"engine": db.get("Engine"), "state": db.get("DBInstanceStatus"),
|
|
60
|
+
"endpoint": (db.get("Endpoint") or {}).get("Address"),
|
|
61
|
+
"instance_class": db.get("DBInstanceClass"), "provider": "aws"})
|
|
62
|
+
)
|
|
63
|
+
contain(nid, "RDS instance in account")
|
|
64
|
+
|
|
65
|
+
lbs = _aws_json(["elbv2", "describe-load-balancers"])
|
|
66
|
+
for lb in (lbs or {}).get("LoadBalancers", []) if isinstance(lbs, dict) else []:
|
|
67
|
+
name = lb.get("LoadBalancerName", "unknown")
|
|
68
|
+
nid = f"loadbalancer:{name}"
|
|
69
|
+
result.nodes.append(
|
|
70
|
+
Node(id=nid, type=NodeType.LOAD_BALANCER.value, name=name,
|
|
71
|
+
origin=Origin.LIVE, source_adapter=self.name,
|
|
72
|
+
evidence=["aws elbv2 describe-load-balancers"],
|
|
73
|
+
attrs={"dns_name": lb.get("DNSName"), "state": (lb.get("State") or {}).get("Code"),
|
|
74
|
+
"public": lb.get("Scheme") == "internet-facing", "provider": "aws"})
|
|
75
|
+
)
|
|
76
|
+
contain(nid, "load balancer in account")
|
|
77
|
+
|
|
78
|
+
eks = _aws_json(["eks", "list-clusters"])
|
|
79
|
+
for name in (eks or {}).get("clusters", []) if isinstance(eks, dict) else []:
|
|
80
|
+
nid = f"cluster:{name}"
|
|
81
|
+
result.nodes.append(
|
|
82
|
+
Node(id=nid, type=NodeType.CLUSTER.value, name=name,
|
|
83
|
+
origin=Origin.LIVE, source_adapter=self.name,
|
|
84
|
+
evidence=["aws eks list-clusters"], attrs={"provider": "aws", "kind": "eks"})
|
|
85
|
+
)
|
|
86
|
+
contain(nid, "EKS cluster in account")
|
|
87
|
+
|
|
88
|
+
sqs = _aws_json(["sqs", "list-queues"])
|
|
89
|
+
for url in (sqs or {}).get("QueueUrls", []) if isinstance(sqs, dict) else []:
|
|
90
|
+
name = url.rstrip("/").rsplit("/", 1)[-1]
|
|
91
|
+
nid = f"queue:{name}"
|
|
92
|
+
result.nodes.append(
|
|
93
|
+
Node(id=nid, type=NodeType.QUEUE.value, name=name,
|
|
94
|
+
origin=Origin.LIVE, source_adapter=self.name,
|
|
95
|
+
evidence=["aws sqs list-queues"], attrs={"provider": "aws"})
|
|
96
|
+
)
|
|
97
|
+
contain(nid, "SQS queue in account")
|
|
98
|
+
|
|
99
|
+
ec2 = _aws_json(["ec2", "describe-instances", "--max-results", "100"])
|
|
100
|
+
for reservation in (ec2 or {}).get("Reservations", []) if isinstance(ec2, dict) else []:
|
|
101
|
+
for inst in reservation.get("Instances", []):
|
|
102
|
+
iid = inst.get("InstanceId", "unknown")
|
|
103
|
+
tags = {t["Key"]: t["Value"] for t in inst.get("Tags", []) or []}
|
|
104
|
+
name = tags.get("Name", iid)
|
|
105
|
+
nid = f"compute:{iid}"
|
|
106
|
+
result.nodes.append(
|
|
107
|
+
Node(id=nid, type=NodeType.COMPUTE_NODE.value, name=name,
|
|
108
|
+
origin=Origin.LIVE, source_adapter=self.name,
|
|
109
|
+
evidence=["aws ec2 describe-instances"],
|
|
110
|
+
attrs={"instance_id": iid, "state": (inst.get("State") or {}).get("Name"),
|
|
111
|
+
"instance_type": inst.get("InstanceType"), "provider": "aws"})
|
|
112
|
+
)
|
|
113
|
+
contain(nid, "EC2 instance in account")
|
|
114
|
+
|
|
115
|
+
s3 = _aws_json(["s3api", "list-buckets"])
|
|
116
|
+
for bucket in (s3 or {}).get("Buckets", []) if isinstance(s3, dict) else []:
|
|
117
|
+
name = bucket.get("Name", "unknown")
|
|
118
|
+
nid = f"bucket:{name}"
|
|
119
|
+
result.nodes.append(
|
|
120
|
+
Node(id=nid, type=NodeType.BUCKET.value, name=name,
|
|
121
|
+
origin=Origin.LIVE, source_adapter=self.name,
|
|
122
|
+
evidence=["aws s3api list-buckets"], attrs={"provider": "aws"})
|
|
123
|
+
)
|
|
124
|
+
contain(nid, "S3 bucket in account")
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
def collect_logs(self, scope: str, lines: int = 200) -> list[str]:
|
|
128
|
+
group = scope.split(":", 1)[-1]
|
|
129
|
+
res = run_cmd(
|
|
130
|
+
["aws", "logs", "tail", group, "--format", "short", "--since", "1h"], timeout=AWS_TIMEOUT
|
|
131
|
+
)
|
|
132
|
+
return res.stdout.splitlines()[-lines:] if res.ok else []
|
|
133
|
+
|
|
134
|
+
def current_state(self, scope: str) -> dict:
|
|
135
|
+
name = scope.split(":", 1)[-1]
|
|
136
|
+
if scope.startswith("database:"):
|
|
137
|
+
data = _aws_json(["rds", "describe-db-instances", "--db-instance-identifier", name])
|
|
138
|
+
instances = (data or {}).get("DBInstances", []) if isinstance(data, dict) else []
|
|
139
|
+
if instances:
|
|
140
|
+
return {"state": instances[0].get("DBInstanceStatus"), "engine": instances[0].get("Engine")}
|
|
141
|
+
return {}
|