Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions grafana-github/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand All @@ -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"]):
Expand Down Expand Up @@ -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())

Expand Down
82 changes: 82 additions & 0 deletions grafana-github/test_lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
from lambda_function import (
authorized,
lambda_handler,
opens_to_everyone,
record,
record_finding,
record_ingress,
render,
title,
)
Expand Down Expand Up @@ -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"))
Expand Down
Loading