aegis-devops 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aegis_devops-0.2.1/src/aegis_devops.egg-info → aegis_devops-0.3.0}/PKG-INFO +4 -3
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/README.md +3 -2
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/pyproject.toml +1 -1
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/__init__.py +1 -1
- aegis_devops-0.3.0/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- aegis_devops-0.3.0/src/aegis_core/_examples/agents.example.yaml +41 -0
- aegis_devops-0.3.0/src/aegis_core/_examples/agents.example.yaml.sig +2 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/authority.example.yaml +1 -0
- aegis_devops-0.3.0/src/aegis_core/_examples/authority.example.yaml.sig +2 -0
- aegis_devops-0.3.0/src/aegis_core/audit_aws.py +324 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/cli.py +326 -0
- aegis_devops-0.3.0/src/aegis_core/compile/__init__.py +81 -0
- aegis_devops-0.3.0/src/aegis_core/compile/actions/aws.yaml +339 -0
- aegis_devops-0.3.0/src/aegis_core/compile/aws.py +758 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/gitsource.py +3 -0
- aegis_devops-0.3.0/src/aegis_core/identity.py +400 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/store.py +139 -4
- {aegis_devops-0.2.1 → aegis_devops-0.3.0/src/aegis_devops.egg-info}/PKG-INFO +4 -3
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_devops.egg-info/SOURCES.txt +11 -0
- aegis_devops-0.3.0/tests/test_audit_aws.py +253 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_authority.py +2 -2
- aegis_devops-0.3.0/tests/test_compile_aws.py +498 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_gitsource.py +24 -0
- aegis_devops-0.3.0/tests/test_identity.py +347 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_signing.py +2 -1
- aegis_devops-0.3.0/tests/test_snapshot.py +264 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/__pycache__/__init__.cpython-312.pyc +0 -0
- aegis_devops-0.2.1/src/aegis_core/_examples/authority.example.yaml.sig +0 -2
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/LICENSE +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/MANIFEST.in +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/setup.cfg +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/__init__.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/constraints.example.yaml +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/constraints.example.yaml.sig +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/environments.example.yaml +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/environments.example.yaml.sig +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/example-signing.key +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/plan_constraints.example.yaml +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/plan_constraints.example.yaml.sig +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/repos.example.yaml +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/repos.example.yaml.sig +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/signers.example.yaml +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/signers.example.yaml.sig +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/AEGIS-MANIFEST.sig +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/PRINCIPALS.yaml +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-4001.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-6001.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-6002.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-6003.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-6004.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-6005.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-6006.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-6007.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-6008.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-6009.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-789abc.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-abc123.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-bcd234.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/git-def456.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-1001.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-1002.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-2001.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-2002.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-3001.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-3002.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-3003.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-3004.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-3005.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-5001.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-5002.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-5003.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-5004.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/jira-5006.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/plan-1001.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/plan-1002.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/plan-1003.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/plan-1004.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/plan-5005.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/_examples/sources/slack-perma-1.json +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/adversarial.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/authority.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/baselines/__init__.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/baselines/base.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/baselines/external.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/baselines/llm.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/baselines/metrics.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/baselines/opa.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/config.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/environments.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/hook.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/integrations/opencode-plugin.js +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/intent.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/interceptor.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/ledger.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/parser.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/parsers/__init__.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/parsers/pulumi.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/parsers/sql.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/plan.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/provenance.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/shell.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_core/signing.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_devops.egg-info/dependency_links.txt +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_devops.egg-info/entry_points.txt +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_devops.egg-info/requires.txt +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/src/aegis_devops.egg-info/top_level.txt +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_adversarial.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_baselines.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_benchmark.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_cli.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_config.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_corpus.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_default_rules.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_docs_consistency.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_environments.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_hook.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_intent.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_interceptor.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_ledger.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_opencode_plugin.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_parser.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_parsers_pulumi.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_parsers_sql.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_plan.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_plugins.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_provenance.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_shell.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_store.py +0 -0
- {aegis_devops-0.2.1 → aegis_devops-0.3.0}/tests/test_store_match.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aegis-devops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Provenance-backed, authority-aware policy verifier for agentic DevOps actions.
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/moneytool/aegis-devops
|
|
@@ -252,7 +252,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
252
252
|
|
|
253
253
|
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
254
254
|
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
255
|
-
v0.
|
|
255
|
+
v0.3.0 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
256
256
|
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
257
257
|
through the Codex CLI, and a local `mistral:latest`.
|
|
258
258
|
|
|
@@ -273,7 +273,8 @@ of anything you care about.
|
|
|
273
273
|
| [`docs/agents.md`](docs/agents.md) | Claude Code, Codex, Copilot, VS Code and Cursor: install, what gets blocked |
|
|
274
274
|
| [`docs/cli.md`](docs/cli.md) | Exit codes, store health, the Claude Code hook, argv forms, compound commands, dry runs, library usage |
|
|
275
275
|
| [`docs/constraints.md`](docs/constraints.md) | Writing constraints, metadata vocabulary, authority policy, environment mapping |
|
|
276
|
-
| [`docs/configuration.md`](docs/configuration.md) | Configuration/config-dir discovery, signing, source verification, rate limits & ledger |
|
|
276
|
+
| [`docs/configuration.md`](docs/configuration.md) | Configuration/config-dir discovery, signing, source verification, the identity model (`agents.yaml`), rate limits & ledger |
|
|
277
|
+
| [`docs/server-side.md`](docs/server-side.md) | Preview: compiling the policy to AWS Service Control Policies scoped to agent identities |
|
|
277
278
|
| [`docs/benchmark.md`](docs/benchmark.md) | Benchmark methodology, full results table, real LLM baselines, agent-harness baselines, corpus, adversarial suite |
|
|
278
279
|
| [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) | Adding a new parser or tool |
|
|
279
280
|
| [`docs/dev/PLAN.md`](docs/dev/PLAN.md) | Design plan and open gaps |
|
|
@@ -220,7 +220,7 @@ context a rule can arrive from a ticket or a chat message as easily as from you.
|
|
|
220
220
|
|
|
221
221
|
The engine (constraint store, interceptor, environment mapping, dry-run handling, rate limits,
|
|
222
222
|
plan-level constraints, and parsers for every tool in "Supported tools") is complete, and
|
|
223
|
-
v0.
|
|
223
|
+
v0.3.0 is on PyPI as an **alpha**. Real LLM baselines have been run: Claude Sonnet 5 through
|
|
224
224
|
the API (cached in `results/llm-external.md`), Haiku through the Claude Code CLI, `gpt-6-astra`
|
|
225
225
|
through the Codex CLI, and a local `mistral:latest`.
|
|
226
226
|
|
|
@@ -241,7 +241,8 @@ of anything you care about.
|
|
|
241
241
|
| [`docs/agents.md`](docs/agents.md) | Claude Code, Codex, Copilot, VS Code and Cursor: install, what gets blocked |
|
|
242
242
|
| [`docs/cli.md`](docs/cli.md) | Exit codes, store health, the Claude Code hook, argv forms, compound commands, dry runs, library usage |
|
|
243
243
|
| [`docs/constraints.md`](docs/constraints.md) | Writing constraints, metadata vocabulary, authority policy, environment mapping |
|
|
244
|
-
| [`docs/configuration.md`](docs/configuration.md) | Configuration/config-dir discovery, signing, source verification, rate limits & ledger |
|
|
244
|
+
| [`docs/configuration.md`](docs/configuration.md) | Configuration/config-dir discovery, signing, source verification, the identity model (`agents.yaml`), rate limits & ledger |
|
|
245
|
+
| [`docs/server-side.md`](docs/server-side.md) | Preview: compiling the policy to AWS Service Control Policies scoped to agent identities |
|
|
245
246
|
| [`docs/benchmark.md`](docs/benchmark.md) | Benchmark methodology, full results table, real LLM baselines, agent-harness baselines, corpus, adversarial suite |
|
|
246
247
|
| [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) | Adding a new parser or tool |
|
|
247
248
|
| [`docs/dev/PLAN.md`](docs/dev/PLAN.md) | Design plan and open gaps |
|
|
@@ -62,7 +62,7 @@ version = {attr = "aegis_core.__version__"}
|
|
|
62
62
|
where = ["src"]
|
|
63
63
|
|
|
64
64
|
[tool.setuptools.package-data]
|
|
65
|
-
aegis_core = ["_examples/**/*", "integrations/*"]
|
|
65
|
+
aegis_core = ["_examples/**/*", "integrations/*", "compile/actions/*"]
|
|
66
66
|
|
|
67
67
|
[tool.ruff]
|
|
68
68
|
line-length = 100
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# The identity model for server-side enforcement (docs/configuration.md#agentsyaml-identity-model).
|
|
2
|
+
# Server-side layers see which identity made an API call, not who typed a command,
|
|
3
|
+
# so compiled policies apply to agent identities and this file says which those are.
|
|
4
|
+
# Copy to agents.yaml, replace every identity with your own, sign it with `aegis sign`,
|
|
5
|
+
# then check it with: aegis agents --pretty
|
|
6
|
+
version: 1
|
|
7
|
+
principal: admin # must hold the 'identity' class in authority.yaml
|
|
8
|
+
mode: deny-by-default # every identity not listed below is treated as an agent
|
|
9
|
+
enforcement: report-only # switch to 'enforce' only after reviewing
|
|
10
|
+
# `aegis audit-identity --would-restrict`
|
|
11
|
+
|
|
12
|
+
# Never restricted, in either mode. Keep one per platform you compile for.
|
|
13
|
+
break_glass:
|
|
14
|
+
- platform: kubernetes
|
|
15
|
+
kind: group
|
|
16
|
+
id: aegis:break-glass
|
|
17
|
+
- platform: aws
|
|
18
|
+
kind: role
|
|
19
|
+
id: "arn:aws:iam::111122223333:role/BreakGlass"
|
|
20
|
+
|
|
21
|
+
# People, CI and controllers that are not agents. Anything unlisted is restricted
|
|
22
|
+
# by the compiled policy, which in practice means its destructive actions.
|
|
23
|
+
trusted:
|
|
24
|
+
- platform: kubernetes
|
|
25
|
+
kind: group
|
|
26
|
+
id: platform-admins
|
|
27
|
+
- platform: kubernetes
|
|
28
|
+
kind: serviceaccount
|
|
29
|
+
id: "argocd:argocd-application-controller"
|
|
30
|
+
note: applies reviewed changes only; agents must not push to the branch it syncs
|
|
31
|
+
- platform: aws
|
|
32
|
+
kind: role
|
|
33
|
+
id: "arn:aws:iam::111122223333:role/Deploy"
|
|
34
|
+
- platform: aws
|
|
35
|
+
kind: source-identity
|
|
36
|
+
id: alice@example.com
|
|
37
|
+
- platform: github
|
|
38
|
+
kind: oidc-subject
|
|
39
|
+
# bound to one reusable workflow at a protected ref, so an agent job in the same
|
|
40
|
+
# repository cannot present it (a plain repo:<org>/<repo>:ref:... subject can)
|
|
41
|
+
id: "repo:example-org/app:job_workflow_ref:example-org/app/.github/workflows/deploy.yml@refs/heads/main"
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""``aegis audit-identity aws``: what the identity model means for the
|
|
2
|
+
identities that actually exist in an AWS account (design v0.3, §4.2, §4.3).
|
|
3
|
+
|
|
4
|
+
Read-only. It works on an IAM inventory -- the output of
|
|
5
|
+
``aws iam get-account-authorization-details`` -- which :func:`collect_inventory`
|
|
6
|
+
fetches with the operator's own credentials, or which the operator saves and
|
|
7
|
+
passes in. The analysis itself is pure, so it is tested offline.
|
|
8
|
+
|
|
9
|
+
* ``would_restrict``: every IAM role and user a compiled policy would restrict.
|
|
10
|
+
In ``deny-by-default`` that is everything not listed as trusted or
|
|
11
|
+
break-glass, so legitimate automation (backup jobs, cleanup functions,
|
|
12
|
+
deploy roles) shows up here and must be added to ``trusted`` before
|
|
13
|
+
``enforcement: enforce``. Service-linked roles are never restricted (SCPs
|
|
14
|
+
do not apply to them) and are listed separately.
|
|
15
|
+
* ``missing``: identities ``agents.yaml`` lists for this account that do not
|
|
16
|
+
exist. A missing break-glass role means there is no break-glass.
|
|
17
|
+
* ``findings``: trust-policy problems that let an agent become an exempt
|
|
18
|
+
identity without calling ``sts:AssumeRole`` from its own session --
|
|
19
|
+
GitHub OIDC trust that is not bound to a workflow or protected
|
|
20
|
+
environment, OIDC trust with no subject condition at all -- and exempt
|
|
21
|
+
roles that trust a restricted identity directly (closed by the compiled
|
|
22
|
+
self-protection block once enforced, open while report-only).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from aegis_core.identity import IdentityModel
|
|
34
|
+
|
|
35
|
+
GITHUB_OIDC = "token.actions.githubusercontent.com"
|
|
36
|
+
_SUB_KEY = f"{GITHUB_OIDC}:sub"
|
|
37
|
+
_WORKFLOW_KEYS = (f"{GITHUB_OIDC}:job_workflow_ref", f"{GITHUB_OIDC}:environment")
|
|
38
|
+
_BOUND_MARKERS = ("job_workflow_ref:", ":environment:")
|
|
39
|
+
_ARN_ACCOUNT = re.compile(r"arn:aws[a-z-]*:iam::(\d{12}):")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InventoryError(ValueError):
|
|
43
|
+
"""The inventory could not be read or collected."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def collect_inventory(profile: str | None = None) -> dict[str, Any]:
|
|
47
|
+
"""``aws iam get-account-authorization-details`` for roles and users,
|
|
48
|
+
read-only, with the operator's credentials (the CLI paginates)."""
|
|
49
|
+
argv = ["aws", "iam", "get-account-authorization-details", "--filter", "Role", "User",
|
|
50
|
+
"--output", "json"]
|
|
51
|
+
if profile:
|
|
52
|
+
argv += ["--profile", profile]
|
|
53
|
+
try:
|
|
54
|
+
proc = subprocess.run(argv, capture_output=True, text=True, check=False,
|
|
55
|
+
env=_cli_env())
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
raise InventoryError("the aws CLI is not on PATH (or pass --inventory FILE)") from None
|
|
58
|
+
if proc.returncode != 0:
|
|
59
|
+
raise InventoryError(f"aws iam get-account-authorization-details failed: "
|
|
60
|
+
f"{proc.stderr.strip()[:500]}")
|
|
61
|
+
return json.loads(proc.stdout)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cli_env() -> dict[str, str]:
|
|
65
|
+
import os
|
|
66
|
+
|
|
67
|
+
return {**os.environ, "AWS_PAGER": ""}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- trust policies ----------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _as_list(value: Any) -> list[Any]:
|
|
74
|
+
if value is None:
|
|
75
|
+
return []
|
|
76
|
+
return value if isinstance(value, list) else [value]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _principals(statement: dict[str, Any]) -> dict[str, list[str]]:
|
|
80
|
+
principal = statement.get("Principal", {})
|
|
81
|
+
if principal == "*":
|
|
82
|
+
return {"AWS": ["*"]}
|
|
83
|
+
if not isinstance(principal, dict):
|
|
84
|
+
return {}
|
|
85
|
+
return {k: [str(v) for v in _as_list(vals)] for k, vals in principal.items()}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Only these operators *require* the claim to match (review of #18): negated
|
|
89
|
+
# operators, ...IfExists (optional), Null and ForAllValues (vacuously true
|
|
90
|
+
# for a missing key) never prove a binding.
|
|
91
|
+
_BINDING_OPERATORS = frozenset({
|
|
92
|
+
"StringEquals", "StringLike", "ForAnyValue:StringEquals", "ForAnyValue:StringLike",
|
|
93
|
+
})
|
|
94
|
+
_GLOB = frozenset("*?")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _conditions(statement: dict[str, Any]) -> list[tuple[str, str, list[str]]]:
|
|
98
|
+
out = []
|
|
99
|
+
for op, kv in (statement.get("Condition") or {}).items():
|
|
100
|
+
if isinstance(kv, dict):
|
|
101
|
+
for key, vals in kv.items():
|
|
102
|
+
out.append((str(op), str(key).lower(), [str(v) for v in _as_list(vals)]))
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _binding_value(sub: str) -> str | None:
|
|
107
|
+
"""The workflow or environment a ``sub`` value binds to, or None."""
|
|
108
|
+
for marker in _BOUND_MARKERS:
|
|
109
|
+
if marker in sub:
|
|
110
|
+
return sub.split(marker, 1)[1]
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def github_trust_findings(trust: dict[str, Any]) -> list[dict[str, str]]:
|
|
115
|
+
"""Problems with ``sts:AssumeRoleWithWebIdentity`` trust for GitHub
|
|
116
|
+
Actions. A statement is bound only when a required (positive) condition
|
|
117
|
+
pins it to one specific reusable workflow at one ref, or one environment:
|
|
118
|
+
|
|
119
|
+
* ``oidc-no-subject``: no required condition on ``sub``, the workflow or
|
|
120
|
+
the environment -- any repository's workflow can assume the role;
|
|
121
|
+
* ``oidc-unsafe-condition``: a negated, optional (``IfExists``), ``Null``
|
|
122
|
+
or ``ForAllValues`` condition on those claims, which never binds;
|
|
123
|
+
* ``oidc-wildcard-subject``: a wildcard in the owner or repository;
|
|
124
|
+
* ``wildcard-binding``: a wildcard in the bound workflow, its ref or the
|
|
125
|
+
environment (``environment:*`` accepts an unprotected environment an
|
|
126
|
+
agent job can name; ``workflows/*`` accepts the agent's own workflow);
|
|
127
|
+
* ``workflow-unbound``: repository/ref-scoped, so every job in that
|
|
128
|
+
repository -- an agent's included -- presents it."""
|
|
129
|
+
findings = []
|
|
130
|
+
claims = (_SUB_KEY.lower(), *(k.lower() for k in _WORKFLOW_KEYS))
|
|
131
|
+
for st in _as_list(trust.get("Statement")):
|
|
132
|
+
if not isinstance(st, dict) or st.get("Effect") != "Allow":
|
|
133
|
+
continue
|
|
134
|
+
federated = _principals(st).get("Federated", [])
|
|
135
|
+
if not any(GITHUB_OIDC in f for f in federated):
|
|
136
|
+
continue
|
|
137
|
+
conds = _conditions(st)
|
|
138
|
+
# conditions are ANDed: a negated or optional one next to a precise
|
|
139
|
+
# required binding only narrows it, so it is reported only when
|
|
140
|
+
# nothing else binds this statement
|
|
141
|
+
unsafe = [{"kind": "oidc-unsafe-condition",
|
|
142
|
+
"detail": f"{op} on {key} {vals!r} does not require that value, so it "
|
|
143
|
+
"binds the role to nothing"}
|
|
144
|
+
for op, key, vals in conds if key in claims and op not in _BINDING_OPERATORS]
|
|
145
|
+
statement: list[dict[str, str]] = []
|
|
146
|
+
|
|
147
|
+
def required(key: str) -> list[str]:
|
|
148
|
+
return [v for op, k, vals in conds if k == key.lower()
|
|
149
|
+
and op in _BINDING_OPERATORS for v in vals]
|
|
150
|
+
|
|
151
|
+
subs = required(_SUB_KEY)
|
|
152
|
+
bindings = [v for k in _WORKFLOW_KEYS for v in required(k)]
|
|
153
|
+
if not subs and not bindings:
|
|
154
|
+
if not unsafe:
|
|
155
|
+
statement.append({"kind": "oidc-no-subject",
|
|
156
|
+
"detail": "GitHub OIDC trust with no required sub, workflow "
|
|
157
|
+
"or environment condition: any repository's "
|
|
158
|
+
"workflow can assume this role"})
|
|
159
|
+
findings += statement + unsafe
|
|
160
|
+
continue
|
|
161
|
+
for value in bindings:
|
|
162
|
+
if _GLOB & set(value):
|
|
163
|
+
statement.append({"kind": "wildcard-binding",
|
|
164
|
+
"detail": f"workflow/environment {value!r} matches more "
|
|
165
|
+
"than one workflow, ref or environment"})
|
|
166
|
+
bound_by_key = any(not (_GLOB & set(v)) for v in bindings)
|
|
167
|
+
for sub in subs:
|
|
168
|
+
repo = sub.split(":", 2)[1] if sub.startswith("repo:") and ":" in sub else ""
|
|
169
|
+
binding = _binding_value(sub)
|
|
170
|
+
if _GLOB & set(repo):
|
|
171
|
+
statement.append({"kind": "oidc-wildcard-subject",
|
|
172
|
+
"detail": f"sub {sub!r} matches more than one repository"})
|
|
173
|
+
elif binding is not None and _GLOB & set(binding):
|
|
174
|
+
statement.append({"kind": "wildcard-binding",
|
|
175
|
+
"detail": f"sub {sub!r} matches more than one workflow, ref "
|
|
176
|
+
"or environment"})
|
|
177
|
+
elif binding is None and not bound_by_key:
|
|
178
|
+
statement.append({"kind": "workflow-unbound",
|
|
179
|
+
"detail": f"sub {sub!r} is repository/ref-scoped: every job "
|
|
180
|
+
"in that repository presents it, an agent's "
|
|
181
|
+
"included"})
|
|
182
|
+
bound = not statement and (bound_by_key or all(
|
|
183
|
+
_binding_value(v) is not None for v in subs))
|
|
184
|
+
findings += statement + ([] if bound else unsafe)
|
|
185
|
+
return findings
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def trusted_principals(trust: dict[str, Any]) -> list[str]:
|
|
189
|
+
"""The AWS principals an ``Allow`` statement lets assume the role."""
|
|
190
|
+
out = []
|
|
191
|
+
for st in _as_list(trust.get("Statement")):
|
|
192
|
+
if isinstance(st, dict) and st.get("Effect") == "Allow":
|
|
193
|
+
out += _principals(st).get("AWS", [])
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --- the audit ------------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class AuditResult:
|
|
202
|
+
account: str
|
|
203
|
+
mode: str
|
|
204
|
+
enforcement: str
|
|
205
|
+
would_restrict: list[dict[str, Any]] = field(default_factory=list)
|
|
206
|
+
exempt: list[dict[str, Any]] = field(default_factory=list)
|
|
207
|
+
service_linked: list[str] = field(default_factory=list)
|
|
208
|
+
missing: list[dict[str, str]] = field(default_factory=list)
|
|
209
|
+
findings: list[dict[str, Any]] = field(default_factory=list)
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def problems(self) -> bool:
|
|
213
|
+
return bool(self.missing) or any(f["severity"] == "high" for f in self.findings)
|
|
214
|
+
|
|
215
|
+
def to_dict(self) -> dict[str, Any]:
|
|
216
|
+
return {
|
|
217
|
+
"account": self.account, "mode": self.mode, "enforcement": self.enforcement,
|
|
218
|
+
"would_restrict": self.would_restrict, "exempt": self.exempt,
|
|
219
|
+
"service_linked": self.service_linked, "missing": self.missing,
|
|
220
|
+
"findings": self.findings,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _account_of(inventory: dict[str, Any]) -> str:
|
|
225
|
+
for entry in (*inventory.get("RoleDetailList", []), *inventory.get("UserDetailList", [])):
|
|
226
|
+
m = _ARN_ACCOUNT.match(entry.get("Arn", ""))
|
|
227
|
+
if m:
|
|
228
|
+
return m.group(1)
|
|
229
|
+
raise InventoryError("the inventory has no IAM role or user ARNs")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _hint(kind: str, path: str, name: str) -> str | None:
|
|
233
|
+
if path.startswith("/aws-reserved/sso.amazonaws.com/"):
|
|
234
|
+
return "IAM Identity Center role: people sign in through it"
|
|
235
|
+
if name == "OrganizationAccountAccessRole":
|
|
236
|
+
return "the management account's way in; usually break-glass"
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def audit(inventory: dict[str, Any], model: IdentityModel) -> AuditResult:
|
|
241
|
+
account = _account_of(inventory)
|
|
242
|
+
result = AuditResult(account, model.mode, model.enforcement)
|
|
243
|
+
present: set[str] = set()
|
|
244
|
+
restricted_arns: set[str] = set()
|
|
245
|
+
|
|
246
|
+
for kind, entries in (("role", inventory.get("RoleDetailList", [])),
|
|
247
|
+
("user", inventory.get("UserDetailList", []))):
|
|
248
|
+
for entry in entries:
|
|
249
|
+
arn, path = entry["Arn"], entry.get("Path", "/")
|
|
250
|
+
name = entry.get("RoleName") or entry.get("UserName") or arn
|
|
251
|
+
present.add(arn)
|
|
252
|
+
if kind == "role" and path.startswith("/aws-service-role/"):
|
|
253
|
+
result.service_linked.append(arn)
|
|
254
|
+
continue
|
|
255
|
+
last = (entry.get("RoleLastUsed") or {}).get("LastUsedDate") if kind == "role" \
|
|
256
|
+
else entry.get("PasswordLastUsed")
|
|
257
|
+
row = {"arn": arn, "kind": kind, "last_used": last}
|
|
258
|
+
hint = _hint(kind, path, name)
|
|
259
|
+
if hint:
|
|
260
|
+
row["hint"] = hint
|
|
261
|
+
if model.is_agent("aws", [(kind, arn)]):
|
|
262
|
+
result.would_restrict.append(row)
|
|
263
|
+
restricted_arns.add(arn)
|
|
264
|
+
else:
|
|
265
|
+
result.exempt.append(row)
|
|
266
|
+
|
|
267
|
+
exempt_listed = model.exempt("aws")
|
|
268
|
+
for ident in (*exempt_listed, *(model.agents_for("aws") if model.mode == "agents-only"
|
|
269
|
+
else ())):
|
|
270
|
+
if ident.kind not in ("role", "user"):
|
|
271
|
+
continue
|
|
272
|
+
m = _ARN_ACCOUNT.match(ident.id)
|
|
273
|
+
if m and m.group(1) == account and ident.id not in present:
|
|
274
|
+
section = ("break_glass" if ident in model.break_glass else
|
|
275
|
+
"trusted" if ident in model.trusted else "agents")
|
|
276
|
+
result.missing.append({"arn": ident.id, "listed_as": section,
|
|
277
|
+
"detail": "listed in agents.yaml but not in this account"
|
|
278
|
+
+ ("; there is no working break-glass for it"
|
|
279
|
+
if section == "break_glass" else "")})
|
|
280
|
+
|
|
281
|
+
exempt_arns = {r["arn"] for r in result.exempt}
|
|
282
|
+
for entry in inventory.get("RoleDetailList", []):
|
|
283
|
+
arn = entry["Arn"]
|
|
284
|
+
if entry.get("Path", "/").startswith("/aws-service-role/"):
|
|
285
|
+
continue
|
|
286
|
+
trust = entry.get("AssumeRolePolicyDocument") or {}
|
|
287
|
+
if isinstance(trust, str):
|
|
288
|
+
from urllib.parse import unquote
|
|
289
|
+
|
|
290
|
+
trust = json.loads(unquote(trust))
|
|
291
|
+
exempt = arn in exempt_arns
|
|
292
|
+
for f in github_trust_findings(trust):
|
|
293
|
+
result.findings.append({
|
|
294
|
+
"role": arn, **f,
|
|
295
|
+
"severity": "high" if exempt else "info",
|
|
296
|
+
"why": ("an exempt role: a GitHub job that is an agent can assume it with "
|
|
297
|
+
"AssumeRoleWithWebIdentity, which the SCP cannot see"
|
|
298
|
+
if exempt else "a restricted role: the policy still applies to it"),
|
|
299
|
+
})
|
|
300
|
+
if exempt:
|
|
301
|
+
for principal in trusted_principals(trust):
|
|
302
|
+
if principal in (f"arn:aws:iam::{account}:root", account):
|
|
303
|
+
result.findings.append({
|
|
304
|
+
"role": arn, "kind": "exempt-trusts-account",
|
|
305
|
+
"detail": "trusts the whole account: any identity in it allowed "
|
|
306
|
+
"sts:AssumeRole can assume it, agents included",
|
|
307
|
+
"severity": "high" if model.enforcement != "enforce" else "info",
|
|
308
|
+
"why": ("blocked for agents by the compiled self-protection block "
|
|
309
|
+
"once enforced; open while report-only. Prefer trusting "
|
|
310
|
+
"named principals"),
|
|
311
|
+
})
|
|
312
|
+
elif principal in restricted_arns:
|
|
313
|
+
result.findings.append({
|
|
314
|
+
"role": arn, "kind": "exempt-trusts-restricted",
|
|
315
|
+
"detail": f"trusts {principal} directly",
|
|
316
|
+
"severity": "high" if model.enforcement != "enforce" else "info",
|
|
317
|
+
"why": ("blocked by the compiled self-protection block (no "
|
|
318
|
+
"sts:AssumeRole into exempt roles) once enforced; open "
|
|
319
|
+
"while report-only"),
|
|
320
|
+
})
|
|
321
|
+
result.would_restrict.sort(key=lambda r: r["arn"])
|
|
322
|
+
result.exempt.sort(key=lambda r: r["arn"])
|
|
323
|
+
result.service_linked.sort()
|
|
324
|
+
return result
|