mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(terraform): pin githubdeploy assume-role policy in import checker
Reject controlled role updates whose trust document is not the rendered GitHub OIDC policy, matching the bucket-policy pin.
This commit is contained in:
parent
c9745ae023
commit
9b53dfa5cc
3 changed files with 155 additions and 5 deletions
|
|
@ -13,6 +13,8 @@ from terraform_import_plan_resources import (
|
||||||
ALLOWED_CREATE_ADDRESSES,
|
ALLOWED_CREATE_ADDRESSES,
|
||||||
CONTROLLED_UPDATE_ADDRESSES,
|
CONTROLLED_UPDATE_ADDRESSES,
|
||||||
ENVIRONMENT_CONFIG,
|
ENVIRONMENT_CONFIG,
|
||||||
|
GITHUB_OIDC_PROVIDER_ARN,
|
||||||
|
GITHUB_REPO,
|
||||||
REQUIRED_IMPORT_IDS,
|
REQUIRED_IMPORT_IDS,
|
||||||
REQUIRED_RESOURCES,
|
REQUIRED_RESOURCES,
|
||||||
)
|
)
|
||||||
|
|
@ -336,6 +338,67 @@ def _validate_policy_update(
|
||||||
return violations
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_github_deploy_assume_policy(environment: str) -> dict[str, Any]:
|
||||||
|
return _canonical(
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "GithubDeployOidc",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": "sts:AssumeRoleWithWebIdentity",
|
||||||
|
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
|
||||||
|
"Condition": {
|
||||||
|
"StringEquals": {
|
||||||
|
"token.actions.githubusercontent.com:aud": (
|
||||||
|
"sts.amazonaws.com"
|
||||||
|
),
|
||||||
|
"token.actions.githubusercontent.com:sub": (
|
||||||
|
f"repo:{GITHUB_REPO}:environment:{environment}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"StringLike": {
|
||||||
|
"token.actions.githubusercontent.com:job_workflow_ref": [
|
||||||
|
(
|
||||||
|
f"{GITHUB_REPO}/.github/workflows/"
|
||||||
|
"deploy-web.yaml@refs/heads/main"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"{GITHUB_REPO}/.github/workflows/"
|
||||||
|
"deploy-web.yaml@refs/tags/v*"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_role_assume_policy(
|
||||||
|
address: str,
|
||||||
|
before: dict[str, Any],
|
||||||
|
after: dict[str, Any],
|
||||||
|
environment: str,
|
||||||
|
) -> list[str]:
|
||||||
|
before_policy, violations = _parse_policy(
|
||||||
|
before.get("assume_role_policy"), address, "before"
|
||||||
|
)
|
||||||
|
after_policy, after_violations = _parse_policy(
|
||||||
|
after.get("assume_role_policy"), address, "after"
|
||||||
|
)
|
||||||
|
violations.extend(after_violations)
|
||||||
|
if before_policy == after_policy:
|
||||||
|
violations.append(f"{address}: assume_role_policy semantics did not change")
|
||||||
|
expected_after = _expected_github_deploy_assume_policy(environment)
|
||||||
|
if after_policy is not None and after_policy != expected_after:
|
||||||
|
violations.append(
|
||||||
|
f"{address}: post-adoption assume_role_policy semantics are not exact"
|
||||||
|
)
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
def _validate_role_update(
|
def _validate_role_update(
|
||||||
address: str,
|
address: str,
|
||||||
before: dict[str, Any],
|
before: dict[str, Any],
|
||||||
|
|
@ -362,6 +425,10 @@ def _validate_role_update(
|
||||||
violations.append(
|
violations.append(
|
||||||
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
|
f"{address}: {tag_attribute} must exactly match adopted ownership tags"
|
||||||
)
|
)
|
||||||
|
if any(path and path[0] == "assume_role_policy" for path in changed):
|
||||||
|
violations.extend(
|
||||||
|
_validate_role_assume_policy(address, before, after, environment)
|
||||||
|
)
|
||||||
return violations
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,11 @@ ALLOWED_CREATE_ADDRESSES = frozenset(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
GITHUB_REPO = "Sea-Haven-Industries/shoc-frontend-new"
|
||||||
|
GITHUB_OIDC_PROVIDER_ARN = (
|
||||||
|
"arn:aws:iam::396287094661:oidc-provider/token.actions.githubusercontent.com"
|
||||||
|
)
|
||||||
|
|
||||||
ENVIRONMENT_CONFIG = {
|
ENVIRONMENT_CONFIG = {
|
||||||
"dev": {
|
"dev": {
|
||||||
"bucket_name": "seahaven-shoc-frontend-dev",
|
"bucket_name": "seahaven-shoc-frontend-dev",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ from terraform_import_plan_resources import (
|
||||||
ALLOWED_CREATE_ADDRESSES,
|
ALLOWED_CREATE_ADDRESSES,
|
||||||
CONTROLLED_UPDATE_ADDRESSES,
|
CONTROLLED_UPDATE_ADDRESSES,
|
||||||
ENVIRONMENT_CONFIG,
|
ENVIRONMENT_CONFIG,
|
||||||
|
GITHUB_OIDC_PROVIDER_ARN,
|
||||||
|
GITHUB_REPO,
|
||||||
REQUIRED_IMPORT_IDS,
|
REQUIRED_IMPORT_IDS,
|
||||||
REQUIRED_RESOURCES,
|
REQUIRED_RESOURCES,
|
||||||
)
|
)
|
||||||
|
|
@ -84,6 +86,40 @@ def pre_adoption_bucket_policy(environment: str) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def github_deploy_assume_policy(environment: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "GithubDeployOidc",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": "sts:AssumeRoleWithWebIdentity",
|
||||||
|
"Principal": {"Federated": GITHUB_OIDC_PROVIDER_ARN},
|
||||||
|
"Condition": {
|
||||||
|
"StringEquals": {
|
||||||
|
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||||
|
"token.actions.githubusercontent.com:sub": (
|
||||||
|
f"repo:{GITHUB_REPO}:environment:{environment}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"StringLike": {
|
||||||
|
"token.actions.githubusercontent.com:job_workflow_ref": [
|
||||||
|
(
|
||||||
|
f"{GITHUB_REPO}/.github/workflows/"
|
||||||
|
"deploy-web.yaml@refs/heads/main"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"{GITHUB_REPO}/.github/workflows/"
|
||||||
|
"deploy-web.yaml@refs/tags/v*"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def bucket_policy(environment: str) -> dict[str, Any]:
|
def bucket_policy(environment: str) -> dict[str, Any]:
|
||||||
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
bucket = ENVIRONMENT_CONFIG[environment]["bucket_name"]
|
||||||
bucket_arn = f"arn:aws:s3:::{bucket}"
|
bucket_arn = f"arn:aws:s3:::{bucket}"
|
||||||
|
|
@ -426,11 +462,33 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
||||||
self.assert_fails(plan, "dev", BUCKET)
|
self.assert_fails(plan, "dev", BUCKET)
|
||||||
|
|
||||||
def test_role_trust_change_is_allowed(self) -> None:
|
def test_role_trust_change_is_allowed(self) -> None:
|
||||||
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
for environment in REQUIRED_RESOURCES:
|
||||||
role = resource(plan, ROLE)["change"]
|
plan = make_plan(
|
||||||
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
environment, mode="controlled", controlled_updates={ROLE}
|
||||||
role["after"]["assume_role_policy"] = '{"Statement":[{"Effect":"Allow"}]}'
|
)
|
||||||
self.assert_passes(plan, "dev", ROLE)
|
role = resource(plan, ROLE)["change"]
|
||||||
|
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||||
|
role["after"]["assume_role_policy"] = json.dumps(
|
||||||
|
github_deploy_assume_policy(environment)
|
||||||
|
)
|
||||||
|
with self.subTest(environment=environment):
|
||||||
|
self.assert_passes(plan, environment, ROLE)
|
||||||
|
|
||||||
|
def test_role_trust_rejects_mutated_document(self) -> None:
|
||||||
|
for mutation in ("principal", "missing-sub"):
|
||||||
|
plan = make_plan("dev", mode="controlled", controlled_updates={ROLE})
|
||||||
|
role = resource(plan, ROLE)["change"]
|
||||||
|
policy = github_deploy_assume_policy("dev")
|
||||||
|
if mutation == "principal":
|
||||||
|
policy["Statement"][0]["Principal"] = {"AWS": "*"}
|
||||||
|
else:
|
||||||
|
del policy["Statement"][0]["Condition"]["StringEquals"][
|
||||||
|
"token.actions.githubusercontent.com:sub"
|
||||||
|
]
|
||||||
|
role["before"]["assume_role_policy"] = '{"Statement":[]}'
|
||||||
|
role["after"]["assume_role_policy"] = json.dumps(policy)
|
||||||
|
with self.subTest(mutation=mutation):
|
||||||
|
self.assert_fails(plan, "dev", ROLE)
|
||||||
|
|
||||||
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
|
def test_bucket_policy_rejects_malicious_principal_and_extra_statement(self) -> None:
|
||||||
for mutation in ("principal", "extra"):
|
for mutation in ("principal", "extra"):
|
||||||
|
|
@ -484,6 +542,26 @@ class ImportPlanCheckerTests(unittest.TestCase):
|
||||||
CONTROLLED_UPDATE_ADDRESSES,
|
CONTROLLED_UPDATE_ADDRESSES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_github_deploy_assume_document_matches_module(self) -> None:
|
||||||
|
source = (
|
||||||
|
REPOSITORY / "terraform/live/modules/environment-owned/main.tf"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
document = source.split(
|
||||||
|
'data "aws_iam_policy_document" "github_deploy_assume" {', 1
|
||||||
|
)[1]
|
||||||
|
document = document.split(
|
||||||
|
'data "aws_iam_policy_document" "github_deploy" {', 1
|
||||||
|
)[0]
|
||||||
|
self.assertIn("GithubDeployOidc", document)
|
||||||
|
self.assertIn("sts:AssumeRoleWithWebIdentity", document)
|
||||||
|
self.assertIn("token.actions.githubusercontent.com:aud", document)
|
||||||
|
self.assertIn("sts.amazonaws.com", document)
|
||||||
|
self.assertIn("token.actions.githubusercontent.com:sub", document)
|
||||||
|
self.assertIn("local.github_subject", document)
|
||||||
|
self.assertIn("token.actions.githubusercontent.com:job_workflow_ref", document)
|
||||||
|
self.assertIn("deploy-web.yaml@refs/heads/main", document)
|
||||||
|
self.assertIn("deploy-web.yaml@refs/tags/v*", document)
|
||||||
|
|
||||||
def test_deploy_policy_controlled_update_passes(self) -> None:
|
def test_deploy_policy_controlled_update_passes(self) -> None:
|
||||||
self.assert_passes(
|
self.assert_passes(
|
||||||
make_plan(
|
make_plan(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue