osscodeiq 0.0.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 (183) hide show
  1. osscodeiq/__init__.py +0 -0
  2. osscodeiq/analyzer.py +467 -0
  3. osscodeiq/cache/__init__.py +0 -0
  4. osscodeiq/cache/hasher.py +23 -0
  5. osscodeiq/cache/store.py +300 -0
  6. osscodeiq/classifiers/__init__.py +0 -0
  7. osscodeiq/classifiers/layer_classifier.py +69 -0
  8. osscodeiq/cli.py +721 -0
  9. osscodeiq/config.py +113 -0
  10. osscodeiq/detectors/__init__.py +0 -0
  11. osscodeiq/detectors/auth/__init__.py +0 -0
  12. osscodeiq/detectors/auth/certificate_auth.py +139 -0
  13. osscodeiq/detectors/auth/ldap_auth.py +89 -0
  14. osscodeiq/detectors/auth/session_header_auth.py +120 -0
  15. osscodeiq/detectors/base.py +41 -0
  16. osscodeiq/detectors/config/__init__.py +0 -0
  17. osscodeiq/detectors/config/batch_structure.py +128 -0
  18. osscodeiq/detectors/config/cloudformation.py +183 -0
  19. osscodeiq/detectors/config/docker_compose.py +179 -0
  20. osscodeiq/detectors/config/github_actions.py +150 -0
  21. osscodeiq/detectors/config/gitlab_ci.py +216 -0
  22. osscodeiq/detectors/config/helm_chart.py +187 -0
  23. osscodeiq/detectors/config/ini_structure.py +101 -0
  24. osscodeiq/detectors/config/json_structure.py +72 -0
  25. osscodeiq/detectors/config/kubernetes.py +305 -0
  26. osscodeiq/detectors/config/kubernetes_rbac.py +212 -0
  27. osscodeiq/detectors/config/openapi.py +194 -0
  28. osscodeiq/detectors/config/package_json.py +99 -0
  29. osscodeiq/detectors/config/properties_detector.py +108 -0
  30. osscodeiq/detectors/config/pyproject_toml.py +169 -0
  31. osscodeiq/detectors/config/sql_structure.py +155 -0
  32. osscodeiq/detectors/config/toml_structure.py +93 -0
  33. osscodeiq/detectors/config/tsconfig_json.py +105 -0
  34. osscodeiq/detectors/config/yaml_structure.py +82 -0
  35. osscodeiq/detectors/cpp/__init__.py +0 -0
  36. osscodeiq/detectors/cpp/cpp_structures.py +192 -0
  37. osscodeiq/detectors/csharp/__init__.py +0 -0
  38. osscodeiq/detectors/csharp/csharp_efcore.py +184 -0
  39. osscodeiq/detectors/csharp/csharp_minimal_apis.py +156 -0
  40. osscodeiq/detectors/csharp/csharp_structures.py +317 -0
  41. osscodeiq/detectors/docs/__init__.py +0 -0
  42. osscodeiq/detectors/docs/markdown_structure.py +117 -0
  43. osscodeiq/detectors/frontend/__init__.py +0 -0
  44. osscodeiq/detectors/frontend/angular_components.py +177 -0
  45. osscodeiq/detectors/frontend/frontend_routes.py +259 -0
  46. osscodeiq/detectors/frontend/react_components.py +148 -0
  47. osscodeiq/detectors/frontend/svelte_components.py +84 -0
  48. osscodeiq/detectors/frontend/vue_components.py +150 -0
  49. osscodeiq/detectors/generic/__init__.py +1 -0
  50. osscodeiq/detectors/generic/imports_detector.py +413 -0
  51. osscodeiq/detectors/go/__init__.py +0 -0
  52. osscodeiq/detectors/go/go_orm.py +202 -0
  53. osscodeiq/detectors/go/go_structures.py +162 -0
  54. osscodeiq/detectors/go/go_web.py +157 -0
  55. osscodeiq/detectors/iac/__init__.py +0 -0
  56. osscodeiq/detectors/iac/bicep.py +135 -0
  57. osscodeiq/detectors/iac/dockerfile.py +182 -0
  58. osscodeiq/detectors/iac/terraform.py +188 -0
  59. osscodeiq/detectors/java/__init__.py +0 -0
  60. osscodeiq/detectors/java/azure_functions.py +424 -0
  61. osscodeiq/detectors/java/azure_messaging.py +350 -0
  62. osscodeiq/detectors/java/class_hierarchy.py +349 -0
  63. osscodeiq/detectors/java/config_def.py +82 -0
  64. osscodeiq/detectors/java/cosmos_db.py +105 -0
  65. osscodeiq/detectors/java/graphql_resolver.py +188 -0
  66. osscodeiq/detectors/java/grpc_service.py +142 -0
  67. osscodeiq/detectors/java/ibm_mq.py +178 -0
  68. osscodeiq/detectors/java/jaxrs.py +160 -0
  69. osscodeiq/detectors/java/jdbc.py +196 -0
  70. osscodeiq/detectors/java/jms.py +116 -0
  71. osscodeiq/detectors/java/jpa_entity.py +143 -0
  72. osscodeiq/detectors/java/kafka.py +113 -0
  73. osscodeiq/detectors/java/kafka_protocol.py +70 -0
  74. osscodeiq/detectors/java/micronaut.py +248 -0
  75. osscodeiq/detectors/java/module_deps.py +191 -0
  76. osscodeiq/detectors/java/public_api.py +206 -0
  77. osscodeiq/detectors/java/quarkus.py +176 -0
  78. osscodeiq/detectors/java/rabbitmq.py +150 -0
  79. osscodeiq/detectors/java/raw_sql.py +136 -0
  80. osscodeiq/detectors/java/repository.py +131 -0
  81. osscodeiq/detectors/java/rmi.py +129 -0
  82. osscodeiq/detectors/java/spring_events.py +117 -0
  83. osscodeiq/detectors/java/spring_rest.py +168 -0
  84. osscodeiq/detectors/java/spring_security.py +212 -0
  85. osscodeiq/detectors/java/tibco_ems.py +193 -0
  86. osscodeiq/detectors/java/websocket.py +188 -0
  87. osscodeiq/detectors/kotlin/__init__.py +0 -0
  88. osscodeiq/detectors/kotlin/kotlin_structures.py +124 -0
  89. osscodeiq/detectors/kotlin/ktor_routes.py +163 -0
  90. osscodeiq/detectors/proto/__init__.py +0 -0
  91. osscodeiq/detectors/proto/proto_structure.py +153 -0
  92. osscodeiq/detectors/python/__init__.py +0 -0
  93. osscodeiq/detectors/python/celery_tasks.py +88 -0
  94. osscodeiq/detectors/python/django_auth.py +132 -0
  95. osscodeiq/detectors/python/django_models.py +157 -0
  96. osscodeiq/detectors/python/django_views.py +74 -0
  97. osscodeiq/detectors/python/fastapi_auth.py +143 -0
  98. osscodeiq/detectors/python/fastapi_routes.py +68 -0
  99. osscodeiq/detectors/python/flask_routes.py +67 -0
  100. osscodeiq/detectors/python/kafka_python.py +175 -0
  101. osscodeiq/detectors/python/pydantic_models.py +115 -0
  102. osscodeiq/detectors/python/python_structures.py +234 -0
  103. osscodeiq/detectors/python/sqlalchemy_models.py +82 -0
  104. osscodeiq/detectors/registry.py +100 -0
  105. osscodeiq/detectors/rust/__init__.py +0 -0
  106. osscodeiq/detectors/rust/actix_web.py +234 -0
  107. osscodeiq/detectors/rust/rust_structures.py +174 -0
  108. osscodeiq/detectors/scala/__init__.py +0 -0
  109. osscodeiq/detectors/scala/scala_structures.py +128 -0
  110. osscodeiq/detectors/shell/__init__.py +0 -0
  111. osscodeiq/detectors/shell/bash_detector.py +127 -0
  112. osscodeiq/detectors/shell/powershell_detector.py +118 -0
  113. osscodeiq/detectors/typescript/__init__.py +0 -0
  114. osscodeiq/detectors/typescript/express_routes.py +55 -0
  115. osscodeiq/detectors/typescript/fastify_routes.py +156 -0
  116. osscodeiq/detectors/typescript/graphql_resolvers.py +100 -0
  117. osscodeiq/detectors/typescript/kafka_js.py +164 -0
  118. osscodeiq/detectors/typescript/mongoose_orm.py +151 -0
  119. osscodeiq/detectors/typescript/nestjs_controllers.py +99 -0
  120. osscodeiq/detectors/typescript/nestjs_guards.py +138 -0
  121. osscodeiq/detectors/typescript/passport_jwt.py +133 -0
  122. osscodeiq/detectors/typescript/prisma_orm.py +96 -0
  123. osscodeiq/detectors/typescript/remix_routes.py +160 -0
  124. osscodeiq/detectors/typescript/sequelize_orm.py +136 -0
  125. osscodeiq/detectors/typescript/typeorm_entities.py +86 -0
  126. osscodeiq/detectors/typescript/typescript_structures.py +185 -0
  127. osscodeiq/detectors/utils.py +49 -0
  128. osscodeiq/discovery/__init__.py +11 -0
  129. osscodeiq/discovery/change_detector.py +97 -0
  130. osscodeiq/discovery/file_discovery.py +342 -0
  131. osscodeiq/flow/__init__.py +0 -0
  132. osscodeiq/flow/engine.py +78 -0
  133. osscodeiq/flow/models.py +72 -0
  134. osscodeiq/flow/renderer.py +127 -0
  135. osscodeiq/flow/templates/interactive.html +252 -0
  136. osscodeiq/flow/vendor/cytoscape-dagre.min.js +8 -0
  137. osscodeiq/flow/vendor/cytoscape.min.js +32 -0
  138. osscodeiq/flow/vendor/dagre.min.js +3809 -0
  139. osscodeiq/flow/views.py +357 -0
  140. osscodeiq/graph/__init__.py +0 -0
  141. osscodeiq/graph/backend.py +52 -0
  142. osscodeiq/graph/backends/__init__.py +23 -0
  143. osscodeiq/graph/backends/kuzu.py +576 -0
  144. osscodeiq/graph/backends/networkx.py +135 -0
  145. osscodeiq/graph/backends/sqlite_backend.py +406 -0
  146. osscodeiq/graph/builder.py +297 -0
  147. osscodeiq/graph/query.py +228 -0
  148. osscodeiq/graph/store.py +183 -0
  149. osscodeiq/graph/views.py +231 -0
  150. osscodeiq/models/__init__.py +17 -0
  151. osscodeiq/models/graph.py +116 -0
  152. osscodeiq/output/__init__.py +0 -0
  153. osscodeiq/output/dot.py +171 -0
  154. osscodeiq/output/mermaid.py +160 -0
  155. osscodeiq/output/safety.py +58 -0
  156. osscodeiq/output/serializers.py +42 -0
  157. osscodeiq/parsing/__init__.py +5 -0
  158. osscodeiq/parsing/languages/__init__.py +0 -0
  159. osscodeiq/parsing/languages/base.py +23 -0
  160. osscodeiq/parsing/languages/java.py +68 -0
  161. osscodeiq/parsing/languages/python.py +57 -0
  162. osscodeiq/parsing/languages/typescript.py +95 -0
  163. osscodeiq/parsing/parser_manager.py +125 -0
  164. osscodeiq/parsing/structured/__init__.py +0 -0
  165. osscodeiq/parsing/structured/gradle_parser.py +78 -0
  166. osscodeiq/parsing/structured/json_parser.py +24 -0
  167. osscodeiq/parsing/structured/properties_parser.py +56 -0
  168. osscodeiq/parsing/structured/sql_parser.py +54 -0
  169. osscodeiq/parsing/structured/xml_parser.py +148 -0
  170. osscodeiq/parsing/structured/yaml_parser.py +38 -0
  171. osscodeiq/server/__init__.py +7 -0
  172. osscodeiq/server/app.py +53 -0
  173. osscodeiq/server/mcp_server.py +174 -0
  174. osscodeiq/server/middleware.py +16 -0
  175. osscodeiq/server/routes.py +184 -0
  176. osscodeiq/server/service.py +445 -0
  177. osscodeiq/server/templates/welcome.html +56 -0
  178. osscodeiq-0.0.0.dist-info/METADATA +30 -0
  179. osscodeiq-0.0.0.dist-info/RECORD +183 -0
  180. osscodeiq-0.0.0.dist-info/WHEEL +5 -0
  181. osscodeiq-0.0.0.dist-info/entry_points.txt +2 -0
  182. osscodeiq-0.0.0.dist-info/licenses/LICENSE +21 -0
  183. osscodeiq-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,305 @@
1
+ """Kubernetes manifest detector for container orchestration resource definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from osscodeiq.detectors.base import DetectorContext, DetectorResult
8
+ from osscodeiq.models.graph import (
9
+ EdgeKind,
10
+ GraphEdge,
11
+ GraphNode,
12
+ NodeKind,
13
+ SourceLocation,
14
+ )
15
+
16
+ _K8S_KINDS: frozenset[str] = frozenset({
17
+ "Deployment",
18
+ "Service",
19
+ "ConfigMap",
20
+ "Secret",
21
+ "Ingress",
22
+ "Pod",
23
+ "StatefulSet",
24
+ "DaemonSet",
25
+ "Job",
26
+ "CronJob",
27
+ "Namespace",
28
+ "PersistentVolumeClaim",
29
+ "ServiceAccount",
30
+ "Role",
31
+ "RoleBinding",
32
+ "ClusterRole",
33
+ "ClusterRoleBinding",
34
+ })
35
+
36
+
37
+ def _is_k8s_doc(doc: Any) -> bool:
38
+ """Check whether a parsed YAML document looks like a Kubernetes resource."""
39
+ return isinstance(doc, dict) and doc.get("kind") in _K8S_KINDS
40
+
41
+
42
+ def _get_documents(ctx: DetectorContext) -> list[dict[str, Any]]:
43
+ """Extract Kubernetes documents from parsed data."""
44
+ if not ctx.parsed_data:
45
+ return []
46
+
47
+ ptype = ctx.parsed_data.get("type")
48
+
49
+ if ptype == "yaml_multi":
50
+ docs = ctx.parsed_data.get("documents", [])
51
+ if isinstance(docs, list):
52
+ return [d for d in docs if _is_k8s_doc(d)]
53
+ return []
54
+
55
+ if ptype == "yaml":
56
+ data = ctx.parsed_data.get("data")
57
+ if _is_k8s_doc(data):
58
+ return [data]
59
+ return []
60
+
61
+ return []
62
+
63
+
64
+ def _safe_str(val: Any) -> str:
65
+ """Safely convert a value to string."""
66
+ if val is None:
67
+ return ""
68
+ return str(val)
69
+
70
+
71
+ class KubernetesDetector:
72
+ """Detects Kubernetes resources, container specs, and cross-resource relationships."""
73
+
74
+ name: str = "kubernetes"
75
+ supported_languages: tuple[str, ...] = ("yaml",)
76
+
77
+ def detect(self, ctx: DetectorContext) -> DetectorResult:
78
+ result = DetectorResult()
79
+
80
+ documents = _get_documents(ctx)
81
+ if not documents:
82
+ return result
83
+
84
+ fp = ctx.file_path
85
+
86
+ # Track deployments by their match labels for service selector resolution
87
+ deployment_labels: dict[str, str] = {} # "label_key=label_val" -> node_id
88
+ # Track services with selectors
89
+ service_selectors: list[tuple[str, dict[str, str]]] = [] # (node_id, selector)
90
+ # Track ingress backends
91
+ ingress_backends: list[tuple[str, str]] = [] # (ingress_node_id, service_name)
92
+
93
+ for doc in documents:
94
+ kind = doc.get("kind", "")
95
+ metadata = doc.get("metadata") or {}
96
+ if not isinstance(metadata, dict):
97
+ metadata = {}
98
+
99
+ name = _safe_str(metadata.get("name", "unknown"))
100
+ namespace = _safe_str(metadata.get("namespace", "default")) or "default"
101
+ labels = metadata.get("labels")
102
+ annotations = metadata.get("annotations")
103
+
104
+ node_id = f"k8s:{fp}:{kind}:{namespace}/{name}"
105
+
106
+ props: dict[str, Any] = {"kind": kind, "namespace": namespace}
107
+ if isinstance(labels, dict):
108
+ props["labels"] = labels
109
+ if isinstance(annotations, dict):
110
+ props["annotations"] = annotations
111
+
112
+ # INFRA_RESOURCE node for the resource
113
+ result.nodes.append(GraphNode(
114
+ id=node_id,
115
+ kind=NodeKind.INFRA_RESOURCE,
116
+ label=f"{kind}/{name}",
117
+ fqn=f"k8s:{kind}:{namespace}/{name}",
118
+ module=ctx.module_name,
119
+ location=SourceLocation(file_path=fp),
120
+ properties=props,
121
+ ))
122
+
123
+ spec = doc.get("spec") or {}
124
+ if not isinstance(spec, dict):
125
+ spec = {}
126
+
127
+ # Extract container specs from workload resources
128
+ if kind in ("Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob", "Pod"):
129
+ containers = self._extract_containers(spec, kind)
130
+ for container in containers:
131
+ c_name = _safe_str(container.get("name", "unnamed"))
132
+ c_props: dict[str, Any] = {}
133
+
134
+ image = container.get("image")
135
+ if image:
136
+ c_props["image"] = str(image)
137
+
138
+ # Ports
139
+ c_ports = container.get("ports")
140
+ if isinstance(c_ports, list):
141
+ port_strs = []
142
+ for p in c_ports:
143
+ if isinstance(p, dict):
144
+ port_strs.append(
145
+ f"{p.get('containerPort', '?')}/{p.get('protocol', 'TCP')}"
146
+ )
147
+ if port_strs:
148
+ c_props["ports"] = port_strs
149
+
150
+ # Environment variables
151
+ env_vars = container.get("env")
152
+ if isinstance(env_vars, list):
153
+ env_names = []
154
+ for e in env_vars:
155
+ if isinstance(e, dict) and "name" in e:
156
+ env_names.append(str(e["name"]))
157
+ if env_names:
158
+ c_props["env_vars"] = env_names
159
+
160
+ result.nodes.append(GraphNode(
161
+ id=f"{node_id}:container:{c_name}",
162
+ kind=NodeKind.CONFIG_KEY,
163
+ label=f"{name}/{c_name}",
164
+ module=ctx.module_name,
165
+ location=SourceLocation(file_path=fp),
166
+ properties=c_props,
167
+ ))
168
+
169
+ # Track deployment match labels for service selector linking
170
+ if kind in ("Deployment", "StatefulSet", "DaemonSet"):
171
+ template = spec.get("template") or {}
172
+ if isinstance(template, dict):
173
+ tmpl_meta = template.get("metadata") or {}
174
+ if isinstance(tmpl_meta, dict):
175
+ tmpl_labels = tmpl_meta.get("labels")
176
+ if isinstance(tmpl_labels, dict):
177
+ for lk, lv in tmpl_labels.items():
178
+ deployment_labels[f"{lk}={lv}"] = node_id
179
+
180
+ # Also use spec.selector.matchLabels
181
+ selector = spec.get("selector") or {}
182
+ if isinstance(selector, dict):
183
+ match_labels = selector.get("matchLabels")
184
+ if isinstance(match_labels, dict):
185
+ for lk, lv in match_labels.items():
186
+ deployment_labels[f"{lk}={lv}"] = node_id
187
+
188
+ # Track service selectors
189
+ if kind == "Service":
190
+ svc_selector = spec.get("selector")
191
+ if isinstance(svc_selector, dict):
192
+ service_selectors.append((node_id, svc_selector))
193
+
194
+ # Track ingress backends
195
+ if kind == "Ingress":
196
+ self._collect_ingress_backends(spec, node_id, ingress_backends)
197
+
198
+ # Resolve service selector -> deployment edges
199
+ for svc_node_id, selector in service_selectors:
200
+ for sel_key, sel_val in selector.items():
201
+ label_tag = f"{sel_key}={sel_val}"
202
+ if label_tag in deployment_labels:
203
+ result.edges.append(GraphEdge(
204
+ source=svc_node_id,
205
+ target=deployment_labels[label_tag],
206
+ kind=EdgeKind.DEPENDS_ON,
207
+ label=f"service selects {label_tag}",
208
+ properties={"selector": label_tag},
209
+ ))
210
+
211
+ # Resolve ingress -> service edges
212
+ # Build a lookup of service names to their node IDs
213
+ service_name_to_id: dict[str, str] = {}
214
+ for doc in documents:
215
+ if doc.get("kind") != "Service":
216
+ continue
217
+ meta = doc.get("metadata") or {}
218
+ if isinstance(meta, dict):
219
+ svc_name = _safe_str(meta.get("name", ""))
220
+ ns = _safe_str(meta.get("namespace", "default")) or "default"
221
+ svc_nid = f"k8s:{fp}:Service:{ns}/{svc_name}"
222
+ service_name_to_id[svc_name] = svc_nid
223
+
224
+ for ingress_nid, backend_svc in ingress_backends:
225
+ target_id = service_name_to_id.get(backend_svc)
226
+ if target_id:
227
+ result.edges.append(GraphEdge(
228
+ source=ingress_nid,
229
+ target=target_id,
230
+ kind=EdgeKind.CONNECTS_TO,
231
+ label=f"ingress routes to {backend_svc}",
232
+ ))
233
+
234
+ return result
235
+
236
+ @staticmethod
237
+ def _extract_containers(spec: dict[str, Any], kind: str) -> list[dict[str, Any]]:
238
+ """Extract container definitions from a workload spec, navigating nested templates."""
239
+ containers: list[dict[str, Any]] = []
240
+
241
+ if kind == "Pod":
242
+ cs = spec.get("containers")
243
+ if isinstance(cs, list):
244
+ containers.extend(c for c in cs if isinstance(c, dict))
245
+ return containers
246
+
247
+ if kind == "CronJob":
248
+ job_template = spec.get("jobTemplate") or {}
249
+ if isinstance(job_template, dict):
250
+ spec = job_template.get("spec") or {}
251
+ if not isinstance(spec, dict):
252
+ return containers
253
+
254
+ template = spec.get("template") or {}
255
+ if isinstance(template, dict):
256
+ pod_spec = template.get("spec") or {}
257
+ if isinstance(pod_spec, dict):
258
+ cs = pod_spec.get("containers")
259
+ if isinstance(cs, list):
260
+ containers.extend(c for c in cs if isinstance(c, dict))
261
+ init_cs = pod_spec.get("initContainers")
262
+ if isinstance(init_cs, list):
263
+ containers.extend(c for c in init_cs if isinstance(c, dict))
264
+
265
+ return containers
266
+
267
+ @staticmethod
268
+ def _collect_ingress_backends(
269
+ spec: dict[str, Any],
270
+ ingress_node_id: str,
271
+ out: list[tuple[str, str]],
272
+ ) -> None:
273
+ """Collect backend service references from an Ingress spec."""
274
+ # Default backend
275
+ default_backend = spec.get("defaultBackend") or spec.get("backend")
276
+ if isinstance(default_backend, dict):
277
+ svc = default_backend.get("service") or default_backend
278
+ if isinstance(svc, dict):
279
+ svc_name = svc.get("name") or svc.get("serviceName")
280
+ if svc_name:
281
+ out.append((ingress_node_id, str(svc_name)))
282
+
283
+ # Rules
284
+ rules = spec.get("rules")
285
+ if isinstance(rules, list):
286
+ for rule in rules:
287
+ if not isinstance(rule, dict):
288
+ continue
289
+ http = rule.get("http") or {}
290
+ if not isinstance(http, dict):
291
+ continue
292
+ paths = http.get("paths")
293
+ if not isinstance(paths, list):
294
+ continue
295
+ for path_entry in paths:
296
+ if not isinstance(path_entry, dict):
297
+ continue
298
+ backend = path_entry.get("backend")
299
+ if not isinstance(backend, dict):
300
+ continue
301
+ svc = backend.get("service") or backend
302
+ if isinstance(svc, dict):
303
+ svc_name = svc.get("name") or svc.get("serviceName")
304
+ if svc_name:
305
+ out.append((ingress_node_id, str(svc_name)))
@@ -0,0 +1,212 @@
1
+ """Kubernetes RBAC (Role-Based Access Control) detector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from osscodeiq.detectors.base import DetectorContext, DetectorResult
8
+ from osscodeiq.models.graph import (
9
+ EdgeKind,
10
+ GraphEdge,
11
+ GraphNode,
12
+ NodeKind,
13
+ SourceLocation,
14
+ )
15
+
16
+ _RBAC_KINDS: frozenset[str] = frozenset({
17
+ "Role",
18
+ "ClusterRole",
19
+ "RoleBinding",
20
+ "ClusterRoleBinding",
21
+ "ServiceAccount",
22
+ })
23
+
24
+
25
+ def _safe_str(val: Any) -> str:
26
+ """Safely convert a value to string."""
27
+ if val is None:
28
+ return ""
29
+ return str(val)
30
+
31
+
32
+ def _get_documents(ctx: DetectorContext) -> list[dict[str, Any]]:
33
+ """Extract RBAC-related Kubernetes documents from parsed data."""
34
+ if not ctx.parsed_data:
35
+ return []
36
+
37
+ ptype = ctx.parsed_data.get("type")
38
+
39
+ if ptype == "yaml_multi":
40
+ docs = ctx.parsed_data.get("documents", [])
41
+ if isinstance(docs, list):
42
+ return [
43
+ d for d in docs
44
+ if isinstance(d, dict) and d.get("kind") in _RBAC_KINDS
45
+ ]
46
+ return []
47
+
48
+ if ptype == "yaml":
49
+ data = ctx.parsed_data.get("data")
50
+ if isinstance(data, dict) and data.get("kind") in _RBAC_KINDS:
51
+ return [data]
52
+ return []
53
+
54
+ return []
55
+
56
+
57
+ def _make_node_id(file_path: str, kind: str, namespace: str, name: str) -> str:
58
+ """Build deterministic node ID for a K8s RBAC resource."""
59
+ return f"k8s_rbac:{file_path}:{kind}:{namespace}/{name}"
60
+
61
+
62
+ class KubernetesRBACDetector:
63
+ """Detects Kubernetes RBAC resources and produces GUARD nodes and PROTECTS edges."""
64
+
65
+ name: str = "config.kubernetes_rbac"
66
+ supported_languages: tuple[str, ...] = ("yaml",)
67
+
68
+ def detect(self, ctx: DetectorContext) -> DetectorResult:
69
+ result = DetectorResult()
70
+
71
+ documents = _get_documents(ctx)
72
+ if not documents:
73
+ return result
74
+
75
+ fp = ctx.file_path
76
+
77
+ # Collect nodes first so we can resolve bindings
78
+ role_nodes: dict[str, str] = {} # "kind:namespace/name" -> node_id
79
+ sa_nodes: dict[str, str] = {} # "namespace/name" -> node_id
80
+ bindings: list[dict[str, Any]] = []
81
+
82
+ for doc in documents:
83
+ kind = doc.get("kind", "")
84
+ metadata = doc.get("metadata") or {}
85
+ if not isinstance(metadata, dict):
86
+ metadata = {}
87
+
88
+ name = _safe_str(metadata.get("name", "unknown"))
89
+ namespace = _safe_str(metadata.get("namespace", "default")) or "default"
90
+
91
+ node_id = _make_node_id(fp, kind, namespace, name)
92
+
93
+ if kind in ("Role", "ClusterRole"):
94
+ rules = doc.get("rules")
95
+ if not isinstance(rules, list):
96
+ rules = []
97
+ serialized_rules = []
98
+ for rule in rules:
99
+ if isinstance(rule, dict):
100
+ serialized_rules.append({
101
+ "apiGroups": rule.get("apiGroups", []),
102
+ "resources": rule.get("resources", []),
103
+ "verbs": rule.get("verbs", []),
104
+ })
105
+
106
+ result.nodes.append(GraphNode(
107
+ id=node_id,
108
+ kind=NodeKind.GUARD,
109
+ label=f"{kind}/{name}",
110
+ fqn=f"k8s:{kind}:{namespace}/{name}",
111
+ module=ctx.module_name,
112
+ location=SourceLocation(file_path=fp),
113
+ properties={
114
+ "auth_type": "k8s_rbac",
115
+ "k8s_kind": kind,
116
+ "namespace": namespace,
117
+ "rules": serialized_rules,
118
+ },
119
+ ))
120
+ role_key = f"{kind}:{namespace}/{name}"
121
+ # ClusterRoles are cluster-scoped; store with "cluster-wide" marker
122
+ if kind == "ClusterRole":
123
+ role_key = f"ClusterRole:cluster-wide/{name}"
124
+ role_nodes[role_key] = node_id
125
+
126
+ elif kind == "ServiceAccount":
127
+ result.nodes.append(GraphNode(
128
+ id=node_id,
129
+ kind=NodeKind.GUARD,
130
+ label=f"ServiceAccount/{name}",
131
+ fqn=f"k8s:ServiceAccount:{namespace}/{name}",
132
+ module=ctx.module_name,
133
+ location=SourceLocation(file_path=fp),
134
+ properties={
135
+ "auth_type": "k8s_rbac",
136
+ "k8s_kind": "ServiceAccount",
137
+ "namespace": namespace,
138
+ "rules": [],
139
+ },
140
+ ))
141
+ sa_nodes[f"{namespace}/{name}"] = node_id
142
+
143
+ elif kind in ("RoleBinding", "ClusterRoleBinding"):
144
+ result.nodes.append(GraphNode(
145
+ id=node_id,
146
+ kind=NodeKind.GUARD,
147
+ label=f"{kind}/{name}",
148
+ fqn=f"k8s:{kind}:{namespace}/{name}",
149
+ module=ctx.module_name,
150
+ location=SourceLocation(file_path=fp),
151
+ properties={
152
+ "auth_type": "k8s_rbac",
153
+ "k8s_kind": kind,
154
+ "namespace": namespace,
155
+ "rules": [],
156
+ },
157
+ ))
158
+ bindings.append(doc)
159
+
160
+ # Resolve RoleBinding/ClusterRoleBinding -> PROTECTS edges
161
+ for doc in bindings:
162
+ kind = doc.get("kind", "")
163
+ metadata = doc.get("metadata") or {}
164
+ if not isinstance(metadata, dict):
165
+ metadata = {}
166
+ binding_namespace = _safe_str(metadata.get("namespace", "default")) or "default"
167
+
168
+ role_ref = doc.get("roleRef")
169
+ if not isinstance(role_ref, dict):
170
+ continue
171
+
172
+ ref_kind = _safe_str(role_ref.get("kind", ""))
173
+ ref_name = _safe_str(role_ref.get("name", ""))
174
+
175
+ # Resolve the role node
176
+ if ref_kind == "ClusterRole":
177
+ role_key = f"ClusterRole:cluster-wide/{ref_name}"
178
+ else:
179
+ role_key = f"{ref_kind}:{binding_namespace}/{ref_name}"
180
+
181
+ role_nid = role_nodes.get(role_key)
182
+ if not role_nid:
183
+ continue
184
+
185
+ subjects = doc.get("subjects")
186
+ if not isinstance(subjects, list):
187
+ continue
188
+
189
+ for subject in subjects:
190
+ if not isinstance(subject, dict):
191
+ continue
192
+ subj_kind = _safe_str(subject.get("kind", ""))
193
+ subj_name = _safe_str(subject.get("name", ""))
194
+ subj_namespace = _safe_str(
195
+ subject.get("namespace", binding_namespace)
196
+ ) or binding_namespace
197
+
198
+ if subj_kind == "ServiceAccount":
199
+ sa_key = f"{subj_namespace}/{subj_name}"
200
+ sa_nid = sa_nodes.get(sa_key)
201
+ if sa_nid:
202
+ result.edges.append(GraphEdge(
203
+ source=role_nid,
204
+ target=sa_nid,
205
+ kind=EdgeKind.PROTECTS,
206
+ label=f"{ref_kind}/{ref_name} -> ServiceAccount/{subj_name}",
207
+ properties={
208
+ "binding_kind": kind,
209
+ },
210
+ ))
211
+
212
+ return result