diff --git a/README.md b/README.md index 59d74fc..15a6d8e 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,12 @@ later notifications become comments while it is open. New issues get the `alert` label. An EventBridge rule in `aws/access-analyzer.tf` also invokes it for each IAM Access Analyzer finding: an active one opens an issue of its own that says only that one was found, with its id and a console link, and the issue -closes once the finding is resolved or archived. `deploy.yml` sets: +closes once the finding is resolved or archived. An EventBridge rule in +`aws/sg-alerts.tf` invokes it for each security group rule added inbound or +changed in Seoul or Tokyo: one naming `0.0.0.0/0` or `::/0` opens an issue that +says only the region and the CloudTrail event id, and an operator closes it once +checked. +`deploy.yml` sets: - `GITHUB_APP_CLIENT_ID`: the alerts GitHub App, installed on femiwiki/infra with Issues read and write diff --git a/grafana-github/lambda_function.py b/grafana-github/lambda_function.py index 8e010a6..27cb393 100644 --- a/grafana-github/lambda_function.py +++ b/grafana-github/lambda_function.py @@ -13,6 +13,12 @@ and each active finding opens an issue of its own, closed once the finding is resolved or archived. The issue says only that one was found and the finding's id, since the repository is public; the console shows which. + +Another EventBridge rule invokes it for each successful call, in Seoul or Tokyo, +that adds an inbound rule to a security group or changes any of its rules. A call +that names 0.0.0.0/0 or ::/0 opens an issue that names only the region and the +CloudTrail event id, for the same reason. An operator closes it once the rule is +checked. """ import base64 @@ -27,6 +33,7 @@ KST = timezone(timedelta(hours=9), "KST") LABEL = "alert" +WORLD = ("0.0.0.0/0", "::/0") def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, int]: @@ -35,6 +42,11 @@ def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, int]: repo, _ = connect() record_finding(repo, event, os.environ.get("GITHUB_MENTION", "")) return {"statusCode": 204} + if event.get("source") == "aws.ec2": + if opens_to_everyone(event["detail"].get("requestParameters")): + repo, _ = connect() + record_ingress(repo, event, os.environ.get("GITHUB_MENTION", "")) + return {"statusCode": 204} headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()} if not authorized(headers.get("authorization", ""), os.environ["WEBHOOK_TOKEN"]): @@ -101,6 +113,43 @@ def record_finding(repo: Repository, event: dict[str, Any], mention: str = "") - ) +def opens_to_everyone(value: Any) -> bool: + """Whether a request names 0.0.0.0/0 or ::/0 anywhere, whatever its shape.""" + if isinstance(value, dict): + return any(opens_to_everyone(v) for v in value.values()) + if isinstance(value, list): + return any(opens_to_everyone(v) for v in value) + return value in WORLD + + +def record_ingress(repo: Repository, event: dict[str, Any], mention: str = "") -> None: + """Open an issue for a call that opened an inbound rule to everyone.""" + detail = event["detail"] + marker = f"Event {detail['eventID']}" + # EventBridge delivers at least once. + if any( + marker in (issue.body or "") + for issue in repo.get_issues(state="open", labels=[LABEL]) + ): + return + region = detail.get("awsRegion") or event.get("region") or "ap-northeast-2" + start = ( + datetime.fromisoformat(detail["eventTime"][:16]) + .replace(tzinfo=UTC) + .astimezone(KST) + ) + console = f"https://{region}.console.aws.amazon.com/cloudtrailv2/home?region={region}#/events/{detail['eventID']}" + text = ( + f"A security group in {region} got an inbound rule open to everyone. " + f"Check it in CloudTrail: {console}\n\n{marker}" + ) + repo.create_issue( + title=f"World-open inbound rule ({start.month}/{start.day} {start:%H:%M} KST)", + body=f"{text}\n\n{mention}".strip(), + labels=[LABEL], + ) + + def authorized(header: str, token: str) -> bool: return hmac.compare_digest(header.encode(), f"Bearer {token}".encode()) diff --git a/grafana-github/test_lambda_function.py b/grafana-github/test_lambda_function.py index ab62f97..b69a7d0 100644 --- a/grafana-github/test_lambda_function.py +++ b/grafana-github/test_lambda_function.py @@ -7,8 +7,10 @@ from lambda_function import ( authorized, lambda_handler, + opens_to_everyone, record, record_finding, + record_ingress, render, title, ) @@ -127,6 +129,86 @@ def test_handler_skips_the_token_for_eventbridge(self): r.create_issue.assert_called_once() +INGRESS: dict = { + "source": "aws.ec2", + "detail-type": "AWS API Call via CloudTrail", + "region": "ap-northeast-1", + "detail": { + "eventSource": "ec2.amazonaws.com", + "eventName": "AuthorizeSecurityGroupIngress", + "eventTime": "2026-10-15T11:00:00Z", + "awsRegion": "ap-northeast-1", + "eventID": "11111111-2222-3333-4444-555555555555", + "requestParameters": { + "groupId": "sg-0123456789abcdef0", + "ipPermissions": { + "items": [ + { + "ipProtocol": "tcp", + "fromPort": 2376, + "toPort": 2376, + "ipRanges": {"items": [{"cidrIp": "0.0.0.0/0"}]}, + } + ] + }, + }, + }, +} + + +def ingress(**request: object) -> dict: + return {**INGRESS, "detail": {**INGRESS["detail"], "requestParameters": request}} + + +class TestOpensToEveryone(unittest.TestCase): + def test_finds_either_family_at_any_depth(self): + self.assertTrue(opens_to_everyone(INGRESS["detail"]["requestParameters"])) + self.assertTrue( + opens_to_everyone( + {"ModifySecurityGroupRulesRequest": {"Rules": [{"CidrIpv6": "::/0"}]}} + ) + ) + + def test_ignores_narrower_ranges(self): + self.assertFalse(opens_to_everyone({"cidrIp": "13.209.1.56/29"})) + self.assertFalse(opens_to_everyone({"cidrIp": "0.0.0.0/1"})) + self.assertFalse(opens_to_everyone(None)) + + +class TestRecordIngress(unittest.TestCase): + def test_opens_an_issue_without_details(self): + r = repo([]) + record_ingress(r, INGRESS, "@femiwiki/pager") + kwargs = r.create_issue.call_args.kwargs + self.assertEqual(kwargs["title"], "World-open inbound rule (10/15 20:00 KST)") + self.assertIn("in ap-northeast-1", kwargs["body"]) + self.assertIn("region=ap-northeast-1#/events/1111", kwargs["body"]) + self.assertIn("Event 11111111-2222-3333-4444-555555555555", kwargs["body"]) + self.assertTrue(kwargs["body"].endswith("\n\n@femiwiki/pager")) + self.assertEqual(kwargs["labels"], ["alert"]) + for secret in ("sg-0123456789abcdef0", "2376", "tcp"): + self.assertNotIn(secret, kwargs["title"] + kwargs["body"]) + + def test_skips_an_event_that_already_has_an_issue(self): + existing = issue("World-open inbound rule (10/15 20:00 KST)") + existing.body = "Event 11111111-2222-3333-4444-555555555555" + r = repo([existing]) + record_ingress(r, INGRESS) + r.create_issue.assert_not_called() + + def test_handler_opens_an_issue_for_a_world_open_rule(self): + r = repo([]) + with ( + patch.dict(os.environ, {"GITHUB_MENTION": ""}, clear=False), + patch.object(lambda_function, "connect", return_value=(r, BOT)) as c, + ): + self.assertEqual(lambda_handler(INGRESS, None), {"statusCode": 204}) + narrow = ingress(groupId="sg-1", cidrIp="10.0.0.0/8") + self.assertEqual(lambda_handler(narrow, None), {"statusCode": 204}) + c.assert_called_once() + r.create_issue.assert_called_once() + + class TestAuthorized(unittest.TestCase): def test_matches_only_the_bearer_token(self): self.assertTrue(authorized("Bearer s3cret", "s3cret"))