aegis-devops 0.3.0__tar.gz → 0.3.2__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.
- {aegis_devops-0.3.0/src/aegis_devops.egg-info → aegis_devops-0.3.2}/PKG-INFO +8 -4
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/README.md +6 -3
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/pyproject.toml +1 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/__init__.py +1 -1
- aegis_devops-0.3.2/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- aegis_devops-0.3.2/src/aegis_core/audit_kubernetes.py +312 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/cli.py +142 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/compile/actions/aws.yaml +0 -14
- aegis_devops-0.3.2/src/aegis_core/compile/kubernetes.py +702 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/parser.py +48 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2/src/aegis_devops.egg-info}/PKG-INFO +8 -4
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_devops.egg-info/SOURCES.txt +4 -0
- aegis_devops-0.3.2/tests/test_audit_kubernetes.py +265 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_compile_aws.py +43 -4
- aegis_devops-0.3.2/tests/test_compile_kubernetes.py +332 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_parser.py +54 -0
- aegis_devops-0.3.0/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/LICENSE +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/MANIFEST.in +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/setup.cfg +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/__init__.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/agents.example.yaml +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/agents.example.yaml.sig +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/authority.example.yaml +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/authority.example.yaml.sig +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/constraints.example.yaml +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/environments.example.yaml +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/example-signing.key +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/repos.example.yaml +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/repos.example.yaml.sig +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/signers.example.yaml +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/signers.example.yaml.sig +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-4001.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-6001.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-6002.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-6003.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-6004.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-6005.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-6006.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-6007.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-6008.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-6009.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/git-def456.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/adversarial.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/audit_aws.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/authority.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/baselines/__init__.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/baselines/base.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/baselines/external.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/baselines/llm.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/baselines/metrics.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/baselines/opa.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/compile/__init__.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/compile/aws.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/config.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/environments.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/gitsource.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/hook.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/identity.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/integrations/opencode-plugin.js +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/intent.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/interceptor.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/ledger.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/parsers/__init__.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/parsers/pulumi.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/parsers/sql.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/plan.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/provenance.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/shell.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/signing.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_core/store.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_devops.egg-info/entry_points.txt +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_devops.egg-info/requires.txt +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/src/aegis_devops.egg-info/top_level.txt +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_adversarial.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_audit_aws.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_authority.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_baselines.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_benchmark.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_cli.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_config.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_corpus.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_default_rules.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_docs_consistency.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_environments.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_gitsource.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_hook.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_identity.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_intent.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_interceptor.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_ledger.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_opencode_plugin.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_parsers_pulumi.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_parsers_sql.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_plan.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_plugins.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_provenance.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_shell.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_signing.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_snapshot.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_store.py +0 -0
- {aegis_devops-0.3.0 → aegis_devops-0.3.2}/tests/test_store_match.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aegis-devops
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Provenance-backed, authority-aware policy verifier for agentic DevOps actions.
|
|
5
|
+
Author: Vamshidher Reddy Jannapu Reddy
|
|
5
6
|
License-Expression: Apache-2.0
|
|
6
7
|
Project-URL: Homepage, https://github.com/moneytool/aegis-devops
|
|
7
8
|
Project-URL: Repository, https://github.com/moneytool/aegis-devops
|
|
@@ -33,7 +34,7 @@ Dynamic: license-file
|
|
|
33
34
|
# Aegis-DevOps
|
|
34
35
|
|
|
35
36
|
[](https://doi.org/10.5281/zenodo.22950337)
|
|
36
|
-
[](https://pypi.org/project/aegis-devops/)
|
|
37
38
|
|
|
38
39
|
**Stop AI agents from running `kubectl delete`, `terraform destroy` or `DROP TABLE` because a
|
|
39
40
|
ticket told them to.**
|
|
@@ -65,7 +66,10 @@ Codex, GitHub Copilot (CLI and VS Code), Cursor and OpenCode:
|
|
|
65
66
|
(see [Coding agents](docs/agents.md)). It only acts in projects with a `.aegis/` policy, and
|
|
66
67
|
only blocks what that policy blocks. It also works as a CI step
|
|
67
68
|
(`aegis check terraform plan.json --exit-style ci`) and as a Python library; see
|
|
68
|
-
[Quick start](#quick-start).
|
|
69
|
+
[Quick start](#quick-start). In GitHub Actions, the
|
|
70
|
+
[Aegis-DevOps Plan Check](https://github.com/marketplace/actions/aegis-devops-plan-check) Action
|
|
71
|
+
checks a Terraform or OpenTofu plan and comments the verdict on the pull request:
|
|
72
|
+
`uses: moneytool/aegis-devops-action@v1` with `plan: plan.json`.
|
|
69
73
|
|
|
70
74
|
## What it does
|
|
71
75
|
|
|
@@ -252,7 +256,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
252
256
|
|
|
253
257
|
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
254
258
|
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
255
|
-
v0.3.
|
|
259
|
+
v0.3.2 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
256
260
|
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
257
261
|
through the Codex CLI, and a local `mistral:latest`.
|
|
258
262
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Aegis-DevOps
|
|
2
2
|
|
|
3
3
|
[](https://doi.org/10.5281/zenodo.22950337)
|
|
4
|
-
[](https://pypi.org/project/aegis-devops/)
|
|
5
5
|
|
|
6
6
|
**Stop AI agents from running `kubectl delete`, `terraform destroy` or `DROP TABLE` because a
|
|
7
7
|
ticket told them to.**
|
|
@@ -33,7 +33,10 @@ Codex, GitHub Copilot (CLI and VS Code), Cursor and OpenCode:
|
|
|
33
33
|
(see [Coding agents](docs/agents.md)). It only acts in projects with a `.aegis/` policy, and
|
|
34
34
|
only blocks what that policy blocks. It also works as a CI step
|
|
35
35
|
(`aegis check terraform plan.json --exit-style ci`) and as a Python library; see
|
|
36
|
-
[Quick start](#quick-start).
|
|
36
|
+
[Quick start](#quick-start). In GitHub Actions, the
|
|
37
|
+
[Aegis-DevOps Plan Check](https://github.com/marketplace/actions/aegis-devops-plan-check) Action
|
|
38
|
+
checks a Terraform or OpenTofu plan and comments the verdict on the pull request:
|
|
39
|
+
`uses: moneytool/aegis-devops-action@v1` with `plan: plan.json`.
|
|
37
40
|
|
|
38
41
|
## What it does
|
|
39
42
|
|
|
@@ -220,7 +223,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
220
223
|
|
|
221
224
|
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
222
225
|
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
223
|
-
v0.3.
|
|
226
|
+
v0.3.2 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
224
227
|
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
225
228
|
through the Codex CLI, and a local `mistral:latest`.
|
|
226
229
|
|
|
@@ -7,6 +7,7 @@ name = "aegis-devops"
|
|
|
7
7
|
license = "Apache-2.0"
|
|
8
8
|
dynamic = ["version"]
|
|
9
9
|
description = "Provenance-backed, authority-aware policy verifier for agentic DevOps actions."
|
|
10
|
+
authors = [{ name = "Vamshidher Reddy Jannapu Reddy" }]
|
|
10
11
|
readme = "README.md"
|
|
11
12
|
requires-python = ">=3.11"
|
|
12
13
|
dependencies = [
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""``aegis audit-identity kubernetes``: what the identity model means for the
|
|
2
|
+
identities that exist in a cluster (design v0.3, §4.2, §4.3, §6.6).
|
|
3
|
+
|
|
4
|
+
Read-only. The inventory -- ServiceAccounts, RBAC bindings, which
|
|
5
|
+
ServiceAccounts pods run as, and SubjectAccessReviews for the escape
|
|
6
|
+
permissions -- comes from :func:`collect_inventory` (kubectl with the
|
|
7
|
+
operator's credentials) or a saved copy; :func:`audit` is pure.
|
|
8
|
+
|
|
9
|
+
* ``would_restrict``: every identity the compiled policies would restrict --
|
|
10
|
+
ServiceAccounts, and users/groups named in RBAC bindings (the API has no
|
|
11
|
+
user objects) -- with the control plane exempt as in the compiler.
|
|
12
|
+
* ``missing``: listed ServiceAccounts that do not exist, and a break-glass
|
|
13
|
+
group or user no binding grants anything (break-glass without access).
|
|
14
|
+
* ``findings``: escape permissions an identity the policies restrict still
|
|
15
|
+
holds. Admission cannot see impersonation or protect admission objects, so
|
|
16
|
+
RBAC is the only control there.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import subprocess
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from aegis_core.identity import IdentityModel
|
|
28
|
+
|
|
29
|
+
# (id, why). Each check is asked as several SubjectAccessReviews (see
|
|
30
|
+
# _probes): by name where RBAC can grant by name (resourceNames), per
|
|
31
|
+
# namespace where a RoleBinding can grant it, and for every write verb that
|
|
32
|
+
# disables a control (review of #22).
|
|
33
|
+
ESCAPE_CHECKS: tuple[tuple[str, str], ...] = (
|
|
34
|
+
("impersonate-users", "can act as another user (kubectl --as), a trusted or break-glass "
|
|
35
|
+
"one included; admission then sees that user"),
|
|
36
|
+
("impersonate-groups", "can claim another group, break-glass included"),
|
|
37
|
+
("impersonate-serviceaccounts", "can act as another ServiceAccount"),
|
|
38
|
+
("impersonate-uids", "can claim another identity's UID"),
|
|
39
|
+
("write-admission-policies", "can update, patch or delete ValidatingAdmissionPolicies or "
|
|
40
|
+
"their bindings (make a rule always pass, or Deny into Warn), "
|
|
41
|
+
"which admission cannot protect"),
|
|
42
|
+
("write-webhooks", "can update, patch or delete admission webhook configurations"),
|
|
43
|
+
("create-mutating-admission", "can create a mutating webhook or mutating admission policy "
|
|
44
|
+
"that rewrites requests before validation"),
|
|
45
|
+
("escalate-roles", "can grant itself permissions it does not hold (escalate)"),
|
|
46
|
+
("bind-roles", "can bind any cluster role, cluster-admin included"),
|
|
47
|
+
("write-clusterrolebindings", "can create or change cluster role bindings"),
|
|
48
|
+
)
|
|
49
|
+
_ADM = "admissionregistration.k8s.io"
|
|
50
|
+
_RBAC = "rbac.authorization.k8s.io"
|
|
51
|
+
_SA_PREFIX = "system:serviceaccount:"
|
|
52
|
+
# every identity (or every ServiceAccount) carries these; agents.yaml refuses
|
|
53
|
+
# to trust them, so they are not identities to review
|
|
54
|
+
_UNIVERSAL_GROUPS = ("system:authenticated", "system:unauthenticated", "system:serviceaccounts")
|
|
55
|
+
_HINTS = {
|
|
56
|
+
("group", "system:masters"): "the super-admin group; usually break-glass",
|
|
57
|
+
("user", "kube-apiserver-kubelet-client"): "the API server's client to kubelets; trust it",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InventoryError(ValueError):
|
|
62
|
+
"""The cluster inventory could not be collected or read."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_control_plane(username: str, groups: list[str]) -> bool:
|
|
66
|
+
"""The compiler's always-exempt set in deny-by-default."""
|
|
67
|
+
if username.startswith("system:") and not username.startswith(_SA_PREFIX):
|
|
68
|
+
return True
|
|
69
|
+
return "system:nodes" in groups or "system:serviceaccounts:kube-system" in groups
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sa_groups(namespace: str) -> list[str]:
|
|
73
|
+
return ["system:serviceaccounts", f"system:serviceaccounts:{namespace}",
|
|
74
|
+
"system:authenticated"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --- collection ------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run(context: str | None, *args: str) -> subprocess.CompletedProcess:
|
|
81
|
+
argv = ["kubectl", *(["--context", context] if context else []), *args]
|
|
82
|
+
try:
|
|
83
|
+
return subprocess.run(argv, capture_output=True, text=True, check=False)
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
raise InventoryError("kubectl is not on PATH (or pass --inventory FILE)") from None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _kubectl(context: str | None, *args: str) -> str:
|
|
89
|
+
proc = _run(context, *args)
|
|
90
|
+
if proc.returncode != 0:
|
|
91
|
+
raise InventoryError(f"kubectl {' '.join(args)} failed: {proc.stderr.strip()[:300]}")
|
|
92
|
+
return proc.stdout
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def can_i(context: str | None, args: list[str]) -> bool:
|
|
96
|
+
"""One SubjectAccessReview. ``kubectl auth can-i`` exits non-zero for
|
|
97
|
+
"no" too, so only an explicit yes/no answer counts: a failed review (no
|
|
98
|
+
right to impersonate for it, no SubjectAccessReview access, an API error)
|
|
99
|
+
is an error, never a quiet "no" (review of #22)."""
|
|
100
|
+
proc = _run(context, "auth", "can-i", *args)
|
|
101
|
+
lines = proc.stdout.strip().splitlines()
|
|
102
|
+
answer = lines[-1].strip() if lines else ""
|
|
103
|
+
if answer == "yes":
|
|
104
|
+
return True
|
|
105
|
+
if answer == "no":
|
|
106
|
+
return False
|
|
107
|
+
raise InventoryError(f"SubjectAccessReview failed (kubectl auth can-i {' '.join(args)}): "
|
|
108
|
+
f"{(proc.stderr or proc.stdout).strip()[:300] or 'no answer'}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _probes(model: IdentityModel, namespaces: list[str]) -> list[tuple[str, list[str], str]]:
|
|
112
|
+
"""``(check, can-i arguments, target description)`` for every escape
|
|
113
|
+
check."""
|
|
114
|
+
exempt = model.exempt("kubernetes")
|
|
115
|
+
users = sorted({i.id for i in exempt if i.kind == "user"})
|
|
116
|
+
groups = sorted({i.id for i in exempt if i.kind == "group"} | {"system:masters"})
|
|
117
|
+
sas = sorted({i.id for i in exempt if i.kind == "serviceaccount"})
|
|
118
|
+
out: list[tuple[str, list[str], str]] = [
|
|
119
|
+
("impersonate-users", ["impersonate", "users"], "any user"),
|
|
120
|
+
("impersonate-groups", ["impersonate", "groups"], "any group"),
|
|
121
|
+
("impersonate-uids", ["impersonate", "uids"], "any uid"),
|
|
122
|
+
]
|
|
123
|
+
out += [("impersonate-users", ["impersonate", f"users/{u}"], f"user {u}") for u in users]
|
|
124
|
+
out += [("impersonate-groups", ["impersonate", f"groups/{g}"], f"group {g}") for g in groups]
|
|
125
|
+
for ns in sorted(set(namespaces)):
|
|
126
|
+
out.append(("impersonate-serviceaccounts", ["impersonate", "serviceaccounts", "-n", ns],
|
|
127
|
+
f"any ServiceAccount in {ns}"))
|
|
128
|
+
for sa in sas:
|
|
129
|
+
ns, name = sa.split(":", 1)
|
|
130
|
+
out.append(("impersonate-serviceaccounts",
|
|
131
|
+
["impersonate", f"serviceaccounts/{name}", "-n", ns],
|
|
132
|
+
f"ServiceAccount {sa}"))
|
|
133
|
+
for res in ("validatingadmissionpolicies", "validatingadmissionpolicybindings"):
|
|
134
|
+
for verb in ("update", "patch", "delete"):
|
|
135
|
+
out.append(("write-admission-policies", [verb, f"{res}.{_ADM}"], f"{verb} {res}"))
|
|
136
|
+
for res in ("validatingwebhookconfigurations", "mutatingwebhookconfigurations"):
|
|
137
|
+
for verb in ("update", "patch", "delete"):
|
|
138
|
+
out.append(("write-webhooks", [verb, f"{res}.{_ADM}"], f"{verb} {res}"))
|
|
139
|
+
for res in ("mutatingwebhookconfigurations", "mutatingadmissionpolicies",
|
|
140
|
+
"mutatingadmissionpolicybindings"):
|
|
141
|
+
out.append(("create-mutating-admission", ["create", f"{res}.{_ADM}"], f"create {res}"))
|
|
142
|
+
out.append(("escalate-roles", ["escalate", f"clusterroles.{_RBAC}"], "escalate clusterroles"))
|
|
143
|
+
out.append(("bind-roles", ["bind", f"clusterroles.{_RBAC}"], "bind clusterroles"))
|
|
144
|
+
for verb in ("create", "update", "patch"):
|
|
145
|
+
out.append(("write-clusterrolebindings", [verb, f"clusterrolebindings.{_RBAC}"],
|
|
146
|
+
f"{verb} clusterrolebindings"))
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _identities(sas: list[dict], bindings: list[dict]) -> list[dict[str, Any]]:
|
|
151
|
+
out: dict[tuple[str, str], dict[str, Any]] = {}
|
|
152
|
+
for sa in sas:
|
|
153
|
+
ns, name = sa["namespace"], sa["name"]
|
|
154
|
+
user = f"{_SA_PREFIX}{ns}:{name}"
|
|
155
|
+
out[("serviceaccount", user)] = {"kind": "serviceaccount", "id": f"{ns}:{name}",
|
|
156
|
+
"username": user, "groups": _sa_groups(ns)}
|
|
157
|
+
for b in bindings:
|
|
158
|
+
for s in b.get("subjects") or []:
|
|
159
|
+
if s.get("kind") == "User":
|
|
160
|
+
out.setdefault(("user", s["name"]), {
|
|
161
|
+
"kind": "user", "id": s["name"], "username": s["name"],
|
|
162
|
+
"groups": ["system:authenticated"]})
|
|
163
|
+
elif s.get("kind") == "Group" and not (
|
|
164
|
+
s["name"] in _UNIVERSAL_GROUPS
|
|
165
|
+
or s["name"].startswith("system:serviceaccounts:")):
|
|
166
|
+
out.setdefault(("group", s["name"]), {
|
|
167
|
+
"kind": "group", "id": s["name"], "username": "aegis-audit:probe",
|
|
168
|
+
"groups": [s["name"], "system:authenticated"]})
|
|
169
|
+
return sorted(out.values(), key=lambda i: (i["kind"], i["id"]))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def collect_inventory(context: str | None, model: IdentityModel) -> dict[str, Any]:
|
|
173
|
+
"""ServiceAccounts, bindings, pod ServiceAccounts and, for every identity
|
|
174
|
+
the policies would restrict, the escape SubjectAccessReviews."""
|
|
175
|
+
sa_doc = json.loads(_kubectl(context, "get", "serviceaccounts", "-A", "-o", "json"))
|
|
176
|
+
sas = [{"namespace": i["metadata"]["namespace"], "name": i["metadata"]["name"]}
|
|
177
|
+
for i in sa_doc.get("items", [])]
|
|
178
|
+
bindings = []
|
|
179
|
+
for kind in ("clusterrolebindings", "rolebindings"):
|
|
180
|
+
doc = json.loads(_kubectl(context, "get", kind, "-A", "-o", "json"))
|
|
181
|
+
for i in doc.get("items", []):
|
|
182
|
+
bindings.append({"kind": kind, "namespace": i["metadata"].get("namespace", ""),
|
|
183
|
+
"name": i["metadata"]["name"], "role": i["roleRef"]["name"],
|
|
184
|
+
"subjects": i.get("subjects") or []})
|
|
185
|
+
pods = json.loads(_kubectl(context, "get", "pods", "-A", "-o", "json"))
|
|
186
|
+
pod_sas: dict[str, int] = {}
|
|
187
|
+
for p in pods.get("items", []):
|
|
188
|
+
key = f"{p['metadata']['namespace']}:{p['spec'].get('serviceAccountName', 'default')}"
|
|
189
|
+
pod_sas[key] = pod_sas.get(key, 0) + 1
|
|
190
|
+
|
|
191
|
+
inventory = {"serviceaccounts": sas, "bindings": bindings, "pod_serviceaccounts": pod_sas,
|
|
192
|
+
"access": {}}
|
|
193
|
+
restricted = [i for i in _identities(sas, bindings) if _restricted(model, i)]
|
|
194
|
+
probes = _probes(model, [s["namespace"] for s in sas])
|
|
195
|
+
|
|
196
|
+
def ask(job):
|
|
197
|
+
ident, (check, args, target) = job
|
|
198
|
+
who = ["--as", ident["username"]]
|
|
199
|
+
for g in ident["groups"]:
|
|
200
|
+
who += ["--as-group", g]
|
|
201
|
+
return f"{ident['kind']}:{ident['id']}", check, target, can_i(context, [*args, *who])
|
|
202
|
+
|
|
203
|
+
jobs = [(i, p) for i in restricted for p in probes]
|
|
204
|
+
for ident in restricted:
|
|
205
|
+
inventory["access"][f"{ident['kind']}:{ident['id']}"] = {
|
|
206
|
+
c[0]: [] for c in ESCAPE_CHECKS}
|
|
207
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
208
|
+
for key, check, target, allowed in pool.map(ask, jobs):
|
|
209
|
+
if allowed:
|
|
210
|
+
inventory["access"][key][check].append(target)
|
|
211
|
+
return inventory
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --- analysis ---------------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _restricted(model: IdentityModel, ident: dict[str, Any]) -> bool:
|
|
218
|
+
if model.mode == "deny-by-default" and _is_control_plane(ident["username"],
|
|
219
|
+
ident["groups"]):
|
|
220
|
+
return False
|
|
221
|
+
presented = [("user", ident["username"]), *(("group", g) for g in ident["groups"])]
|
|
222
|
+
if ident["kind"] == "group":
|
|
223
|
+
presented = [("group", ident["id"])]
|
|
224
|
+
return model.is_agent("kubernetes", presented)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass
|
|
228
|
+
class KubernetesAuditResult:
|
|
229
|
+
context: str
|
|
230
|
+
mode: str
|
|
231
|
+
enforcement: str
|
|
232
|
+
would_restrict: list[dict[str, Any]] = field(default_factory=list)
|
|
233
|
+
exempt: list[dict[str, Any]] = field(default_factory=list)
|
|
234
|
+
control_plane: int = 0
|
|
235
|
+
missing: list[dict[str, str]] = field(default_factory=list)
|
|
236
|
+
findings: list[dict[str, Any]] = field(default_factory=list)
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def problems(self) -> bool:
|
|
240
|
+
return bool(self.missing) or any(f["severity"] == "high" for f in self.findings)
|
|
241
|
+
|
|
242
|
+
def to_dict(self) -> dict[str, Any]:
|
|
243
|
+
return {"context": self.context, "mode": self.mode, "enforcement": self.enforcement,
|
|
244
|
+
"would_restrict": self.would_restrict, "exempt": self.exempt,
|
|
245
|
+
"control_plane": self.control_plane, "missing": self.missing,
|
|
246
|
+
"findings": self.findings}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def audit(inventory: dict[str, Any], model: IdentityModel, context: str = "") -> \
|
|
250
|
+
KubernetesAuditResult:
|
|
251
|
+
result = KubernetesAuditResult(context, model.mode, model.enforcement)
|
|
252
|
+
sas, bindings = inventory.get("serviceaccounts", []), inventory.get("bindings", [])
|
|
253
|
+
pod_sas = inventory.get("pod_serviceaccounts", {})
|
|
254
|
+
access = inventory.get("access", {})
|
|
255
|
+
bound: dict[tuple[str, str], list[str]] = {}
|
|
256
|
+
for b in bindings:
|
|
257
|
+
for s in b.get("subjects") or []:
|
|
258
|
+
if s.get("kind") == "ServiceAccount":
|
|
259
|
+
key = ("serviceaccount", f"{s.get('namespace', b.get('namespace', ''))}:"
|
|
260
|
+
f"{s['name']}")
|
|
261
|
+
else:
|
|
262
|
+
key = (str(s.get("kind", "")).lower(), s["name"])
|
|
263
|
+
bound.setdefault(key, []).append(f"{b['kind'][:-1]}/{b['name']} -> {b['role']}")
|
|
264
|
+
|
|
265
|
+
for ident in _identities(sas, bindings):
|
|
266
|
+
if model.mode == "deny-by-default" and _is_control_plane(ident["username"],
|
|
267
|
+
ident["groups"]):
|
|
268
|
+
result.control_plane += 1
|
|
269
|
+
continue
|
|
270
|
+
row = {"kind": ident["kind"], "id": ident["id"],
|
|
271
|
+
"bindings": sorted(bound.get((ident["kind"], ident["id"]), []))}
|
|
272
|
+
if ident["kind"] == "serviceaccount":
|
|
273
|
+
row["pods"] = pod_sas.get(ident["id"], 0)
|
|
274
|
+
hint = _HINTS.get((ident["kind"], ident["id"]))
|
|
275
|
+
if ident["kind"] == "group" and ident["id"].startswith("system:bootstrappers:"):
|
|
276
|
+
hint = "node bootstrap tokens (kubeadm join); trust it"
|
|
277
|
+
if hint:
|
|
278
|
+
row["hint"] = hint
|
|
279
|
+
if _restricted(model, ident):
|
|
280
|
+
result.would_restrict.append(row)
|
|
281
|
+
granted = access.get(f"{ident['kind']}:{ident['id']}", {})
|
|
282
|
+
for check, why in ESCAPE_CHECKS:
|
|
283
|
+
targets = granted.get(check)
|
|
284
|
+
if targets:
|
|
285
|
+
detail = why + (f" ({', '.join(targets)})" if isinstance(targets, list)
|
|
286
|
+
else "")
|
|
287
|
+
result.findings.append({
|
|
288
|
+
"identity": f"{ident['kind']}/{ident['id']}", "kind": check,
|
|
289
|
+
"severity": "high", "detail": detail,
|
|
290
|
+
"why": "an identity the compiled policies restrict; admission cannot "
|
|
291
|
+
"see or stop this, so RBAC must not grant it"})
|
|
292
|
+
else:
|
|
293
|
+
result.exempt.append(row)
|
|
294
|
+
|
|
295
|
+
existing = {f"{s['namespace']}:{s['name']}" for s in sas}
|
|
296
|
+
for ident in (*model.exempt("kubernetes"),
|
|
297
|
+
*(model.agents_for("kubernetes") if model.mode == "agents-only" else ())):
|
|
298
|
+
section = ("break_glass" if ident in model.break_glass else
|
|
299
|
+
"trusted" if ident in model.trusted else "agents")
|
|
300
|
+
if ident.kind == "serviceaccount" and ident.id not in existing:
|
|
301
|
+
result.missing.append({"identity": f"serviceaccount/{ident.id}",
|
|
302
|
+
"listed_as": section,
|
|
303
|
+
"detail": "listed in agents.yaml but not in this cluster"})
|
|
304
|
+
elif section == "break_glass" and ident.kind in ("user", "group") and \
|
|
305
|
+
(ident.kind, ident.id) not in bound:
|
|
306
|
+
result.missing.append({"identity": f"{ident.kind}/{ident.id}",
|
|
307
|
+
"listed_as": section,
|
|
308
|
+
"detail": "no role binding grants it anything, so "
|
|
309
|
+
"break-glass has no access"})
|
|
310
|
+
result.would_restrict.sort(key=lambda r: (r["kind"], r["id"]))
|
|
311
|
+
result.exempt.sort(key=lambda r: (r["kind"], r["id"]))
|
|
312
|
+
return result
|
|
@@ -529,6 +529,23 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
529
529
|
aws_parser.add_argument("--max-policies", type=int, default=4,
|
|
530
530
|
help="SCPs available on the target (default 4: five may attach, "
|
|
531
531
|
"one is usually FullAWSAccess)")
|
|
532
|
+
kube_parser = compile_sub.add_parser(
|
|
533
|
+
"kubernetes",
|
|
534
|
+
help="Kubernetes ValidatingAdmissionPolicies scoped to agent identities (agents.yaml), "
|
|
535
|
+
"for one cluster",
|
|
536
|
+
)
|
|
537
|
+
_add_common_options(kube_parser)
|
|
538
|
+
_add_agents_option(kube_parser)
|
|
539
|
+
kube_parser.add_argument("--cluster", required=True,
|
|
540
|
+
help="the cluster (or kube context) name; its environment comes "
|
|
541
|
+
"from environments.yaml (kubernetes.clusters / contexts)")
|
|
542
|
+
kube_out = kube_parser.add_mutually_exclusive_group(required=True)
|
|
543
|
+
kube_out.add_argument("--out", metavar="DIR", help="write the output here")
|
|
544
|
+
kube_out.add_argument("--check", metavar="DIR",
|
|
545
|
+
help="exit 1 if DIR differs from a fresh compile (drift check)")
|
|
546
|
+
kube_parser.add_argument("--escalate", default="deny", choices=("deny", "omit"),
|
|
547
|
+
help="ESCALATE rules: 'deny' (default; admission cannot ask) or "
|
|
548
|
+
"'omit' (left client-only, reported as not enforced)")
|
|
532
549
|
|
|
533
550
|
audit_parser = subparsers.add_parser(
|
|
534
551
|
"audit-identity",
|
|
@@ -547,6 +564,19 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
547
564
|
"output instead of calling AWS")
|
|
548
565
|
audit_aws.add_argument("--would-restrict", action="store_true",
|
|
549
566
|
help="print only the identities the compiled policy would restrict")
|
|
567
|
+
audit_kube = audit_sub.add_parser(
|
|
568
|
+
"kubernetes", help="a cluster's ServiceAccounts, RBAC subjects and escape permissions")
|
|
569
|
+
_add_common_options(audit_kube)
|
|
570
|
+
_add_agents_option(audit_kube)
|
|
571
|
+
audit_kube.add_argument("--context", dest="kube_context", default=None,
|
|
572
|
+
help="kube context to read (read-only: get and SubjectAccessReviews)")
|
|
573
|
+
audit_kube.add_argument("--inventory", default=None, metavar="FILE",
|
|
574
|
+
help="use a saved inventory (JSON) instead of calling the cluster")
|
|
575
|
+
audit_kube.add_argument("--save-inventory", default=None, metavar="FILE",
|
|
576
|
+
help="also write the collected inventory here")
|
|
577
|
+
audit_kube.add_argument("--would-restrict", action="store_true",
|
|
578
|
+
help="print only the identities the compiled policies would "
|
|
579
|
+
"restrict")
|
|
550
580
|
|
|
551
581
|
agents_parser = subparsers.add_parser(
|
|
552
582
|
"agents",
|
|
@@ -1343,6 +1373,53 @@ def _run_audit_aws(args: argparse.Namespace) -> int:
|
|
|
1343
1373
|
return 1 if result.problems else 0
|
|
1344
1374
|
|
|
1345
1375
|
|
|
1376
|
+
def _run_audit_kubernetes(args: argparse.Namespace) -> int:
|
|
1377
|
+
"""``aegis audit-identity kubernetes``: read-only; exit 1 on a problem."""
|
|
1378
|
+
from aegis_core.audit_kubernetes import InventoryError, audit, collect_inventory
|
|
1379
|
+
|
|
1380
|
+
model, warnings = _load_identity(args)
|
|
1381
|
+
try:
|
|
1382
|
+
if args.inventory:
|
|
1383
|
+
inventory = json.loads(Path(args.inventory).read_text())
|
|
1384
|
+
else:
|
|
1385
|
+
inventory = collect_inventory(args.kube_context, model)
|
|
1386
|
+
if args.save_inventory:
|
|
1387
|
+
Path(args.save_inventory).write_text(json.dumps(inventory, indent=2) + "\n")
|
|
1388
|
+
result = audit(inventory, model, args.kube_context or "")
|
|
1389
|
+
except InventoryError as exc:
|
|
1390
|
+
raise DataError(f"audit-identity: {exc}") from None
|
|
1391
|
+
doc = result.to_dict()
|
|
1392
|
+
if args.would_restrict:
|
|
1393
|
+
doc = {"context": result.context, "would_restrict": result.would_restrict}
|
|
1394
|
+
if not args.pretty:
|
|
1395
|
+
print(json.dumps({**doc, "warnings": warnings}, sort_keys=True))
|
|
1396
|
+
return 1 if result.problems else 0
|
|
1397
|
+
print(f"AUDIT kubernetes {result.context or '(current context)'} "
|
|
1398
|
+
f"({result.mode}, {result.enforcement})")
|
|
1399
|
+
print(f" would restrict ({len(result.would_restrict)}):")
|
|
1400
|
+
for r in result.would_restrict:
|
|
1401
|
+
pods = f", {r['pods']} pod(s)" if r.get("pods") else ""
|
|
1402
|
+
roles = f"{len(r['bindings'])} binding(s)" if r["bindings"] else "no bindings"
|
|
1403
|
+
hint = f" <- {r['hint']}" if r.get("hint") else ""
|
|
1404
|
+
print(f" {r['kind']:<14} {r['id']} ({roles}{pods}){hint}")
|
|
1405
|
+
if not args.would_restrict:
|
|
1406
|
+
print(f" exempt ({len(result.exempt)}):")
|
|
1407
|
+
for r in result.exempt:
|
|
1408
|
+
print(f" {r['kind']:<14} {r['id']}")
|
|
1409
|
+
print(f" control plane, always exempt: {result.control_plane}")
|
|
1410
|
+
for m in result.missing:
|
|
1411
|
+
print(f" MISSING {m['listed_as']}: {m['identity']} ({m['detail']})")
|
|
1412
|
+
by_identity: dict[str, list[str]] = {}
|
|
1413
|
+
for f in result.findings:
|
|
1414
|
+
by_identity.setdefault(f["identity"], []).append(f["kind"])
|
|
1415
|
+
for identity, kinds in by_identity.items():
|
|
1416
|
+
print(f" PROBLEM {identity} can: {', '.join(kinds)} (admission cannot see or stop "
|
|
1417
|
+
"these; RBAC must not grant them to an identity the policies restrict)")
|
|
1418
|
+
for w in warnings:
|
|
1419
|
+
print(f" warning: {w}")
|
|
1420
|
+
return 1 if result.problems else 0
|
|
1421
|
+
|
|
1422
|
+
|
|
1346
1423
|
def _run_agents(args: argparse.Namespace) -> int:
|
|
1347
1424
|
"""``aegis agents``: verify ``agents.yaml`` (signature, shape, the
|
|
1348
1425
|
principal's ``identity`` class) and print the identity model."""
|
|
@@ -1399,6 +1476,67 @@ def _build_snapshot(args: argparse.Namespace):
|
|
|
1399
1476
|
authority_map, load
|
|
1400
1477
|
|
|
1401
1478
|
|
|
1479
|
+
def _compile_inputs(args: argparse.Namespace):
|
|
1480
|
+
"""``(store, snapshot, model, env_map, identity path)`` for a compile."""
|
|
1481
|
+
store, snap, authority_map, load = _build_snapshot(args)
|
|
1482
|
+
path = _agents_path(args)
|
|
1483
|
+
if not os.path.exists(path):
|
|
1484
|
+
raise DataError(f"compile: no {path}; server-side policy needs the identity model "
|
|
1485
|
+
"(copy agents.example.yaml, see docs/configuration.md)")
|
|
1486
|
+
model = _load_or_data_error(
|
|
1487
|
+
lambda: load_identity_model(path, authority_map=authority_map, **load), path, "agents")
|
|
1488
|
+
env_map = None
|
|
1489
|
+
if args.environments:
|
|
1490
|
+
env_map = _load_or_data_error(lambda: load_environment_map(args.environments, **load),
|
|
1491
|
+
args.environments, "environments")
|
|
1492
|
+
return store, snap, model, env_map, path
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _write_or_check(args: argparse.Namespace, result, snap) -> int | None:
|
|
1496
|
+
from aegis_core.compile import check_outputs, write_outputs
|
|
1497
|
+
|
|
1498
|
+
if args.check:
|
|
1499
|
+
drift = check_outputs(args.check, result.files)
|
|
1500
|
+
for line in drift:
|
|
1501
|
+
print(f"drift: {line}")
|
|
1502
|
+
if not drift:
|
|
1503
|
+
print(f"aegis: {args.check} matches the current policy "
|
|
1504
|
+
f"(snapshot {snap.digest[:16]})")
|
|
1505
|
+
return 1 if drift else 0
|
|
1506
|
+
write_outputs(args.out, result.files)
|
|
1507
|
+
return None
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def _run_compile_kubernetes(args: argparse.Namespace) -> int:
|
|
1511
|
+
"""``aegis compile kubernetes``: ValidatingAdmissionPolicies for one cluster."""
|
|
1512
|
+
from aegis_core.compile import CompileError
|
|
1513
|
+
from aegis_core.compile.kubernetes import KubernetesTarget, compile_kubernetes
|
|
1514
|
+
|
|
1515
|
+
store, snap, model, env_map, path = _compile_inputs(args)
|
|
1516
|
+
env = None
|
|
1517
|
+
if env_map is not None:
|
|
1518
|
+
env = (env_map.kubernetes_clusters.get(args.cluster)
|
|
1519
|
+
or env_map.kubernetes_contexts.get(args.cluster))
|
|
1520
|
+
try:
|
|
1521
|
+
result = compile_kubernetes(snap, model,
|
|
1522
|
+
KubernetesTarget(cluster=args.cluster, env=env,
|
|
1523
|
+
escalate=args.escalate),
|
|
1524
|
+
identity_sha256=_sha256_file(path))
|
|
1525
|
+
except (CompileError, ValueError) as exc:
|
|
1526
|
+
raise DataError(f"compile: {exc}") from None
|
|
1527
|
+
checked = _write_or_check(args, result, snap)
|
|
1528
|
+
if checked is not None:
|
|
1529
|
+
return checked
|
|
1530
|
+
summary = ", ".join(f"{k}={v}" for k, v in result.coverage["summary"].items())
|
|
1531
|
+
print(f"aegis: wrote {len(result.manifest['policies'])} ValidatingAdmissionPolicies for "
|
|
1532
|
+
f"cluster {args.cluster} to {args.out} (snapshot {snap.digest[:16]}; {summary})")
|
|
1533
|
+
if model.enforcement != "enforce":
|
|
1534
|
+
print("aegis: agents.yaml is report-only: bindings warn and audit instead of denying")
|
|
1535
|
+
for w in [*store.warnings, *(f"agents: {w}" for w in model.warnings)]:
|
|
1536
|
+
print(f" warning: {w}")
|
|
1537
|
+
return 0
|
|
1538
|
+
|
|
1539
|
+
|
|
1402
1540
|
def _run_compile_aws(args: argparse.Namespace) -> int:
|
|
1403
1541
|
"""``aegis compile aws``: SCPs, coverage and manifest for one account."""
|
|
1404
1542
|
from aegis_core.compile import CompileError, check_outputs, write_outputs
|
|
@@ -1486,8 +1624,12 @@ def _run(argv: list[str]) -> int:
|
|
|
1486
1624
|
if args.command == "agents":
|
|
1487
1625
|
return _run_agents(args)
|
|
1488
1626
|
if args.command == "compile":
|
|
1627
|
+
if args.target == "kubernetes":
|
|
1628
|
+
return _run_compile_kubernetes(args)
|
|
1489
1629
|
return _run_compile_aws(args)
|
|
1490
1630
|
if args.command == "audit-identity":
|
|
1631
|
+
if args.target == "kubernetes":
|
|
1632
|
+
return _run_audit_kubernetes(args)
|
|
1491
1633
|
return _run_audit_aws(args)
|
|
1492
1634
|
if args.command == "hook":
|
|
1493
1635
|
return hook_module.main_hook(
|