diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 646cd65..bc29faa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,3 +22,11 @@ repos: "-d {extends: relaxed, rules: {line-length: disable}, ignore: chart}", "-s", ] + - repo: local + hooks: + - id: policy-path-scope + name: policy rule paths stay within their own directory + entry: python3 scripts/check_policy_paths.py + language: python + additional_dependencies: [pyyaml] + files: ^policies/.*\.yaml$ diff --git a/Makefile b/Makefile index 2aab8c3..ffe9cd9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: init plan apply format +.PHONY: init plan apply format check-policies VAULT_AUTH_METHOD ?= approle VAULT_K8S_ROLE ?= woodpecker_terraform_vault @@ -28,6 +28,9 @@ apply: init @$(call vault_env) && \ terragrunt run --all --parallelism 2 --non-interactive apply +check-policies: + @python3 scripts/check_policy_paths.py + format: @echo "Formatting OpenTofu files..." @tofu fmt -recursive . diff --git a/scripts/check_policy_paths.py b/scripts/check_policy_paths.py new file mode 100644 index 0000000..415883f --- /dev/null +++ b/scripts/check_policy_paths.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Fail when a rule path in policies/**/*.yaml grants outside the policy's own directory.""" +import glob +import sys +from pathlib import PurePosixPath + +import yaml + +# kv-v2 inserts one of these directly after the mount; it is not part of the scope. +KV_API_SEGMENTS = {"data", "metadata", "delete", "undelete", "destroy"} + +ALLOWED = { + ("policies/global-root.yaml", "*"), # root policy + ("policies/gpg/admin.yaml", "sys/plugins/catalog/secret/vault-plugin-secrets-gpg"), # plugin catalog registration + ("policies/kv/service/terraform/authentik.yaml", "kv/data/kubernetes/namespace/+/default/oauth-credentials"), # terraform writes k8s namespace secrets + ("policies/kv/service/terraform/authentik.yaml", "kv/data/kubernetes/namespace/logging/default/vlogs-oauth-credentials"), # terraform writes k8s namespace secrets + ("policies/kv/service/terraform/enc-encapi-environment.yaml", "kv/data/kubernetes/namespace/encapi/default/environment"), # terraform writes k8s namespace secrets + ("policies/kv/service/terraform/rancher.yaml", "kv/data/kubernetes/namespace/cattle-system/default/oauth-credentials"), # terraform writes k8s namespace secrets + ("policies/identity/group/admin.yaml", "identity/group-alias"), # group-alias endpoint for the same groups + ("policies/identity/group/admin.yaml", "identity/group-alias/*"), # group-alias endpoint for the same groups + ("policies/identity/group/admin.yaml", "identity/lookup/group"), # group lookup endpoint + ("policies/sys/policy/admin.yaml", "sys/policies/acl"), # dir is policy, API path is policies + ("policies/sys/policy/admin.yaml", "sys/policies/acl/*"), # dir is policy, API path is policies + ("policies/sys/mounts/admin.yaml", "sys/mounts-tune/*"), # sibling API path of the mounts endpoint +} + + +def policy_scope(path): + parts = PurePosixPath(path).parts + rel = PurePosixPath(*parts[parts.index("policies") + 1:]) + # a policy at the policies/ root has no directory, so its own name is the scope + return "policies" / rel, rel.parent if rel.parent.parts else PurePosixPath(rel.stem) + + +def rule_scope(rule_path): + path = PurePosixPath(rule_path) + if len(path.parts) > 1 and path.parts[1] in KV_API_SEGMENTS: + return PurePosixPath(path.parts[0], *path.parts[2:]) + return path + + +def violations(files): + for f in files: + key, scope = policy_scope(f) + rules = (yaml.safe_load(open(f)) or {}).get("rules") + if not rules: + yield f"{f}: no rules" + continue + for rule in rules: + path = rule.get("path") + if not path: + yield f"{f}: rule without a path" + elif (str(key), path) not in ALLOWED and not rule_scope(path).is_relative_to(scope): + yield f'{f}: rule path "{path}" escapes policy scope "{scope}"' + + +if __name__ == "__main__": + found = list(violations(sys.argv[1:] or sorted(glob.glob("policies/**/*.yaml", recursive=True)))) + for v in found: + print(v) + sys.exit(1 if found else 0) diff --git a/scripts/test_check_policy_paths.py b/scripts/test_check_policy_paths.py new file mode 100644 index 0000000..2fd6f26 --- /dev/null +++ b/scripts/test_check_policy_paths.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Run with: python3 scripts/test_check_policy_paths.py""" +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from check_policy_paths import violations + +TMP = Path(tempfile.mkdtemp()) + + +def check(rel, *rule_paths): + f = TMP / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text("rules:\n" + "".join(f' - path: "{p}"\n' for p in rule_paths)) + return [v.replace(f"{TMP}/", "") for v in violations([str(f)])] + + +# in scope, including kv-v2 data/metadata segments and any kv-v2 mount +assert check("policies/sys/mounts/admin.yaml", "sys/mounts", "sys/mounts/*") == [] +assert check("policies/rundeck/rundeck.yaml", "rundeck/data/*", "rundeck/metadata/*") == [] +assert check("policies/kv/service/authentik/oidc-vault/read.yaml", "kv/data/service/authentik/oidc-vault") == [] + +# + and * below the directory are fine +assert check("policies/kv/service/vault/read.yaml", "kv/data/service/vault/+/+/auth_backend/*") == [] +assert check("policies/kubernetes/au/admin.yaml", "kubernetes/au/+/config") == [] + +# + standing in for a literal directory segment is not +assert check("policies/kv/service/vault/au/syd1/ghp/write.yaml", "kv/data/service/vault/+/+/ghp/config") == [ + 'policies/kv/service/vault/au/syd1/ghp/write.yaml: rule path ' + '"kv/data/service/vault/+/+/ghp/config" escapes policy scope "kv/service/vault/au/syd1/ghp"' +] + +# a sibling API path is not a prefix match +assert check("policies/sys/thing/admin.yaml", "sys/thing-tune/*") == [ + 'policies/sys/thing/admin.yaml: rule path "sys/thing-tune/*" escapes policy scope "sys/thing"' +] + +# out of tree entirely +assert check("policies/kv/foo/bar/baz.yaml", "kv/data/foo/baz/bar") == [ + 'policies/kv/foo/bar/baz.yaml: rule path "kv/data/foo/baz/bar" escapes policy scope "kv/foo/bar"' +] + +# allowlisted outliers pass +assert check("policies/global-root.yaml", "*") == [] +assert check("policies/sys/policy/admin.yaml", "sys/policies/acl", "sys/policies/acl/*") == [] +assert check("policies/sys/mounts/admin.yaml", "sys/mounts-tune/*") == [] + +# malformed policies fail +(TMP / "policies/empty.yaml").write_text("auth:\n approle:\n - x\n") +assert list(violations([str(TMP / "policies/empty.yaml")]))[0].endswith(": no rules") +(TMP / "policies/nopath.yaml").write_text("rules:\n - capabilities:\n - read\n") +assert list(violations([str(TMP / "policies/nopath.yaml")]))[0].endswith(": rule without a path") + +print("ok")