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.
Files changed (69) hide show
  1. cirdan/__init__.py +3 -0
  2. cirdan/access/__init__.py +3 -0
  3. cirdan/access/context.py +221 -0
  4. cirdan/access/redaction.py +56 -0
  5. cirdan/actions/__init__.py +3 -0
  6. cirdan/actions/executor.py +165 -0
  7. cirdan/adapters/__init__.py +3 -0
  8. cirdan/adapters/aws.py +141 -0
  9. cirdan/adapters/base.py +127 -0
  10. cirdan/adapters/common.py +133 -0
  11. cirdan/adapters/docker.py +221 -0
  12. cirdan/adapters/docker_compose.py +124 -0
  13. cirdan/adapters/github_actions.py +76 -0
  14. cirdan/adapters/helm.py +84 -0
  15. cirdan/adapters/k8s_manifests.py +182 -0
  16. cirdan/adapters/kubernetes.py +374 -0
  17. cirdan/adapters/local_files.py +47 -0
  18. cirdan/adapters/nginx.py +69 -0
  19. cirdan/adapters/prometheus.py +70 -0
  20. cirdan/adapters/registry.py +69 -0
  21. cirdan/adapters/sql_schema.py +54 -0
  22. cirdan/adapters/systemd.py +87 -0
  23. cirdan/adapters/systemd_units.py +54 -0
  24. cirdan/adapters/terraform.py +161 -0
  25. cirdan/agents/__init__.py +3 -0
  26. cirdan/agents/installer.py +156 -0
  27. cirdan/api/__init__.py +0 -0
  28. cirdan/api/http.py +197 -0
  29. cirdan/audit.py +37 -0
  30. cirdan/cli/__init__.py +0 -0
  31. cirdan/cli/main.py +418 -0
  32. cirdan/config.py +102 -0
  33. cirdan/daemon/__init__.py +3 -0
  34. cirdan/daemon/server.py +132 -0
  35. cirdan/engine.py +323 -0
  36. cirdan/fingerprint/__init__.py +3 -0
  37. cirdan/fingerprint/engine.py +141 -0
  38. cirdan/graph/__init__.py +21 -0
  39. cirdan/graph/builder.py +98 -0
  40. cirdan/graph/diff.py +97 -0
  41. cirdan/graph/export.py +101 -0
  42. cirdan/graph/queries.py +144 -0
  43. cirdan/graph/schema.py +169 -0
  44. cirdan/graph/store.py +359 -0
  45. cirdan/incidents/__init__.py +4 -0
  46. cirdan/incidents/detector.py +131 -0
  47. cirdan/incidents/reports.py +82 -0
  48. cirdan/incidents/store.py +90 -0
  49. cirdan/mcp/__init__.py +0 -0
  50. cirdan/mcp/server.py +256 -0
  51. cirdan/query.py +178 -0
  52. cirdan/reports/__init__.py +3 -0
  53. cirdan/reports/infra_report.py +130 -0
  54. cirdan/telemetry/__init__.py +4 -0
  55. cirdan/telemetry/clusters.py +69 -0
  56. cirdan/telemetry/events.py +148 -0
  57. cirdan/ui/__init__.py +4 -0
  58. cirdan/ui/render.py +109 -0
  59. cirdan/ui/router.py +232 -0
  60. cirdan/ui/templates/view.html.j2 +218 -0
  61. cirdan/ui/view_spec.py +67 -0
  62. cirdan/util.py +80 -0
  63. cirdan/verify/__init__.py +3 -0
  64. cirdan/verify/checks.py +95 -0
  65. cirdanops-0.1.0.dist-info/METADATA +379 -0
  66. cirdanops-0.1.0.dist-info/RECORD +69 -0
  67. cirdanops-0.1.0.dist-info/WHEEL +4 -0
  68. cirdanops-0.1.0.dist-info/entry_points.txt +3 -0
  69. cirdanops-0.1.0.dist-info/licenses/LICENSE +201 -0
cirdan/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Cirdan: AI infrastructure cartographer and operations daemon."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from cirdan.access.context import AccessContext, detect_access, render_access_report
2
+
3
+ __all__ = ["AccessContext", "detect_access", "render_access_report"]
@@ -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,3 @@
1
+ from cirdan.actions.executor import ActionRecord, execute_action, find_action, list_actions
2
+
3
+ __all__ = ["ActionRecord", "execute_action", "find_action", "list_actions"]
@@ -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)
@@ -0,0 +1,3 @@
1
+ from cirdan.adapters.base import ActionResult, ActionSpec, Adapter, Signal
2
+
3
+ __all__ = ["ActionResult", "ActionSpec", "Adapter", "Signal"]
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 {}