From 45492a16568ce5f882269b577afff66bb17c1781 Mon Sep 17 00:00:00 2001 From: ddericco Date: Tue, 28 Jul 2026 16:19:07 -0400 Subject: [PATCH 1/9] feat(mcp): Add AWS VPC DNS diagnostics MCP server Adds mcp/aws-vpc-dns-diagnostics-mcp, an MCP server for AWS DevOps Agent that diagnoses VPC DNS resolution two ways no describe API can. Mode A (dns_probe_*) runs live, comparative, multi-resolver DNS resolution inside an EC2 instance via SSM Run Command, returning what a name actually resolves to from that subnet, which resolver answered (hostname.bind), and how a custom or hybrid resolver diverges from the VPC resolver. The EC2, VPC, and Route 53 describe APIs return DNS configuration; they never return that ground truth. Mode B (dns_simulate_*) is read-only pre-change validation. It builds the VPC's effective configuration as the union of directly attached and Route 53 Profile-inherited constructs, applies a proposed change symbolically, and reports which currently-resolving names would break. A seven-level precedence engine and six trap detectors (VPCE-shadow-NXDOMAIN, broad-FORWARD-sweep, flag-AND-mismatch, DNS-Firewall-block, Profile-union-shift, resolver-disabled) name the mechanism rather than only the affected names. list_sops and get_sop serve 16 diagnostic runbooks bundled in the deployment package, so the agent fetches interpretation guidance at runtime. Transport and auth follow the documented DevOps Agent path: FastMCP over Streamable HTTP via Lambda Web Adapter, exposed on a Lambda Function URL with AuthType AWS_IAM and RESPONSE_STREAM, registered with the mcpserversigv4 service type. No AgentCore Gateway or OAuth front end is required. Security model. The central Lambda execution role holds only sts:AssumeRole and assumes one of two per-target-account scoped roles per call. Mode B's role never holds ssm:SendCommand. The probe role's sole privileged grant is ssm:SendCommand resource-scoped to one purpose-built SSM document that accepts three allowedPattern-validated parameters and renders a fixed read-only probe set (dig, cat /etc/resolv.conf, resolvectl, getent). The server sends structured parameters, never a command string. Account, region, VPC, and resolver allowlists are enforced in one place, and the server refuses to start on a wildcard when StageName=prod. There are no credentials to store, so Secrets Manager is not used; the function needs no VPC attachment because it contacts only AWS control-plane APIs. Both deviations from the common reference pattern are documented in the README. Cross-account constructs shared via RAM or contributed through a Route 53 Profile may be enumerable but opaque. Denied detail reads become OPAQUE markers rather than failing the model build, and an opaque firewall rule is evaluated first because a hidden block list may cover any name. Reporting says 'cannot determine from this account' rather than 'not affected'. Validation. 77 unit tests, no AWS calls. Deployed to a test account and drove all six tools over a SigV4-signed Function URL: unsigned requests return 403, signed return 200 with an mcp-session-id; Mode A reproduced a custom-resolver divergence (10.42.200.99 via a local resolver vs 10.42.200.10 from the PHZ, with getent following the custom resolver); Mode B read the live effective config and correctly predicted a broad '.' FORWARD rule would sweep seven names. Live testing surfaced three defects since fixed: a dependency layer built for the build host's platform rather than manylinux2014_x86_64, two missing EC2 read grants for DHCP discovery, and add_resolver_rule ignoring the documented target_ips shape. test-infra/ holds CloudFormation fixtures that reproduce each diagnostic scenario end to end, including a two-account provider/consumer pair for the cross-account opacity cases. Licensed under Apache-2.0. --- mcp/aws-vpc-dns-diagnostics-mcp/.gitignore | 20 + mcp/aws-vpc-dns-diagnostics-mcp/CHANGELOG.md | 56 + mcp/aws-vpc-dns-diagnostics-mcp/LICENSE | 202 +++ mcp/aws-vpc-dns-diagnostics-mcp/README.md | 627 ++++++++++ .../docs/ARCHITECTURE.md | 286 +++++ .../docs/architecture.drawio | 262 ++++ .../layers/dependencies/Makefile | 30 + .../layers/dependencies/requirements.txt | 2 + .../scoped-roles.yaml | 217 ++++ .../src/dns_model.py | 493 ++++++++ mcp/aws-vpc-dns-diagnostics-mcp/src/run.sh | 8 + mcp/aws-vpc-dns-diagnostics-mcp/src/server.py | 1080 +++++++++++++++++ .../src/sops/A-address-family-divergence.md | 47 + .../src/sops/A-critical-safety-rules.md | 59 + .../src/sops/A-custom-resolver-divergence.md | 53 + .../A-forward-vs-phz-precedence-collision.md | 62 + .../sops/A-mode-a-live-resolver-comparison.md | 73 ++ .../sops/A-name-category-classification.md | 46 + .../sops/A-resolver-disabled-precondition.md | 52 + .../src/sops/B-broad-forward-sweep.md | 66 + .../src/sops/B-dns-firewall-block.md | 57 + .../src/sops/B-flag-and-mismatch.md | 61 + .../sops/B-mode-b-pre-change-validation.md | 87 ++ .../src/sops/B-profile-propagation-timing.md | 68 ++ .../src/sops/B-vpce-shadow-nxdomain.md | 62 + .../sops/C-cross-account-opaque-constructs.md | 71 ++ .../src/sops/C-limitations-and-boundaries.md | 48 + .../src/sops/Z-general-triage.md | 66 + .../ssm-document/dns-diagnostic-probe.yaml | 66 + mcp/aws-vpc-dns-diagnostics-mcp/template.yaml | 172 +++ .../test-infra/01-base-network.yaml | 226 ++++ .../test-infra/02-mode-a-scenarios.yaml | 129 ++ .../test-infra/03-mode-b-config.yaml | 133 ++ .../test-infra/04-mode-b-lattice.yaml | 92 ++ .../test-infra/xacct/consumer.yaml | 45 + .../test-infra/xacct/provider.yaml | 151 +++ .../tests/test_allowlist.py | 256 ++++ .../tests/test_live_regressions.py | 113 ++ .../tests/test_security_review.py | 202 +++ .../tests/test_simulate.py | 313 +++++ .../tests/test_sops.py | 89 ++ 41 files changed, 6248 insertions(+) create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/.gitignore create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/CHANGELOG.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/LICENSE create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/README.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/docs/ARCHITECTURE.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/docs/architecture.drawio create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/layers/dependencies/Makefile create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/layers/dependencies/requirements.txt create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/scoped-roles.yaml create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/dns_model.py create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/run.sh create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/server.py create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-address-family-divergence.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-critical-safety-rules.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-custom-resolver-divergence.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-forward-vs-phz-precedence-collision.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-mode-a-live-resolver-comparison.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-name-category-classification.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-resolver-disabled-precondition.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-broad-forward-sweep.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-dns-firewall-block.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-flag-and-mismatch.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-mode-b-pre-change-validation.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-profile-propagation-timing.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-vpce-shadow-nxdomain.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/C-cross-account-opaque-constructs.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/C-limitations-and-boundaries.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/src/sops/Z-general-triage.md create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/ssm-document/dns-diagnostic-probe.yaml create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/template.yaml create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/test-infra/01-base-network.yaml create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/test-infra/02-mode-a-scenarios.yaml create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/test-infra/03-mode-b-config.yaml create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/test-infra/04-mode-b-lattice.yaml create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/test-infra/xacct/consumer.yaml create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/test-infra/xacct/provider.yaml create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/tests/test_allowlist.py create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/tests/test_live_regressions.py create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/tests/test_simulate.py create mode 100644 mcp/aws-vpc-dns-diagnostics-mcp/tests/test_sops.py diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/.gitignore b/mcp/aws-vpc-dns-diagnostics-mcp/.gitignore new file mode 100644 index 0000000..a4bb3ae --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/.gitignore @@ -0,0 +1,20 @@ +# AWS SAM build artifacts +.aws-sam/ +samconfig.toml + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.venv/ +venv/ + +# OS / editor +.DS_Store +*.swp + +# Draw.io autosave/backup artifacts +.$*.drawio.bkp +*.drawio.bkp +*.drawio.dtmp diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/CHANGELOG.md b/mcp/aws-vpc-dns-diagnostics-mcp/CHANGELOG.md new file mode 100644 index 0000000..6fe45da --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable changes to the AWS VPC DNS Diagnostics MCP server are documented in +this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and +this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] — 2026-07-27 + +Initial release. + +### Added + +- **Mode A, live DNS observation.** `dns_probe_context` reports the VPC-attribute + precondition (`enableDnsSupport` / `enableDnsHostnames`), the instance's + addressing family, and the DHCP option set's configured resolvers. + `dns_probe_compare` runs a fixed read-only probe set inside the instance via SSM + and returns a per-resolver, per-family answer matrix including a + `hostname.bind` resolver-identity lookup and the OS-effective `getent` result. +- **Mode B, symbolic pre-change validation.** `dns_simulate_effective_config` + reports the VPC's effective DNS configuration as the union of directly-attached + and Route 53 Profile-inherited constructs, each source-tagged. + `dns_simulate_change` predicts which currently-resolving names a proposed change + would break, using a seven-level resolution precedence engine. +- **Six trap detectors:** `VPCE-shadow-NXDOMAIN`, `broad-FORWARD-sweep`, + `flag-AND-mismatch`, `DNS-Firewall-block`, `Profile-union-shift`, and + `resolver-disabled`. +- **16 diagnostic runbooks** served at runtime via `list_sops` and `get_sop`, + bundled into the deployment package and organized by diagnostic scenario. +- **Two-role security model.** The central Lambda's execution role holds only + `sts:AssumeRole`. Mode A assumes a probe role whose sole privileged grant is a + resource-scoped `ssm:SendCommand` to one diagnostic document; Mode B assumes a + separate read-only role that never holds `ssm:SendCommand`. +- **On-instance enforcement boundary.** A purpose-built SSM document accepts three + `allowedPattern`-validated parameters and renders a fixed read-only probe set. + The server sends structured parameters, never a command string. +- **Fail-closed guards.** Wildcard allowlists are refused when `StageName=prod`. + An empty resolver allowlist permits literal IPs only and rejects all hostnames, + so resolver comparison cannot become an arbitrary-egress primitive. +- **Cross-account opacity handling.** RAM-shared and Profile-contained constructs + whose detail reads are denied become `OPAQUE` markers rather than crashing the + model build. An opaque firewall rule is evaluated first, because a hidden block + list may cover any name. +- **DHCP option-set awareness.** The VPC-intended resolver is reported alongside + the instance-actual `resolv.conf`, and DHCP-configured resolvers are added to + the comparison set by default. +- **Dualstack support.** IPv4 and IPv6 resolver addresses and A/AAAA families are + handled per instance addressing family. +- **CloudFormation test fixtures** under `test-infra/`, including a two-account + provider/consumer pair for the cross-account opacity scenarios. +- 58 unit tests covering injection safety, the probe parameter boundary, the + resolution engine, all six trap detectors, opaque-marker handling, and runbook + catalogue integrity. + +[1.0.0]: https://github.com/aws-samples/sample-devops-agent-tools diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/LICENSE b/mcp/aws-vpc-dns-diagnostics-mcp/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/README.md b/mcp/aws-vpc-dns-diagnostics-mcp/README.md new file mode 100644 index 0000000..a6b0154 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/README.md @@ -0,0 +1,627 @@ +# AWS VPC DNS Diagnostics MCP + +> **⚠️ Proof of Concept (POC):** This project is sample code and is not intended +> for production use without additional review and testing. Validate it in a +> non-production account before using it with production workloads. + +> ⚠️ This MCP server is designed exclusively for integration with AWS DevOps +> Agent via Streamable HTTP + SigV4. It is NOT compatible with local MCP clients +> (Kiro, Cursor, VS Code) that use stdio transport. + +MCP server for AWS DevOps Agent that diagnoses VPC DNS resolution two ways no +describe API can: by observing what a name **actually** resolves to from inside a +subnet, and by predicting what a proposed DNS change **would break** before it is +applied. + +Mode A (`dns_probe_*`) runs live, comparative, multi-resolver DNS resolution +inside an EC2 instance via SSM Run Command. It returns what a name resolves to +from that subnet, which resolver answered (`hostname.bind`), and how a +custom or hybrid resolver's answer differs from the VPC resolver. + +Mode B (`dns_simulate_*`) is symbolic, read-only pre-change validation. It +predicts which currently-resolving names a proposed DNS control-plane change +would break: endpoint private DNS, a Resolver rule, a private hosted zone, DNS +Firewall, or a Route 53 Profile. + +`list_sops` and `get_sop` serve 16 diagnostic runbooks at runtime, carrying the +decision trees, precedence model, and reporting rules for interpreting results. + +> **Want to understand the internals?** See [Architecture & Design](docs/ARCHITECTURE.md) +> for the component layout, the resolution precedence engine, the trap detectors, +> and the security model. + +--- + +## Why not just call the AWS APIs? + +The EC2, VPC, and Route 53 describe APIs return DNS **configuration**. They never +return: + +- what a name actually resolves to from a given subnet right now +- which resolver answered +- the instance's in-OS resolver configuration and NSS-effective answer +- whether a resolver is reachable from that subnet at all + +Those are Mode A. Separately, no single describe call produces a precedence-aware, +Profile-union-aware impact diff of a proposed change. That is Mode B. This server +is the enforcement and composition layer over those primitives, not a thin +wrapper. + +--- + +## Prerequisites + +### 1. AWS SAM CLI + +```bash +brew install aws-sam-cli # macOS +# or: pip install aws-sam-cli +``` + +### 2. Python 3.12 + +The Lambda runtime is `python3.12`. A matching local interpreter is recommended +for running the tests. + +### 3. AWS credentials + +You need permissions to create IAM roles, Lambda functions, Lambda layers, a +Lambda Function URL, and SSM documents. + +```bash +aws configure +# or: aws sso login --profile your-profile +export AWS_PROFILE=your-profile +``` + +### 4. SSM reachability on target instances (Mode A only) + +Mode A executes inside an instance via SSM Run Command. Each target instance +needs: + +- SSM Agent running (default on Amazon Linux 2023 AMIs) +- `AmazonSSMManagedInstanceCore` (or equivalent) on its instance profile +- A private path to SSM: interface endpoints for `ssm`, `ssmmessages`, and + `ec2messages` + +All three interface endpoints are required. An EC2 Instance Connect Endpoint is +not a substitute: Run Command works by the SSM Agent polling **outbound** to +`ssmmessages` and `ec2messages`, whereas EICE is an **inbound** interactive +SSH/RDP tunnel that carries no SSM control-plane traffic. An instance with EICE +but no path to those services reports `ConnectionStatus: Not connected` in SSM, +and `SendCommand` fails. EICE is useful alongside these endpoints as a +break-glass path for a human to inspect an instance, but Mode A cannot run on it. + +The server reports unreachable SSM as a blocker rather than falling back to a +public path. + +--- + +## Deployment + +Two stacks. The central stack hosts the MCP Lambda; the scoped-roles stack is +deployed once per target account. + +### Step 1 — Deploy the central MCP Lambda + +```bash +sam build +sam deploy --guided +``` + +Note the `FunctionRoleArn` output. The scoped roles must trust it. + +| Parameter | Purpose | Default | +| --- | --- | --- | +| `StageName` | `dev`, `staging`, or `prod`. Wildcard allowlists are refused when `prod`. | `prod` | +| `AllowedAccounts` | Account IDs the tools may inspect | `*` (dev only) | +| `AllowedRegions` | Regions the tools may operate in | `*` (dev only) | +| `AllowedVpcs` | VPC IDs the tools may target | `*` (dev only) | +| `AllowedResolvers` | Extra resolver IPs/hostnames the probes may query | `*` (dev only) | +| `DiagnosticDocumentName` | The single SSM document the probe role may send | `dns-diagnostic-probe` | +| `ProbeRoleArnPattern` | Per-account Mode A role ARN pattern | `arn:aws:iam::*:role/DnsDiagnosticProbeRole` | +| `ReadOnlyRoleArnPattern` | Per-account Mode B role ARN pattern | `arn:aws:iam::*:role/DnsDiagnosticReadOnlyRole` | + +Set every `Allowed*` parameter explicitly for anything beyond local testing. With +`StageName=prod`, the server refuses to start if any allowlist is `*`. + +### Step 2 — Deploy scoped roles in each target account + +```bash +aws cloudformation deploy \ + --template-file scoped-roles.yaml \ + --stack-name dns-diagnostics-scoped-roles \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides CentralFunctionRoleArn= +``` + +This creates the read-only role, the probe role, and the diagnostic SSM document. + +The two stacks reference each other, so deploy the central stack first for its +`FunctionRoleArn`. The `*RoleArnPattern` values use stable role **names**, so they +can be set up front. + +### Step 3 — Register with DevOps Agent + +Register the `MCPEndpointUrl` output as an MCP server using **AWS SigV4** auth. +Registration is account-level: the server is registered once per AWS account and +then shared with individual Agent Spaces, which select which tools they need. + +| Setting | Value | +| --- | --- | +| Service type | `mcpserversigv4` | +| Endpoint | `MCPEndpointUrl` output from step 1 | +| Region | The region the function is deployed in | +| Service name | `lambda` | +| IAM role | A role trusting `aidevops.amazonaws.com` with `lambda:InvokeFunctionUrl` on the function URL | + +#### Option A — DevOps Agent console + +1. Open the DevOps Agent console and go to **Capability Providers**. +2. Choose **Register MCP Server**. +3. **MCP server details**: enter a name, and the `MCPEndpointUrl` output from + step 1 as the **Endpoint URL**. +4. **Authorization flow**: select **AWS SigV4**. +5. **Authorization configuration**: + - **Configure IAM role**: select an existing role, or follow the console's + instructions to create one. The role must trust + `aidevops.amazonaws.com` (see the trust policy below). + - **AWS Region**: the region the function is deployed in. + - **Service Name**: `lambda`. +6. **Review and submit.** DevOps Agent validates the connection by calling the + MCP `initialize` and `tools/list` methods against your endpoint. + +#### Option B — AWS CLI + +```bash +aws devops-agent register-service \ + --service mcpserversigv4 \ + --service-details '{ + "mcpserversigv4": { + "name": "aws-vpc-dns-diagnostics", + "endpoint": "", + "authorizationConfig": { + "region": "", + "service": "lambda", + "mcpRoleArn": "" + } + } + }' +``` + +Then associate the returned `serviceId` with your Agent Space. + +#### IAM role for SigV4 signing + +DevOps Agent assumes this role in your account to sign requests to the endpoint. +The trust policy needs confused-deputy conditions: + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { "Service": "aidevops.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { "aws:SourceAccount": "ACCOUNT_ID" }, + "ArnLike": { "aws:SourceArn": "arn:aws:aidevops:REGION:ACCOUNT_ID:service/*" } + } + }] +} +``` + +Attach only the permission needed to invoke the endpoint: + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn:aws:lambda:REGION:ACCOUNT_ID:function:aws-vpc-dns-diagnostics-mcp-STAGE", + "Condition": { "StringEquals": { "lambda:FunctionUrlAuthType": "AWS_IAM" } } + }] +} +``` + +### Step 4 — Configure tools in your Agent Space + +After registering at the account level, choose which tools each Agent Space may +use: + +1. In the DevOps Agent console, select your Agent Space. +2. Go to the **Capabilities** tab. +3. Select the registered `aws-vpc-dns-diagnostics` MCP server. +4. Choose **Select specific tools**, **not** *Allow all tools*. +5. Allowlist only the tools that Agent Space needs, then choose **Add**. + +Allowlist the minimum set for the job. Two useful groupings: + +| Use case | Allowlist | +| --- | --- | +| Pre-change validation only (no instance execution) | `list_sops`, `get_sop`, `dns_simulate_effective_config`, `dns_simulate_change` | +| Full diagnosis, including live probes | all six tools | + +The first grouping is worth preferring where it is sufficient, because it excludes +both `dns_probe_*` tools and therefore never exercises the probe role that holds +`ssm:SendCommand`. Tool-level allowlisting is enforced by the Agent Space and is +independent of this server's own allowlists. + +--- + +## Security Model + +### Deviations from the reference MCP pattern + +The common reference pattern for a DevOps Agent MCP server is a Lambda deployed +**inside a VPC** that reads credentials from **Secrets Manager** to reach a data +resource such as RDS, Redshift, or OpenSearch. This server deviates on both +points, deliberately. Both deviations reduce the amount of sensitive material and +network surface involved, rather than working around a constraint. + +**No Secrets Manager, because there are no credentials.** The reference pattern +needs Secrets Manager because it holds a database username and password. This +server authenticates entirely with IAM: the central Lambda's execution role holds +only `sts:AssumeRole`, and it assumes one of two scoped roles per call. There is +no secret to store, rotate, retrieve, or leak into a tool response. The +credential path the requirement protects does not exist here. Adding Secrets +Manager would mean inventing a secret in order to store one. + +**Lambda runs outside a VPC, because there is no in-VPC data resource.** The +reference pattern attaches the Lambda to a VPC to reach a private database +endpoint. This server talks only to AWS control-plane APIs (SSM, EC2, Route 53, +Route 53 Resolver, Route 53 Profiles, VPC Lattice) and never opens a connection to +a customer data resource. The private-path requirement applies to the **target +instances**, not the Lambda: Mode A reaches them through SSM, which requires +`ssm`, `ssmmessages`, and `ec2messages` interface endpoints or an EC2 Instance +Connect Endpoint in the target VPC, and the server reports unreachable SSM as a +blocker rather than falling back to a public path. + +Putting the Lambda in a VPC would add NAT or interface endpoints purely so it +could keep reaching the same public AWS API endpoints, with no reduction in what +it can touch. If your environment requires the MCP endpoint itself to be +privately reachable, that is a separate concern addressed by DevOps Agent's +private connection feature rather than by the function's own VPC attachment. + +### Read-only by construction + +Mode A executes on an instance, so the boundary that matters is what it is *able* +to execute. This server does not send a command string. It sends three structured +parameters to one purpose-built SSM document that renders a fixed probe set: + +| Parameter | Constraint | +| --- | --- | +| `Name` | `allowedPattern` limits it to DNS labels (`[A-Za-z0-9_-]`, dot-separated), max 253 chars | +| `Resolver` | `allowedPattern` limits it to `[A-Za-z0-9_.:-]`, max 253 chars | +| `Family` | `allowedValues: [A, AAAA]` | + +The probe set is `cat /etc/resolv.conf`, `resolvectl status`, three `dig` +queries, and `getent hosts`. Every command reads. None writes, installs, captures +traffic, or produces an artifact. The set is fixed in the document and cannot be +extended by a caller. + +Three independent layers enforce this: + +1. **Server-side validators** reject shell metacharacters, require `Resolver` to + be a literal IP or an explicitly allowlisted hostname, and constrain `Family` + to an enum. +2. **Document `allowedPattern`** re-validates every parameter inside SSM, + independently of the server. A caller reaching SSM directly still cannot pass + a value containing a quote, semicolon, backtick, pipe, space, or newline. +3. **IAM** grants the probe role exactly one privileged action: + `ssm:SendCommand`, resource-scoped to this one document ARN. It cannot send + `AWS-RunShellScript` or any other document. + +Because the reachable command set is fixed, read-only, and produces no artifact, +Mode A does not gate on human approval. The `Resolver` allowlist is deliberately +fail-closed: with `ALLOWED_RESOLVERS` unset, only literal IPs are accepted and +every hostname is rejected, so the comparison feature cannot be turned into an +arbitrary-egress primitive via an unvetted hostname. + +Mode B holds no `ssm:SendCommand` grant at all. It runs on a separate read-only +role, so a simulation call can never ride on credentials capable of executing +anything. + +### Safety — what this server can and cannot do + +**On a target instance (Mode A), the reachable command set is fixed:** + +- ✅ `cat /etc/resolv.conf`: the instance's configured resolvers +- ✅ `resolvectl status`: systemd-resolved state, when present +- ✅ `dig @`: the answer, short form +- ✅ `dig @ +stats`: full response with flags and timing +- ✅ `dig hostname.bind CH TXT @`: which resolver actually answered +- ✅ `getent hosts `: the OS-effective answer through NSS + +- ❌ No arbitrary or caller-supplied commands: the server sends parameters, never a command string +- ❌ No writes, installs, package operations, or service restarts +- ❌ No packet capture, no file uploads, no artifacts produced +- ❌ No reads outside the four commands above (no arbitrary file reads) +- ❌ No other SSM document: `ssm:SendCommand` is resource-scoped to one document ARN +- ❌ No `AWS-RunShellScript` + +**In the AWS control plane, both tool families are read-only:** + +- ✅ `Describe*` / `Get*` / `List*` on EC2, Route 53, Route 53 Resolver, Route 53 Profiles, VPC Lattice +- ❌ No CloudWatch Logs grant: query-log volume enrichment is designed but not implemented, so the permission is deliberately absent +- ❌ No mutating API of any kind: no create, modify, associate, or delete +- ❌ Mode B holds no `ssm:SendCommand` grant at all +- ❌ No account, region, VPC, or resolver outside the configured allowlists +- ❌ No startup at all when `StageName=prod` and any allowlist is a wildcard + +**Inputs are constrained before they reach anything:** + +- ✅ `Name`: DNS labels only (`[A-Za-z0-9_-]`, dot-separated), max 253 chars +- ✅ `Resolver`: a literal IPv4/IPv6 address, or a hostname explicitly listed in `ALLOWED_RESOLVERS` +- ✅ `Family`: `A` or `AAAA` only +- ✅ `slug` (for `get_sop`): must match an in-code catalogue entry +- ❌ No shell metacharacters: rejected by the server, then again by the document's `allowedPattern` +- ❌ No hostname resolvers when `ALLOWED_RESOLVERS` is unset (fail-closed; literal IPs only) +- ❌ No path traversal in `get_sop`: allowlist lookup, plus a `realpath` containment check + +### Never use a wildcard resolver allowlist + +`ALLOWED_RESOLVERS` is the one allowlist whose wildcard widens the blast radius +beyond read-only. With it set to `*`, `dns_probe_compare` accepts any literal +resolver IP and queries it from the target instance. That is a caller-directed +outbound DNS query, and the only outbound path in this server a caller can point +somewhere new. The queried name is DNS-charset only and capped at 253 characters, so the +channel is narrow, but it is real. + +`StageName=prod` refuses to start on a wildcard. That is not sufficient on its +own: **set an explicit `ALLOWED_RESOLVERS` list in any deployment reachable by +DevOps Agent, whatever the stage.** The server logs a warning at startup when the +wildcard is active. + +Hostnames are refused unless explicitly listed, in every stage. That is +deliberate. It prevents the resolver-comparison feature becoming an +arbitrary-egress primitive via an unvetted hostname. + +### Data trust boundary — probe output is untrusted + +Mode A returns instance output verbatim: `/etc/resolv.conf`, `resolvectl status`, +`dig` responses, and `getent` results. Any of it can be attacker-influenced. A DNS +TXT record or a poisoned `resolv.conf` comment can carry text shaped like +instructions to an agent. + +This server does not interpret, execute, or act on that content. It passes it +through as data, and holds no tool that could act on an injected instruction: every +tool is read-only, and with an explicit resolver allowlist there is no +caller-directable outbound channel. A consuming agent should nonetheless treat +probe output as untrusted input rather than as trustworthy diagnostic narration. + +### Controls + +| Control | Default | +| --- | --- | +| Authentication | SigV4 (`AuthType: AWS_IAM`) on the Function URL — always on | +| Central Lambda role permissions | `sts:AssumeRole` only; holds no diagnostic permissions | +| Credential scoping | Per tool family; Mode B's role never holds `ssm:SendCommand` | +| Probe execution surface | One SSM document, fixed read-only probe set | +| `ssm:SendCommand` scope | Resource-scoped to that one document ARN | +| Account / region / VPC allowlists | Enforced in one place, before any AWS call | +| Resolver allowlist | Fail-closed: literal IPs only unless a hostname is listed | +| Production enforcement | Wildcard allowlists refused at startup when `StageName=prod` | +| SSM path | `ssm` + `ssmmessages` + `ec2messages` interface endpoints required; no public-path fallback | +| Cross-account reads | Denials become `OPAQUE` markers, never a crash or a false negative | + +### Cross-account behavior + +Constructs shared via AWS RAM or contributed through a Route 53 Profile may be +enumerable but opaque to a consumer account. Shared DNS Firewall domain lists, +profile-contained resolver rules, and profile-contained private hosted zones all +deny their detail reads cross-account. The server models these as `OPAQUE` rather +than crashing or silently reporting the name as unaffected, and an opaque firewall +rule is evaluated first because a hidden block list may cover any name. + +The practical consequence is reported honestly: when a construct is opaque, the +answer is "cannot determine from this account," not "not affected." + +--- + +## Tools + +| Tool | Mode | Purpose | +| --- | --- | --- | +| `list_sops` | — | Catalogue of the 16 diagnostic runbooks with one-line purposes | +| `get_sop` | — | Full text of one runbook by slug | +| `dns_probe_context` | A | VPC-attribute precondition, instance addressing family, DHCP-configured resolvers | +| `dns_probe_compare` | A | Per-resolver, per-family answer matrix from inside the instance | +| `dns_simulate_effective_config` | B | The VPC's effective config (direct + Profile-inherited), source-tagged | +| `dns_simulate_change` | B | Predicted per-name impact of a proposed change, with traps and severity | + +### Runbooks + +The server ships its own interpretation guidance rather than relying on preloaded +instructions. Slug prefixes: `Z` start-here triage, `A` live diagnosis and safety +rules, `B` pre-change validation, `C` cross-cutting concerns. Call `list_sops` +for the catalogue, or `get_sop("Z-general-triage")` for a vague symptom. + +### Agent workflow + +``` +list_sops → get_sop(Z-general-triage) → dns_probe_context → dns_probe_compare + ↘ dns_simulate_effective_config → dns_simulate_change +``` + +--- + +## Usage Examples + +### Live resolution divergence + +``` +An app on i-0abc123def in us-east-1 is connecting to the wrong database host. +DNS looks correct in the console. Find out what the instance actually resolves. +``` + +### Pre-change validation + +``` +We want to enable private DNS on the Secrets Manager endpoint in vpc-0abc123. +What would that break? +``` + +### Hybrid DNS troubleshooting + +``` +After adding a '.' forwarding rule to our on-prem resolver, some AWS service +endpoints stopped resolving in vpc-0abc123. Figure out which ones and why. +``` + +--- + +## Structure + +``` +aws-vpc-dns-diagnostics-mcp/ +├── README.md +├── LICENSE # Apache-2.0 +├── template.yaml # SAM: central MCP Lambda (assume-role only) +├── scoped-roles.yaml # Per target account: both scoped roles + SSM document +├── docs/ +│ └── ARCHITECTURE.md +├── ssm-document/ +│ └── dns-diagnostic-probe.yaml # Standalone copy of the on-instance probe document +├── src/ +│ ├── server.py # FastMCP server: all six tools +│ ├── dns_model.py # Mode B: effective model + resolution engine + trap detectors +│ ├── run.sh # Lambda Web Adapter entry point +│ └── sops/ # 16 runbooks, bundled into the deployment package +├── layers/dependencies/ # fastmcp, boto3 +├── tests/ +│ ├── test_allowlist.py # Injection safety, probe boundary, DHCP read, allowlists +│ ├── test_simulate.py # Resolution engine, trap detectors, severity ranking +│ └── test_sops.py # Runbook catalogue integrity and path-traversal safety +└── test-infra/ # CFN fixtures for reproducing diagnostic scenarios +``` + +--- + +## Test + +```bash +uv run --with fastmcp --with boto3 --with pytest pytest tests/ -q +``` + +77 tests: injection safety and the probe parameter boundary, the Mode B +resolution engine and all six trap detectors, cross-account opaque-marker +handling, runbook catalogue integrity, regressions found in live validation, and +guards asserting the IAM grants cannot silently re-widen. + +--- + +## Local Testing + +Run the server locally to verify the MCP handshake and tool surface before +deploying. FastMCP serves Streamable HTTP on port 8000 at `/mcp`, the same path +Lambda Web Adapter targets for its readiness check. + +```bash +cd src +ALLOWED_ACCOUNTS=111122223333 ALLOWED_REGIONS=us-east-1 STAGE_NAME=dev \ + uv run --with fastmcp --with boto3 python server.py +``` + +In another shell, initialize a session and capture the session ID: + +```bash +SID=$(curl -s -D - -X POST http://127.0.0.1:8000/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-06-18","capabilities":{}, + "clientInfo":{"name":"local","version":"1.0"}}}' \ + | grep -i '^mcp-session-id:' | tr -d '\r' | awk '{print $2}') +echo "$SID" +``` + +A returned `mcp-session-id` confirms Streamable HTTP. Complete the handshake, then +list the tools: + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H "mcp-session-id: $SID" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' + +curl -s -X POST http://127.0.0.1:8000/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H "mcp-session-id: $SID" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' +``` + +All six tools should appear. Fetch a runbook to confirm the bundled SOPs are +readable without any AWS access: + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H "mcp-session-id: $SID" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{ + "name":"get_sop","arguments":{"slug":"Z-general-triage"}}}' +``` + +The `dns_probe_*` and `dns_simulate_*` tools need real AWS credentials and a +deployed scoped role, so they are best exercised after deployment. + +### Testing the deployed endpoint + +The Function URL requires SigV4, so plain `curl` returns 403. Sign the request: + +```bash +uv run --with boto3 --with requests --with requests-auth-aws-sigv4 python - <<'PY' +import json, requests +from requests_auth_aws_sigv4 import AWSSigV4 +url = "" # from the SAM output; append /mcp if not present +r = requests.post(url, + auth=AWSSigV4('lambda', region='us-east-1'), + headers={'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream'}, + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "verify", "version": "1.0"}}}), + timeout=60) +print(r.status_code, r.headers.get('mcp-session-id')) +PY +``` + +An unsigned request returning 403 and a signed request returning 200 with an +`mcp-session-id` together confirm the endpoint is correctly gated and speaking +Streamable HTTP. + +--- + +## Test Infrastructure + +`test-infra/` holds CloudFormation fixtures that reproduce the diagnostic +scenarios end to end, including a custom split-horizon resolver, an interface +endpoint with private DNS, a private hosted zone, a Resolver outbound endpoint +with FORWARD and SYSTEM rules, a DNS Firewall rule group, and a two-account +provider/consumer pair for the cross-account opacity cases. + +These fixtures create billable resources. A Resolver outbound endpoint is the +main cost driver. Tear down in reverse order (`03`, then `02`, then `01`) when +finished. + +--- + +## Cleanup + +```bash +sam delete # central stack +aws cloudformation delete-stack --stack-name dns-diagnostics-scoped-roles # per target account +``` + +Deregister the MCP server from your DevOps Agent space as well. + +--- + +## License + +This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) +file. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/docs/ARCHITECTURE.md b/mcp/aws-vpc-dns-diagnostics-mcp/docs/ARCHITECTURE.md new file mode 100644 index 0000000..25b49bf --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/docs/ARCHITECTURE.md @@ -0,0 +1,286 @@ +# Architecture & Design + +How the AWS VPC DNS Diagnostics MCP server is put together, why it is one server +rather than two, how the resolution engine works, and where the enforcement +boundaries sit. + +--- + +## 1. System layout + +``` +DevOps Agent + │ MCP over Streamable HTTP, SigV4-signed + ▼ +Lambda Function URL (AuthType: AWS_IAM) + │ + ▼ +Lambda: FastMCP server behind Lambda Web Adapter + │ execution role holds sts:AssumeRole ONLY + │ + ├── assume DnsDiagnosticReadOnlyRole ──► Route 53 / Resolver / Profiles / + │ (Mode B, per target account) Lattice / EC2 describe + list + │ + └── assume DnsDiagnosticProbeRole ─────► ssm:SendCommand, scoped to ONE + (Mode A, per target account) diagnostic document + │ + ▼ + Target EC2 instance + fixed read-only probe set +``` + +The central Lambda holds no diagnostic permissions of its own. Every AWS call is +made with credentials from one of two roles deployed in the target account, chosen +by which tool family was invoked. + +### Why Lambda Web Adapter and a Function URL + +DevOps Agent requires MCP over the Streamable HTTP transport. FastMCP serves that +natively, and Lambda Web Adapter lets the same ASGI app run unmodified in Lambda. +The Function URL provides the HTTPS endpoint with `AuthType: AWS_IAM`, which maps +onto DevOps Agent's documented SigV4 auth path: signing service `lambda`, action +`lambda:InvokeFunctionUrl`. No API Gateway or gateway front end is required. + +`AWS_LWA_INVOKE_MODE: response_stream` is set so streaming responses pass through +correctly, and the readiness check targets `/mcp`. + +--- + +## 2. One server, three tool families + +| Family | Tools | Credentials | Nature | +| --- | --- | --- | --- | +| Runbooks | `list_sops`, `get_sop` | none | Local filesystem read | +| Mode A | `dns_probe_context`, `dns_probe_compare` | probe role | Live, observed | +| Mode B | `dns_simulate_effective_config`, `dns_simulate_change` | read-only role | Symbolic, predicted | + +The two diagnostic modes share one server because they answer two halves of the +same question and are most useful in sequence: Mode B predicts what a change will +do, Mode A confirms what actually happened. Splitting them across two servers +would force an operator to register two endpoints and would separate the +precedence model from the ground truth that validates it. + +They are nonetheless isolated where it matters. Each family assumes a different +role, so a read-only simulation never executes on credentials capable of running +a command. + +### Runbooks as a tool, not a skill file + +Interpretation guidance ships inside the server as 16 markdown runbooks under +`src/sops/`, retrieved at runtime through `list_sops` and `get_sop`. The agent +asks for the procedure it needs instead of carrying all of it in context. + +The runbooks are bundled into the deployment package and read from the Lambda +filesystem. There is no S3 bucket and no additional IAM grant. `get_sop` +validates the requested slug against an in-code catalogue, so no filename is ever +constructed from unvalidated caller input; a `realpath` containment check backs +that up, and a test asserts the catalogue and the directory match in both +directions. + +They are organized by the shape of the operator's problem rather than by tool: +`Z` for start-here triage, `A` for live diagnosis and safety rules, `B` for +pre-change validation, `C` for cross-cutting concerns. Runbooks cross-reference +one another, so a symptom leads to a mechanism and then to any interacting trap. + +--- + +## 3. Mode A — live observation + +### The gap it fills + +Describe APIs return configuration. They cannot report what a name resolves to +from a subnet, which resolver answered, what the instance's `resolv.conf` says, or +what the NSS-effective answer is. A DHCP option set states which resolver the VPC +*hands out*; it cannot state which resolver the instance is *using*. An instance +pointing at a local stub, a domain controller, or an on-premises forwarder looks +identical from the control plane. + +### Flow + +1. `dns_probe_context` reads the VPC attributes (`enableDnsSupport`, + `enableDnsHostnames`), the instance's addressing family, and the DHCP option + set's `domain-name-servers`, classifying `AmazonProvidedDNS` versus a custom + resolver. This is the precondition block: if DNS support is off, no resolution + diagnosis is meaningful. +2. `dns_probe_compare` sends the probe for each (name, resolver, family) + combination and assembles an answer matrix. With `include_dhcp_dns=true` + (default) the DHCP-configured resolvers are added automatically, expanding + `AmazonProvidedDNS` to the resolver address appropriate for the instance's + stack. +3. Each probe returns `resolv.conf`, `resolvectl status`, a short-form `dig` + answer, `dig +stats`, a `hostname.bind` CH TXT identity lookup, and + `getent hosts`. + +The `hostname.bind` lookup identifies which resolver actually answered, which +distinguishes "the VPC resolver answered" from "a local stub answered and returned +the same value." `getent` captures the OS-effective result, which can differ from +every individual `dig` because it follows `resolv.conf` order, `nsswitch.conf`, +and `/etc/hosts`. + +### On-instance enforcement boundary + +The SSM document, not the server, is the boundary. It accepts three +`allowedPattern`-validated parameters and renders a fixed read-only probe set; the +server sends structured parameters rather than a command string. Server-side +validators are layer 1, the document's `allowedPattern` is an independent layer 2, +and a resource-scoped `ssm:SendCommand` to that single document ARN is layer 3. +The two tool families use separate assumed roles, so Mode B never holds a grant +capable of execution. + +The document also declares a `Linux` platform precondition and a 60-second step +timeout, and deliberately omits `set -e` so that an informative non-zero result +(NXDOMAIN, SERVFAIL) does not abort the remaining probes. + +--- + +## 4. Mode B — symbolic prediction + +### Effective model + +`_build_effective_model` assembles an `EffectiveModel` from the union of +directly-attached constructs and those inherited through an associated Route 53 +Profile. Every construct is tagged with its source (`direct` or `profile:`), +because a Profile-sourced construct can be changed by the Profile's owner outside +this account's control. + +Constructs modeled: resolver rules (FORWARD and SYSTEM), private hosted zone +associations, DNS Firewall rule groups and their domain lists, interface endpoint +private DNS shadows, and VPC Lattice service network associations with their +`privateDnsEnabled` and `PrivateDnsPreference` flags. + +### Resolution precedence + +`resolve()` walks seven levels in order and returns the winning construct, its +source, and an answer class: + +1. DNS Firewall (BLOCK / OVERRIDE — applied before resolution completes) +2. Specific FORWARD rule +3. SYSTEM rule +4. Interface endpoint private DNS +5. Associated private hosted zone +6. Service network VPC association `PrivateDnsPreference` gate, AND-ed with + `privateDnsEnabled` +7. VPC resolver recursion (default) + +Level 2 sitting above level 5 is why a FORWARD rule and a private hosted zone +claiming the same domain resolves in favor of the forward. That collision +presents as a timeout rather than an NXDOMAIN and is documented in +`A-forward-vs-phz-precedence-collision`. + +### Trap detectors + +`detect_traps()` runs six detectors that name the *mechanism* of a predicted +breakage rather than only the affected names: + +| Detector | Mechanism | +| --- | --- | +| `VPCE-shadow-NXDOMAIN` | Endpoint private DNS shadows a service apex still queried the old way | +| `broad-FORWARD-sweep` | A `.` or broad-suffix FORWARD rule captures AWS FQDNs and PHZ names with no SYSTEM carve-out | +| `flag-AND-mismatch` | `privateDnsEnabled` and `PrivateDnsPreference` combine to leave a custom domain uninstalled | +| `DNS-Firewall-block` | A rule-group change blocks a candidate name | +| `Profile-union-shift` | An association change shifts the effective set in bulk | +| `resolver-disabled` | A DHCP change turns the VPC resolver dark | + +### Simulation and ranking + +`simulate()` builds the current model, applies the change symbolically via +`apply_change()`, builds the post-change model, and diffs resolution per candidate +name. Any triggered trap escalates severity to high; ties break on query volume. +Profile-sourced deltas are annotated with the propagation window (roughly 300–350 +seconds service-side, up to about 900 seconds in the negative-cache worst case). + +Candidate names default to those derived from configuration. Volume ranking is an +optional enrichment: the caller may pass a `volumes` map (name to query count) and +the report is weighted by it, but the server does **not** read Resolver query logs +itself. Reading them directly is designed but not implemented, and the read-only +role holds no CloudWatch Logs grant, because the permission is withheld until the +code that would use it exists. Ranking is never required for correctness. Coverage +equals the candidate set, so a name absent from the report is not thereby proven +safe, and the runbooks require stating that limit. + +--- + +## 5. Cross-account model + +A consumer account can enumerate shared DNS constructs without being able to read +inside them. Measured behavior: + +| Construct | Consumer-side visibility | +| --- | --- | +| Directly associated private hosted zone | fully readable | +| RAM-shared resolver rule | fully readable | +| RAM-shared DNS Firewall rule group | association and rules visible; domain lists **denied** | +| Profile-contained resolver rule | enumerable; `get_resolver_rule` **denied** | +| Profile-contained private hosted zone | enumerable; `get_hosted_zone` **denied** | + +No Route 53 Profiles API action exposes a profile's contents to a consumer. +"Enumerable but opaque" is the complete and correct model. + +Every per-resource detail read in `_build_effective_model` is therefore wrapped so +a denial yields an `OPAQUE` marker rather than failing the whole model build. The +engine applies two rules: an opaque **firewall** rule is treated as OPAQUE first, +because a hidden block list may cover any name; an opaque **resolver** rule is +OPAQUE only when no concrete rule matched. + +This shapes reporting. When a name resolves to OPAQUE the correct statement is +"cannot determine from this account," never "not affected." Where ground truth is +required, Mode A observes the *result* of an opaque construct even when its +configuration cannot be read. + +Two implementation notes worth preserving: consumer-side Lattice shadows are +derived from the consumer's own endpoint records rather than by enumerating +provider-side resource configurations, which is denied by design; and the +read-only role's Route 53, Resolver, Profiles, and Lattice grants carry **no** +`aws:ResourceAccount` condition, because those services do not populate that key +and the condition would evaluate false and silently deny. The EC2 grants do +support it and keep the account guard. The Lattice grants name their two APIs +explicitly rather than using `List*`/`Get*`, so a future API carrying one of those +prefixes is not picked up implicitly. + +--- + +## 6. Fail-closed design + +| Guard | Behavior | +| --- | --- | +| Wildcard allowlists under `STAGE_NAME=prod` | Refused at startup | +| Resolver hostnames with an empty `ALLOWED_RESOLVERS` | All hostnames rejected; literal IPs only | +| Account / region / VPC outside the allowlist | Rejected before any AWS call | +| SSM unreachable | Reported as a blocker; no public-path fallback | +| `enableDnsSupport=false` | Reported as the precondition; resolution diagnosis is not attempted | +| Cross-account detail read denied | `OPAQUE` marker, not a crash and not a false negative | + +The resolver allowlist inverts the usual convention deliberately. Elsewhere an +empty allowlist means allow-all; for resolvers it means literal IPs only, so the +comparison feature cannot become an arbitrary-egress primitive through an unvetted +hostname. + +--- + +## 7. Testing + +77 tests, no AWS calls. Cross-account denial paths are exercised with fake +sessions that raise the real botocore exceptions. + +| Suite | Coverage | +| --- | --- | +| `test_allowlist.py` | Injection safety, structured-parameter probe boundary, DHCP read and classification, allowlist enforcement, opaque-marker handling under denial | +| `test_simulate.py` | Resolution engine across all seven precedence levels, all six trap detectors, severity ranking | +| `test_sops.py` | Runbook catalogue and directory consistency in both directions, retrieval, unknown-slug rejection, path-traversal refusal | +| `test_live_regressions.py` | Defects found only in live AWS validation: the `target_ips` change shape, FORWARD target rendering, and the EC2 reads the probe role needs for DHCP discovery | +| `test_security_review.py` | Guards from the MCP security review: no CloudWatch Logs grants, no VPC Lattice wildcards, granted Lattice APIs match the code, probe-role SSM grants stay within an allowlist, resolver wildcard warns while still refusing hostnames | + +CloudFormation fixtures that reproduce these scenarios against live AWS live in +`test-infra/`, including a two-account provider/consumer pair for the +cross-account opacity cases. See "Test Infrastructure" in the README for what +each stack creates and the teardown order. + +### Known verification gaps + +- `enableDnsSupport=false` is covered by unit test but not demonstrated live: + disabling it is VPC-wide and would sever SSM to every instance in the VPC, + including the one needed to observe the effect. +- Link-local resolver reachability varies by instance resolver path. In testing, + direct queries to the link-local address timed out from instances where the + VPC+2 address answered reliably. Probe both before concluding the VPC resolver + is down. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/docs/architecture.drawio b/mcp/aws-vpc-dns-diagnostics-mcp/docs/architecture.drawio new file mode 100644 index 0000000..063d9f8 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/docs/architecture.drawio @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/layers/dependencies/Makefile b/mcp/aws-vpc-dns-diagnostics-mcp/layers/dependencies/Makefile new file mode 100644 index 0000000..3b4af95 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/layers/dependencies/Makefile @@ -0,0 +1,30 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Dependencies layer build. +# +# fastmcp pulls in pydantic, whose pydantic_core is a COMPILED extension. A +# plain `pip install` on a build host installs a wheel for the HOST platform, +# so building on macOS (or arm64 Linux) yields a binary the x86_64 Lambda +# runtime cannot import, and the function fails at init with: +# ModuleNotFoundError: No module named 'pydantic_core._pydantic_core' +# +# Pin the target platform and Python version so the correct manylinux wheels are +# fetched regardless of the build host. --only-binary=:all: is required with +# --platform and makes a missing wheel a loud failure rather than a source build +# that silently targets the host. +# +# Keep these values in sync with the function's Runtime and Architectures in +# template.yaml (python3.12 / x86_64). + +.PHONY: build-DependenciesLayer +build-DependenciesLayer: + mkdir -p "$(ARTIFACTS_DIR)/python" + python3 -m pip install \ + -r requirements.txt \ + --platform manylinux2014_x86_64 \ + --python-version 3.12 \ + --implementation cp \ + --only-binary=:all: \ + --upgrade \ + -t "$(ARTIFACTS_DIR)/python" diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/layers/dependencies/requirements.txt b/mcp/aws-vpc-dns-diagnostics-mcp/layers/dependencies/requirements.txt new file mode 100644 index 0000000..23605e6 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/layers/dependencies/requirements.txt @@ -0,0 +1,2 @@ +fastmcp>=2.0.0,<4.0.0 +boto3>=1.34.0 diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/scoped-roles.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/scoped-roles.yaml new file mode 100644 index 0000000..f8b5e61 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/scoped-roles.yaml @@ -0,0 +1,217 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +AWSTemplateFormatVersion: '2010-09-09' +Description: > + Per-account scoped roles for the DNS Diagnostic MCP Server. Deploy this stack + in EACH account the MCP server is allowed to inspect. It creates two roles the + central MCP Lambda assumes per tool family, preserving least privilege: + + * DnsDiagnosticReadOnlyRole - Mode B (dns_simulate_*). Read-only control + plane + optional query-log read. NEVER holds ssm:SendCommand. + * DnsDiagnosticProbeRole - Mode A (dns_probe_*). Its ONLY privileged + grant is a resource-scoped ssm:SendCommand to a single diagnostic SSM + document, plus the reads needed to locate/target an instance. + +Parameters: + CentralFunctionRoleArn: + Type: String + Description: > + ARN of the central MCP Lambda's execution role that is permitted to + assume these roles (the trust principal). + + DiagnosticDocumentName: + Type: String + Default: dns-diagnostic-probe + Description: Name of the SSM document the probe role may send. + +Resources: + # ------------------------------------------------------------ + # Diagnostic SSM document (the on-instance enforcement boundary) + # ------------------------------------------------------------ + # Accepts only three pattern-validated parameters (Name/Resolver/Family) and + # renders a FIXED read-only probe set. No free command string is accepted, so + # nothing beyond the predefined probes can run on the instance. + DnsDiagnosticProbeDocument: + Type: AWS::SSM::Document + Properties: + Name: !Ref DiagnosticDocumentName + DocumentType: Command + DocumentFormat: YAML + Content: + schemaVersion: '2.2' + description: >- + DNS diagnostic probe runner (Mode A). Runs a fixed, read-only DNS + probe set for a single (Name, Resolver, Family) triple. + parameters: + Name: + type: String + description: DNS name to resolve. Strict DNS charset only. + allowedPattern: '^([A-Za-z0-9_-]{1,63}\.)*[A-Za-z0-9_-]{1,63}\.?$' + maxChars: 253 + Resolver: + type: String + description: Resolver IP (v4/v6) or hostname. No shell metacharacters. + allowedPattern: '^[A-Za-z0-9_.:-]{1,253}$' + maxChars: 253 + Family: + type: String + description: DNS record family. + allowedValues: [A, AAAA] + mainSteps: + - action: aws:runShellScript + name: dnsProbe + precondition: + StringEquals: [platformType, Linux] + inputs: + timeoutSeconds: '60' + runCommand: + - '#!/bin/bash' + - 'set -u' + - 'NAME="{{ Name }}"' + - 'RESOLVER="{{ Resolver }}"' + - 'FAMILY="{{ Family }}"' + - 'echo "=== resolv.conf ==="' + - 'cat /etc/resolv.conf 2>/dev/null || echo "(no /etc/resolv.conf)"' + - 'echo "=== resolvectl ==="' + - 'if command -v resolvectl >/dev/null 2>&1; then resolvectl status 2>/dev/null || true; else echo "(resolvectl not present)"; fi' + - 'echo "=== dig answer ==="' + - 'dig +short "$NAME" "$FAMILY" @"$RESOLVER" || true' + - 'echo "=== dig stats ==="' + - 'dig "$NAME" "$FAMILY" @"$RESOLVER" +stats || true' + - 'echo "=== resolver identity (hostname.bind) ==="' + - 'dig hostname.bind CH TXT @"$RESOLVER" +short || true' + - 'echo "=== getent hosts ==="' + - 'getent hosts "$NAME" || echo "(getent: no match)"' + + # ------------------------------------------------------------ + # Mode B - read-only control-plane role (dns_simulate_*) + # ------------------------------------------------------------ + DnsDiagnosticReadOnlyRole: + Type: AWS::IAM::Role + Properties: + RoleName: DnsDiagnosticReadOnlyRole + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + AWS: !Ref CentralFunctionRoleArn + Action: sts:AssumeRole + Policies: + - PolicyName: dns-simulate-readonly + PolicyDocument: + Version: '2012-10-17' + Statement: + # Route 53 / Resolver / Profiles do NOT populate aws:ResourceAccount + # in the request context, so guarding these with that condition key + # evaluates false and silently denies. This role is already + # single-account (deployed per target account, trusted only by the + # central role), so no condition is needed here. + - Effect: Allow + Action: + # Route 53 Resolver: rules, endpoints, DNS Firewall, associations + - route53resolver:List* + - route53resolver:Get* + # Route 53 Profiles: inherited resource set + - route53profiles:List* + - route53profiles:Get* + # Hosted zones + record sets (candidate-name source) + - route53:List* + - route53:Get* + # SNVA / SNRA config. Named explicitly rather than + # vpc-lattice:List*/Get*: a wildcard would silently pick up any + # future API that happens to carry a List/Get prefix. These are + # the only two the server calls. + - vpc-lattice:ListServiceNetworkVpcAssociations + - vpc-lattice:GetResourceConfiguration + Resource: '*' + # EC2 supports aws:ResourceAccount; keep the account guard on these. + - Effect: Allow + Action: + # VPCEs, DHCP option sets, VPC attributes + - ec2:DescribeVpcs + - ec2:DescribeVpcAttribute + - ec2:DescribeVpcEndpoints + - ec2:DescribeDhcpOptions + - ec2:DescribeSubnets + - ec2:DescribeNetworkInterfaces + Resource: '*' + Condition: + StringEquals: + 'aws:ResourceAccount': !Ref 'AWS::AccountId' + # NOTE: no CloudWatch Logs grants. Resolver query-log enrichment for + # candidate-name volume ranking is designed but NOT implemented, so + # logs:StartQuery / GetQueryResults / DescribeLogGroups are + # deliberately absent rather than granted ahead of the code that + # would use them. Add them in the same change that adds the calls. + + # ------------------------------------------------------------ + # Mode A - probe role (dns_probe_*) + # ------------------------------------------------------------ + DnsDiagnosticProbeRole: + Type: AWS::IAM::Role + Properties: + RoleName: DnsDiagnosticProbeRole + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + AWS: !Ref CentralFunctionRoleArn + Action: sts:AssumeRole + Policies: + - PolicyName: dns-probe-ssm + PolicyDocument: + Version: '2012-10-17' + Statement: + # The ONLY privileged grant: send exactly one diagnostic document. + - Effect: Allow + Action: ssm:SendCommand + Resource: + - !Sub arn:aws:ssm:*:${AWS::AccountId}:document/${DiagnosticDocumentName} + # SendCommand also needs the instance resource; scope to this account. + - Effect: Allow + Action: ssm:SendCommand + Resource: + - !Sub arn:aws:ec2:*:${AWS::AccountId}:instance/* + Condition: + StringEquals: + 'aws:ResourceAccount': !Ref 'AWS::AccountId' + # Read command results and confirm SSM connectivity (no execution). + - Effect: Allow + Action: + - ssm:GetCommandInvocation + - ssm:ListCommandInvocations + - ssm:DescribeInstanceInformation + Resource: '*' + Condition: + StringEquals: + 'aws:ResourceAccount': !Ref 'AWS::AccountId' + # Reads needed to locate/target an instance and interpret results. + # DescribeVpcs + DescribeDhcpOptions back dns_probe_context's DHCP + # option-set discovery (the VPC-INTENDED resolver), which + # dns_probe_compare also uses to auto-populate the comparison set. + - Effect: Allow + Action: + - ec2:DescribeInstances + - ec2:DescribeVpcAttribute + - ec2:DescribeVpcs + - ec2:DescribeDhcpOptions + - ec2:DescribeNetworkInterfaces + - ec2:DescribeSubnets + Resource: '*' + Condition: + StringEquals: + 'aws:ResourceAccount': !Ref 'AWS::AccountId' + +Outputs: + DiagnosticDocumentName: + Description: Name of the diagnostic SSM document the probe role may send + Value: !Ref DnsDiagnosticProbeDocument + ReadOnlyRoleArn: + Description: Assume this for Mode B (dns_simulate_*) + Value: !GetAtt DnsDiagnosticReadOnlyRole.Arn + ProbeRoleArn: + Description: Assume this for Mode A (dns_probe_*) + Value: !GetAtt DnsDiagnosticProbeRole.Arn diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/dns_model.py b/mcp/aws-vpc-dns-diagnostics-mcp/src/dns_model.py new file mode 100644 index 0000000..0476297 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/dns_model.py @@ -0,0 +1,493 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Mode B core: the shared effective-config model, the symbolic resolver engine, +and the trap detectors used by the dns_simulate_* tools. + +Design contract (kept deterministic and side-effect free so it is unit-testable +without AWS): + + EffectiveModel - the VPC's effective DNS config = union of directly-attached + resources and Route 53 Profile-inherited resources. Every + construct carries a `source` ("direct" or "profile:"). + resolve(name) - the 7-level precedence engine. Returns a Resolution naming + the winning construct, its source, and the answer class. + apply_change() - produces a new EffectiveModel with a proposed change applied. + simulate() - resolves each candidate name through the current and + post-change models, diffs them, runs the trap detectors, and + ranks the result. + +Answer classes: BLOCKED, VPCE_PRIVATE, PHZ_PRIVATE, ONPREM, PUBLIC, NXDOMAIN. +Precedence (highest -> lowest): DNS Firewall > specific FORWARD > SYSTEM > +VPCE private DNS > PHZ > SNVA preference gate > .2 default. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace + +# ---- answer classes ------------------------------------------------------- + +BLOCKED = "BLOCKED" +VPCE_PRIVATE = "VPCE_PRIVATE" +PHZ_PRIVATE = "PHZ_PRIVATE" +ONPREM = "ONPREM" +PUBLIC = "PUBLIC" +NXDOMAIN = "NXDOMAIN" +# A construct is associated but its details are not readable from this account +# (cross-account shared firewall domains / profile-contained rule). The true +# resolution cannot be predicted - surfaced so the tool is honest rather than +# silently treating the construct as inert. +OPAQUE = "OPAQUE" + +# Route 53 Profile propagation window (service-side target .. negative-cache worst case) +PROFILE_PROPAGATION_SECONDS = (300, 900) + + +# ---- data model ----------------------------------------------------------- + +@dataclass(frozen=True) +class ResolverRule: + """A Resolver rule. rule_type is FORWARD or SYSTEM; domain is the match apex. + + opaque=True means the rule is known to be associated but its details + (domain/target) are NOT readable from this account (e.g. a rule delivered + via a cross-account shared Route 53 Profile - get_resolver_rule denies). + When opaque, `domain` may be '' and the engine cannot predict its effect.""" + domain: str + rule_type: str # "FORWARD" | "SYSTEM" + target: str = "" # e.g. "onprem" for FORWARD + source: str = "direct" + opaque: bool = False + + +@dataclass(frozen=True) +class FirewallRule: + """A DNS Firewall domain-list rule. action is ALLOW/ALERT/BLOCK; block_response + is NXDOMAIN/NODATA/OVERRIDE when action is BLOCK. + + opaque=True means the rule group is associated but its domain list is NOT + readable from this account (e.g. a RAM-shared group - list_firewall_domains + denies, or an AWS Managed Domain List). domains will be empty; the engine + must treat the rule as 'present, match set unknown' rather than inert.""" + domains: tuple[str, ...] + action: str # "ALLOW" | "ALERT" | "BLOCK" + block_response: str = "NXDOMAIN" + priority: int = 100 + source: str = "direct" + opaque: bool = False + + +@dataclass(frozen=True) +class Phz: + """A private hosted zone associated with the VPC (zone apex).""" + zone: str + source: str = "direct" + + +@dataclass(frozen=True) +class Shadow: + """A Lattice/PrivateLink-managed private-DNS shadow installed into the VPC. + + One construct covers every path that installs a shadow PHZ over an apex: + * interface VPC endpoint private DNS (e.g. secretsmanager...amazonaws.com) + * Service Network VPC Association (SNVA) published domains + * Service Network endpoint + * VPC Resource endpoint (resource configuration CustomDomainName) + + service_apex the FQDN whose zone is shadowed. + private_dns whether the shadow is actually installed/enabled. + served_names specific records the shadow answers; when installed it + captures the whole apex but answers only served_names (+ the + exact apex) - any other subdomain NXDOMAINs. Empty = apex only. + gated True -> subject to the SNVA PrivateDnsPreference gate for + AWS-owned FQDNs (interface VPCE / SNVA-published). + False -> endpoint-installed custom-domain shadow (resource / + SN endpoint); NOT gated by the SNVA preference. + """ + service_apex: str + private_dns: bool + source: str = "direct" + served_names: tuple[str, ...] = () + gated: bool = True + + +# Backwards-compatible alias: interface VPC endpoints are gated shadows. +Vpce = Shadow + + +@dataclass(frozen=True) +class EffectiveModel: + """The VPC's effective DNS resolution configuration.""" + vpc_id: str + firewall_rules: tuple[FirewallRule, ...] = () + resolver_rules: tuple[ResolverRule, ...] = () + phzs: tuple[Phz, ...] = () + vpces: tuple[Vpce, ...] = () + # SNVA gate: VERIFIED_DOMAINS_ONLY | ALL_DOMAINS | SPECIFIED_DOMAINS_ONLY + snva_preference: str = "VERIFIED_DOMAINS_ONLY" + # domains the SNVA override applies to when SPECIFIED_DOMAINS_ONLY + specified_domains: tuple[str, ...] = () + dns_support: bool = True + # operator-declared on-prem/corp zones (for category judgement) + onprem_zones: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Resolution: + name: str + answer_class: str + winner: str # human label of the construct that won + source: str # "direct" | "profile:" | "system" + + +# ---- helpers -------------------------------------------------------------- + +def _suffix_match(name: str, apex: str) -> bool: + n = name.rstrip(".").lower() + a = apex.rstrip(".").lower() + if a in (".", ""): # root FORWARD matches everything + return True + return n == a or n.endswith("." + a) + + +def _specificity(apex: str) -> int: + """More labels == more specific. Root ('.') is least specific.""" + a = apex.rstrip(".") + if a in ("", "."): + return 0 + return a.count(".") + 1 + + +def _is_aws_fqdn(name: str) -> bool: + n = name.rstrip(".").lower() + return n.endswith(".amazonaws.com") or n.endswith(".api.aws") + + +def _snva_allows(name: str, model: "EffectiveModel") -> bool: + """Whether the SNVA PrivateDnsPreference permits overriding an AWS-owned FQDN + for `name`. + + Live preference values (vpc-lattice dnsOptions.privateDnsPreference): + - ALL_DOMAINS -> always override + - VERIFIED_DOMAINS_ONLY (default) -> never override an AWS FQDN + - SPECIFIED_DOMAINS_ONLY -> override only for a specified domain + - VERIFIED_DOMAINS_AND_SPECIFIED_DOMAINS -> like SPECIFIED for AWS FQDNs + (verified/AWS-owned names are still overridden only when specified) + """ + pref = model.snva_preference + if pref == "ALL_DOMAINS": + return True + if pref in ("SPECIFIED_DOMAINS_ONLY", "VERIFIED_DOMAINS_AND_SPECIFIED_DOMAINS"): + return any(_suffix_match(name, d) for d in model.specified_domains) + return False # VERIFIED_DOMAINS_ONLY (default) and anything unknown + + +# ---- the resolver engine -------------------------------------------------- + +def resolve(name: str, model: EffectiveModel) -> Resolution: + """Symbolically resolve `name` under `model`, honoring the precedence stack.""" + n = name.rstrip(".").lower() + + # 0. If the VPC resolver is disabled (enableDnsSupport=false), NOTHING the + # VPC resolver would answer works - and Resolver rules + DNS Firewall do + # not evaluate either. Short-circuit to NXDOMAIN before any other branch. + if not model.dns_support: + return Resolution(name, NXDOMAIN, "VPC resolver disabled (enableDnsSupport=false)", "system") + + # 1. DNS Firewall - evaluated before resolution completes. Lowest priority + # number wins; a BLOCK short-circuits. Opaque rule groups (domains not + # readable from this account) are evaluated first: since ANY name might be + # in the hidden block list, the effect is unpredictable -> OPAQUE. + opaque_fw = sorted( + [r for r in model.firewall_rules if r.opaque], + key=lambda r: r.priority, + ) + if opaque_fw: + r0 = opaque_fw[0] + return Resolution( + name, OPAQUE, + f"DNS Firewall rule group present but domain list not readable " + f"from this account ({r0.action}); effect on '{name}' cannot be " + f"predicted", r0.source, + ) + fw = sorted( + [r for r in model.firewall_rules + if not r.opaque and any(_suffix_match(n, d) for d in r.domains)], + key=lambda r: r.priority, + ) + for rule in fw: + if rule.action == "BLOCK": + cls = BLOCKED if rule.block_response == "OVERRIDE" else NXDOMAIN + return Resolution(name, cls, + f"DNS Firewall {rule.action}/{rule.block_response}", rule.source) + # ALLOW/ALERT pass through to resolution below. + break + + # 2/3. Resolver rules: most-specific match wins. At equal specificity a + # FORWARD is preferred over SYSTEM (documented modeling decision - see + # test_forward_beats_system_equal_specificity). RECURSIVE (the default + # Internet Resolver rule, e.g. the autodefined '.') means "resolve + # normally" and is NOT an override - skip it so resolution falls through + # to VPCE/PHZ/native below. + matched = [ + r for r in model.resolver_rules + if not r.opaque and _suffix_match(n, r.domain) and r.rule_type in ("FORWARD", "SYSTEM") + ] + if matched: + matched.sort(key=lambda r: (_specificity(r.domain), r.rule_type == "FORWARD"), reverse=True) + top = matched[0] + if top.rule_type == "FORWARD": + # Forwarded off to the target (on-prem). AWS FQDNs swept here break. + cls = ONPREM + return Resolution( + name, + cls, + f"FORWARD rule '{top.domain}' -> {top.target or '(target unspecified)'}", + top.source, + ) + # SYSTEM rule: force VPC-native resolution; fall through to native logic. + + # 2b. Opaque resolver rules (e.g. a rule delivered via a cross-account shared + # Profile whose domain/target are not readable). We cannot tell whether + # `name` matches, so if no concrete rule above claimed it, surface the + # uncertainty rather than assuming it resolves normally. + opaque_rr = [r for r in model.resolver_rules if r.opaque] + if opaque_rr: + return Resolution( + name, OPAQUE, + f"Resolver rule present but not readable from this account " + f"({opaque_rr[0].source}); may forward '{name}' - effect cannot be " + f"predicted", opaque_rr[0].source, + ) + + # 4. Managed private-DNS shadow (interface VPCE / SNVA / SN endpoint / + # resource endpoint). When installed the shadow PHZ captures the whole + # apex: it answers the exact apex + served_names, but a strict subdomain + # it does NOT serve returns NXDOMAIN - the shadow-NXDOMAIN trap (e.g. + # oidc.eks..amazonaws.com issuer path). + for v in model.vpces: + if not (v.private_dns and _suffix_match(n, v.service_apex)): + continue + # The SNVA PrivateDnsPreference gate applies ONLY to gated shadows + # (interface VPCE / SNVA-published) overriding an AWS-owned FQDN. + # Endpoint-installed custom-domain shadows (resource / SN endpoint) are + # ungated - governed by their own per-endpoint private-DNS flag. + if v.gated and _is_aws_fqdn(v.service_apex) and not _snva_allows(n, model): + break # gate blocks the override -> falls through to public + nn = n.rstrip(".").lower() + apex = v.service_apex.rstrip(".").lower() + served = {s.rstrip(".").lower() for s in v.served_names} + label = "VPCE/Lattice private DNS" if v.gated else "Lattice endpoint shadow" + if nn == apex or nn in served: + return Resolution(name, VPCE_PRIVATE, f"{label} '{v.service_apex}'", v.source) + # Strict subdomain shadowed by the PHZ but not answered by it. + return Resolution(name, NXDOMAIN, f"shadow, no record for '{name}'", v.source) + + # 5. Associated PHZ. + phz = [p for p in model.phzs if _suffix_match(n, p.zone)] + if phz: + phz.sort(key=lambda p: _specificity(p.zone), reverse=True) + return Resolution(name, PHZ_PRIVATE, f"PHZ '{phz[0].zone}'", phz[0].source) + + # 6/7. On-prem declared zone with no forwarding path -> NXDOMAIN from .2; + # otherwise default public recursion. + if any(_suffix_match(n, z) for z in model.onprem_zones): + # No FORWARD matched above, so the VPC resolver has no path to on-prem. + return Resolution(name, NXDOMAIN, "no FORWARD path for on-prem zone", "system") + return Resolution(name, PUBLIC, "VPC .2 recursion", "system") + + +# ---- change application --------------------------------------------------- + +def apply_change(model: EffectiveModel, change: dict) -> EffectiveModel: + """Return a new EffectiveModel with the proposed change applied.""" + ctype = change.get("type") + src = f"profile:{change['profile_id']}" if ctype == "associate_profile" and change.get("profile_id") else "direct" + + if ctype == "enable_vpce_private_dns": + apex = change["service_apex"] + served = tuple(change.get("served_names", ())) + # Flip an existing endpoint for this apex if present; else append. + existing = [v for v in model.vpces if v.service_apex.rstrip(".").lower() == apex.rstrip(".").lower()] + if existing: + others = tuple(v for v in model.vpces if v not in existing) + return replace(model, vpces=others + (Vpce(apex, True, existing[0].source, served),)) + return replace(model, vpces=model.vpces + (Vpce(apex, True, "direct", served),)) + + if ctype == "associate_phz": + return replace(model, phzs=model.phzs + (Phz(change["zone"], "direct"),)) + + if ctype == "add_resolver_rule": + # Accept either "target" (a rendered label) or "target_ips" (the shape + # the Route 53 Resolver API and this server's docs use). Without the + # latter, a caller passing target_ips produced a rule whose target + # rendered as an empty string in the impact report. + target = change.get("target") or "" + if not target: + ips = change.get("target_ips") or () + if isinstance(ips, str): + ips = (ips,) + target = ", ".join(str(i) for i in ips) + rule = ResolverRule( + domain=change["domain"], + rule_type=change.get("rule_type", "FORWARD"), + target=target, + source="direct", + ) + return replace(model, resolver_rules=model.resolver_rules + (rule,)) + + if ctype == "associate_dns_firewall": + rule = FirewallRule( + domains=tuple(change.get("domains", ())), + action=change.get("action", "BLOCK"), + block_response=change.get("block_response", "NXDOMAIN"), + priority=change.get("priority", 100), + source="direct", + ) + return replace(model, firewall_rules=model.firewall_rules + (rule,)) + + if ctype == "associate_profile": + # Bulk change: the profile contributes a set of resources, all tagged + # with the profile source. + res = change.get("resources", {}) + return replace( + model, + resolver_rules=model.resolver_rules + tuple( + ResolverRule(r["domain"], r.get("rule_type", "FORWARD"), r.get("target", ""), src) + for r in res.get("resolver_rules", []) + ), + firewall_rules=model.firewall_rules + tuple( + FirewallRule(tuple(f["domains"]), f.get("action", "BLOCK"), + f.get("block_response", "NXDOMAIN"), f.get("priority", 100), src) + for f in res.get("firewall_rules", []) + ), + phzs=model.phzs + tuple(Phz(p["zone"], src) for p in res.get("phzs", [])), + ) + + if ctype == "set_snva_preference": + return replace(model, snva_preference=change["preference"]) + + if ctype == "set_dhcp_dns": + # Modeled as toggling the VPC resolver on/off for this simulation scope. + return replace(model, dns_support=change.get("dns_support", model.dns_support)) + + return model + + +def _change_touches_profile(change: dict) -> bool: + return change.get("type") == "associate_profile" + + +# ---- trap detectors ------------------------------------------------------- + +def detect_traps(name: str, before: Resolution, after: Resolution, + model_before: EffectiveModel, model_after: EffectiveModel, + change: dict) -> list[str]: + """Return the list of trap labels triggered for this name by this change.""" + traps: list[str] = [] + + # 1. VPCE-shadow-NXDOMAIN: a name that resolved (public) now NXDOMAINs because + # a VPCE private-DNS enable shadows its apex without answering it. + if (change.get("type") == "enable_vpce_private_dns" + and before.answer_class in (PUBLIC, VPCE_PRIVATE) + and after.answer_class == NXDOMAIN): + traps.append("VPCE-shadow-NXDOMAIN") + + # 2. broad-FORWARD-sweep: a new '.' or amazonaws.com FORWARD rule sweeps an + # AWS-service FQDN on-prem with no protective SYSTEM carve-out. + if change.get("type") == "add_resolver_rule" and change.get("rule_type", "FORWARD") == "FORWARD": + dom = change.get("domain", "") + if (_is_aws_fqdn(name) and _suffix_match(name, dom) + and after.answer_class == ONPREM and before.answer_class != ONPREM): + traps.append("broad-FORWARD-sweep") + + # 3. flag-AND mismatch: VPCE private DNS enabled but the SNVA gate leaves the + # AWS FQDN override uninstalled (still public), so the intended private + # resolution silently does not take effect. + if (change.get("type") in ("enable_vpce_private_dns", "set_snva_preference") + and _is_aws_fqdn(name) + and any(v.private_dns and _suffix_match(name, v.service_apex) for v in model_after.vpces) + and after.answer_class == PUBLIC): + traps.append("flag-AND-mismatch") + + # 4. DNS-Firewall-block: a firewall change newly blocks a name. + if (change.get("type") == "associate_dns_firewall" + and after.answer_class in (NXDOMAIN, BLOCKED) + and "DNS Firewall" in after.winner + and before.answer_class not in (NXDOMAIN, BLOCKED)): + traps.append("DNS-Firewall-block") + + # 5. Profile-union shift: a Profile associate/disassociate changes the winner + # or its source for this name. + if _change_touches_profile(change) and ( + before.answer_class != after.answer_class or before.source != after.source + ): + traps.append("Profile-union-shift") + + # 6. resolver-disabled: a set_dhcp_dns change turns the VPC resolver dark and + # the name breaks. Labels the cause instead of a bare break->high. + if (change.get("type") == "set_dhcp_dns" + and not model_after.dns_support + and after.answer_class == NXDOMAIN + and before.answer_class != NXDOMAIN): + traps.append("resolver-disabled") + + return traps + + +# ---- severity + orchestration -------------------------------------------- + +def _severity(before: Resolution, after: Resolution, traps: list[str], volume: int) -> str: + breaks = after.answer_class in (NXDOMAIN, BLOCKED) and before.answer_class not in (NXDOMAIN, BLOCKED) + changed = before.answer_class != after.answer_class + # A triggered trap is a known-bad pattern -> high, EXCEPT a Profile-union + # shift that only changed the source (same answer_class) is a benign + # ownership move -> medium, so it does not over-alert (L4). + if traps: + source_only = (traps == ["Profile-union-shift"] and not changed and not breaks) + return "medium" if source_only else "high" + if breaks: + base = "high" + elif changed: + base = "medium" + else: + base = "none" + # Volume can escalate a medium to high when a lot of traffic is affected. + if base == "medium" and volume >= 1000: + base = "high" + return base + + +@dataclass +class NameImpact: + name: str + before: Resolution + after: Resolution + traps: list[str] + severity: str + volume: int = 0 + + +def simulate(model: EffectiveModel, change: dict, + candidate_names: list[str], + volumes: dict[str, int] | None = None) -> list[NameImpact]: + """Resolve each candidate through current and post-change models, diff, run + trap detectors, and rank by severity then volume. Returns impacts that + changed OR triggered a trap, most severe first.""" + volumes = volumes or {} + after_model = apply_change(model, change) + impacts: list[NameImpact] = [] + for name in candidate_names: + b = resolve(name, model) + a = resolve(name, after_model) + traps = detect_traps(name, b, a, model, after_model, change) + if b.answer_class == a.answer_class and b.source == a.source and not traps: + continue # no delta, no trap -> omit + vol = volumes.get(name.rstrip(".").lower(), 0) + impacts.append(NameImpact(name, b, a, traps, _severity(b, a, traps, vol), vol)) + + rank = {"high": 0, "medium": 1, "none": 2} + impacts.sort(key=lambda i: (rank.get(i.severity, 3), -i.volume)) + return impacts diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/run.sh b/mcp/aws-vpc-dns-diagnostics-mcp/src/run.sh new file mode 100644 index 0000000..091d5c5 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# Lambda Web Adapter entry point. +# Starts the FastMCP server on the port expected by LWA. +export PYTHONPATH="/opt/python:${PYTHONPATH}" +cd /var/task +exec python3 server.py diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py new file mode 100644 index 0000000..c47bed5 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py @@ -0,0 +1,1080 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +DNS Diagnostic MCP Server for AWS DevOps Agent. + +Three tool families in one server: + + * list_sops / get_sop - bundled diagnostic runbooks carrying the decision + trees, precedence model, trap semantics, and reporting rules + for interpreting results. The agent fetches guidance at + runtime instead of relying on preloaded instructions. + * dns_probe_* (Mode A) - live, comparative, multi-resolver DNS diagnosis run + inside a target EC2 instance via SSM Run Command. Returns a + per-name, per-resolver, per-family answer matrix plus a + VPC-attribute precondition block. + * dns_simulate_* (Mode B) - symbolic pre-change validation of the VPC's + effective (direct + Route 53 Profile-inherited) DNS + resolution, using control-plane reads only. + +Safety model: + * Enforced allowlists. Mode A exposes only parameterized probe templates with + strict input validation - no free shell. Mode B is read-only describe/list. + * Per-tool-family credential scoping. The function assumes a read-only role for + simulate calls and a separate probe role (whose sole privileged grant is a + resource-scoped ssm:SendCommand to one diagnostic document) for probe calls. + A read-only call never rides on a role holding ssm:SendCommand. + * Fail-closed production enforcement. Wildcard allowlists are refused when + STAGE_NAME=prod. + * Account / region / VPC / resolver allowlists enforced in one place. + +Transport: Streamable HTTP (via Lambda Web Adapter + Function URL, SigV4 auth). +""" + +import ipaddress +import json +import os +import re +import time + +import boto3 +from fastmcp import FastMCP + +mcp = FastMCP( + "aws-vpc-dns-diagnostics-mcp", + instructions=( + "DNS diagnostics for AWS VPCs. Three tool families: list_sops/get_sop " + "return diagnostic runbooks with the decision trees, precedence model, " + "and reporting rules for interpreting results - call list_sops first if " + "you are unsure which procedure applies, or get_sop('Z-general-triage') " + "for a vague symptom. dns_probe_* runs live, comparative multi-resolver " + "DNS queries inside an EC2 instance via SSM (Mode A, ground truth); " + "dns_simulate_* predicts what a proposed DNS control-plane change would " + "break by symbolically resolving the VPC's effective config (Mode B, " + "read-only). Only allowlisted probes and read-only describe calls are " + "permitted." + ), +) + +sts_client = boto3.client("sts") + +# The VPC Amazon-provided resolver, per family. In IPv6-only subnets only the +# IPv6 address is present; in dualstack both answer. +VPC_RESOLVER_IPV4 = "169.254.169.253" +VPC_RESOLVER_IPV6 = "fd00:ec2::253" + + +# ============================================================ +# Configuration & allowlists +# ============================================================ + +def _load_allowlist(env_var: str) -> set[str]: + """Load a comma-separated allowlist from an env var. '*' means allow-all.""" + raw = os.environ.get(env_var, "*").strip() + if raw == "*": + return set() # Empty set == allow all + return {v.strip().lower() for v in raw.split(",") if v.strip()} + + +def _enforce_prod_allowlists(): + """Fail-closed: refuse to start if any allowlist is '*' in production.""" + stage = os.environ.get("STAGE_NAME", "").lower() + if stage != "prod": + return + wildcards = [ + var + for var in ("ALLOWED_ACCOUNTS", "ALLOWED_REGIONS", "ALLOWED_VPCS", "ALLOWED_RESOLVERS") + if os.environ.get(var, "*").strip() == "*" + ] + if wildcards: + raise RuntimeError( + "SECURITY: Production deployment requires explicit allowlists. " + f"The following are set to '*' (wildcard): {', '.join(wildcards)}. " + "Set each to a comma-separated list of permitted values." + ) + + +def _warn_wildcard_resolvers(): + """Surface the one wildcard that widens the blast radius beyond read-only. + + An empty/wildcard ALLOWED_RESOLVERS lets a caller name any resolver IP, and + dns_probe_compare then sends a DNS query from the target instance to that IP. + The queried name is DNS-charset only and capped at 253 characters, so the + channel is narrow, but it IS a caller-directed outbound query -- the only + outbound path in this server a caller can point somewhere new. + + STAGE_NAME=prod refuses to start on this (see _enforce_prod_allowlists). + Outside prod it is permitted, so log it loudly: a deployment attached to a + DevOps Agent should set an explicit resolver allowlist regardless of stage. + """ + if os.environ.get("ALLOWED_RESOLVERS", "*").strip() != "*": + return + stage = os.environ.get("STAGE_NAME", "(unset)") + print( + "WARNING: ALLOWED_RESOLVERS is '*' (wildcard) with STAGE_NAME=" + f"{stage}. dns_probe_compare will accept ANY literal resolver IP and " + "query it from the target instance. Hostnames are still refused " + "(fail-closed). Do NOT use a wildcard resolver allowlist in any " + "deployment reachable by AWS DevOps Agent -- set ALLOWED_RESOLVERS to a " + "comma-separated list of permitted resolver addresses.", + flush=True, + ) + + +_enforce_prod_allowlists() +_warn_wildcard_resolvers() + +ALLOWED_ACCOUNTS = _load_allowlist("ALLOWED_ACCOUNTS") +ALLOWED_REGIONS = _load_allowlist("ALLOWED_REGIONS") +ALLOWED_VPCS = _load_allowlist("ALLOWED_VPCS") +ALLOWED_RESOLVERS = _load_allowlist("ALLOWED_RESOLVERS") + +PROBE_ROLE_ARN_PATTERN = os.environ.get("PROBE_ROLE_ARN_PATTERN", "") +READONLY_ROLE_ARN_PATTERN = os.environ.get("READONLY_ROLE_ARN_PATTERN", "") +DIAGNOSTIC_DOCUMENT_NAME = os.environ.get("DIAGNOSTIC_DOCUMENT_NAME", "dns-diagnostic-probe") + + +def _validate(value: str, allowlist: set[str], label: str) -> tuple[bool, str]: + """Generic allowlist check. Empty allowlist == allow all.""" + if not allowlist: + return True, "" + if value.lower() not in allowlist: + return False, ( + f"ERROR: {label} '{value}' is not in the allowed list. " + f"Permitted: {', '.join(sorted(allowlist))}." + ) + return True, "" + + +# ============================================================ +# Per-tool-family credential scoping +# ============================================================ + +def _role_arn_for(account_id: str, pattern: str) -> str: + """Resolve a per-account role ARN from a pattern containing a '*' account.""" + return pattern.replace("*", account_id, 1) + + +def _assume(account_id: str, region: str, pattern: str, session_name: str): + """ + Assume the given scoped role in the target account and return a boto3 + session bound to that role and region. Mode A uses the probe-role pattern; + Mode B uses the read-only pattern. The two are never interchangeable. + """ + role_arn = _role_arn_for(account_id, pattern) + resp = sts_client.assume_role(RoleArn=role_arn, RoleSessionName=session_name) + c = resp["Credentials"] + return boto3.Session( + aws_access_key_id=c["AccessKeyId"], + aws_secret_access_key=c["SecretAccessKey"], + aws_session_token=c["SessionToken"], + region_name=region, + ) + + +def _preflight(account_id: str, region: str, vpc_id: str | None) -> tuple[bool, str]: + """Shared allowlist gate for both tool families.""" + for value, allowlist, label in ( + (account_id, ALLOWED_ACCOUNTS, "Account"), + (region, ALLOWED_REGIONS, "Region"), + ): + ok, msg = _validate(value, allowlist, label) + if not ok: + return ok, msg + if vpc_id is not None: + ok, msg = _validate(vpc_id, ALLOWED_VPCS, "VPC") + if not ok: + return ok, msg + return True, "" + + +# ============================================================ +# Mode A - probe command allowlist + input validation +# ============================================================ + +# Strict input validators. These are the enforced hard boundary: the model +# cannot improvise beyond these parameterized templates. +_NAME_RE = re.compile(r"^(?=.{1,253}$)([a-zA-Z0-9_-]{1,63}\.)*[a-zA-Z0-9_-]{1,63}\.?$") +_SHELL_META_RE = re.compile(r"[;&|`$(){}<>\n\r\\'\"* \t]") +_FAMILY_ENUM = {"A", "AAAA"} + + +def _valid_name(name: str) -> bool: + return bool(_NAME_RE.match(name)) and not _SHELL_META_RE.search(name) + + +def _valid_resolver(resolver: str) -> bool: + """A resolver must be a literal IP OR an operator-allowlisted hostname.""" + if _SHELL_META_RE.search(resolver): + return False + try: + ipaddress.ip_address(resolver) + return True + except ValueError: + pass + # Not an IP - must be explicitly allowlisted and a well-formed hostname. + # NOTE (L2): the general _validate() treats an empty allowlist as allow-all, + # but resolvers are DELIBERATELY the opposite - an empty ALLOWED_RESOLVERS + # allows only literal IPs and rejects ALL hostnames (fail-closed). This + # prevents the comparison feature from becoming an arbitrary-egress primitive + # via an unvetted hostname. A hostname is permitted only when explicitly + # listed in ALLOWED_RESOLVERS. + if ALLOWED_RESOLVERS and resolver.lower() in ALLOWED_RESOLVERS: + return bool(_NAME_RE.match(resolver)) + return False + + +def _valid_family(family: str) -> bool: + return family in _FAMILY_ENUM + + +# The probe set is defined INSIDE the diagnostic SSM document, which accepts only +# the three pattern-validated parameters below and renders a fixed, read-only +# command set. The server does NOT send a command string - it sends structured +# parameters - so the document is the on-instance enforcement boundary and the +# server-side validators here are the first, independent layer. +PROBE_PARAM_NAMES = ("Name", "Resolver", "Family") + + +def _ssm_reachable(session, instance_id: str) -> bool: + """Whether the SSM agent on the instance is registered/reachable. Checked + once per dns_probe_compare (L3) - never fall back to a public path.""" + info = session.client("ssm").describe_instance_information( + Filters=[{"Key": "InstanceIds", "Values": [instance_id]}] + ) + return bool(info.get("InstanceInformationList")) + + +_SSM_UNREACHABLE_MSG = ( + "SSM is not reachable for instance {iid}. Ensure SSM VPC endpoints " + "(ssm, ssmmessages, ec2messages) or an EC2 Instance Connect Endpoint are in " + "place and the instance role has AmazonSSMManagedInstanceCore. Not falling " + "back to a public path." +) + + +def _ssm_run_probe(session, instance_id: str, name: str, resolver: str, family: str) -> dict: + """ + Invoke the diagnostic SSM document for one (name, resolver, family) triple and + poll for the result. Sends structured parameters only - never a command + string. Read-only diagnostics. Assumes SSM reachability was already checked + by the caller (see _ssm_reachable). + """ + ssm = session.client("ssm") + resp = ssm.send_command( + InstanceIds=[instance_id], + DocumentName=DIAGNOSTIC_DOCUMENT_NAME, + Parameters={"Name": [name], "Resolver": [resolver], "Family": [family]}, + ) + command_id = resp["Command"]["CommandId"] + for _ in range(30): + time.sleep(2) + try: + inv = ssm.get_command_invocation(CommandId=command_id, InstanceId=instance_id) + except ssm.exceptions.InvocationDoesNotExist: + # SSM can briefly 404 the invocation right after send_command; retry. + continue + if inv["Status"] in ("Success", "Failed", "Cancelled", "TimedOut"): + return { + "status": inv["Status"], + "stdout": inv.get("StandardOutputContent", ""), + "stderr": inv.get("StandardErrorContent", ""), + } + return {"error": "Timed out waiting for SSM command invocation."} + + +# ============================================================ +# Mode A - tools (dns_probe_*) +# ============================================================ + +def _read_dhcp_dns(ec2, vpc_id: str) -> dict: + """ + Read the VPC's associated DHCP option set and extract the DNS-relevant + values: domain-name-servers and domain-name. Returns a dict with the raw + server list and a normalized view. + + 'AmazonProvidedDNS' is the sentinel meaning the VPC .2 resolver (no custom + resolver). Anything else is a custom/explicit resolver configured VPC-wide. + """ + vpcs = ec2.describe_vpcs(VpcIds=[vpc_id]).get("Vpcs", []) + dhcp_id = vpcs[0].get("DhcpOptionsId", "") if vpcs else "" + servers: list[str] = [] + domain_name = "" + if dhcp_id: + opts = ec2.describe_dhcp_options(DhcpOptionsIds=[dhcp_id]).get("DhcpOptions", []) + for cfg in (opts[0].get("DhcpConfigurations", []) if opts else []): + key = cfg.get("Key") + vals = [v.get("Value", "") for v in cfg.get("Values", [])] + if key == "domain-name-servers": + servers = vals + elif key == "domain-name": + domain_name = vals[0] if vals else "" + is_amazon = servers == ["AmazonProvidedDNS"] + # Custom resolver IPs are the non-sentinel entries. + custom = [s for s in servers if s and s != "AmazonProvidedDNS"] + return { + "dhcp_options_id": dhcp_id, + "servers": servers, + "domain_name": domain_name, + "is_amazon_provided": is_amazon, + "custom_servers": custom, + } + + +@mcp.tool() +def dns_probe_context(account_id: str, region: str, instance_id: str) -> str: + """ + Collect the VPC-attribute precondition block and host DNS context for an + instance BEFORE interpreting any resolution result. + + Reports enableDnsSupport (gates the whole VPC resolver - if false, .2 and + the IPv6 resolver do not answer at all), enableDnsHostnames, the instance's + addressing (IPv4 / IPv6 / dualstack), and the VPC DHCP option set's + domain-name-servers / domain-name (the VPC-INTENDED resolver, VPC-wide). Use + dns_probe_compare to fetch the instance-ACTUAL /etc/resolv.conf and compare; + a mismatch between the two means the instance is not using the resolver the + VPC hands out via DHCP. + + Args: + account_id: Target AWS account ID. + region: Target region. + instance_id: EC2 instance ID to inspect. + + Returns: + A markdown precondition + context report. + """ + ok, msg = _preflight(account_id, region, None) + if not ok: + return msg + session = _assume(account_id, region, PROBE_ROLE_ARN_PATTERN, "dns-probe-context") + ec2 = session.client("ec2") + + inst = ec2.describe_instances(InstanceIds=[instance_id]) + reservations = inst.get("Reservations", []) + if not reservations or not reservations[0].get("Instances"): + return f"ERROR: instance {instance_id} not found in {account_id}/{region}." + instance = reservations[0]["Instances"][0] + vpc_id = instance.get("VpcId", "") + + ok, msg = _validate(vpc_id, ALLOWED_VPCS, "VPC") + if not ok: + return msg + + dns_support = ec2.describe_vpc_attribute(VpcId=vpc_id, Attribute="enableDnsSupport") + dns_hostnames = ec2.describe_vpc_attribute(VpcId=vpc_id, Attribute="enableDnsHostnames") + support = dns_support["EnableDnsSupport"]["Value"] + hostnames = dns_hostnames["EnableDnsHostnames"]["Value"] + + dhcp = _read_dhcp_dns(ec2, vpc_id) + + # Addressing family from the instance ENIs. + has_v4 = bool(instance.get("PrivateIpAddress")) + has_v6 = any(ni.get("Ipv6Addresses") for ni in instance.get("NetworkInterfaces", [])) + stack = "dualstack" if (has_v4 and has_v6) else ("ipv6-only" if has_v6 else "ipv4-only") + + lead = "" + if not support: + lead = ( + "> LEAD FINDING: enableDnsSupport=FALSE - the VPC resolver is " + "intentionally dark. Neither the IPv4 (.2) nor IPv6 resolver will " + "answer, and any custom/on-prem resolver that forwards back to the " + "VPC resolver will also fail. Interpret all probe rows in this light.\n\n" + ) + + # DHCP DNS reporting: VPC-intended resolver, VPC-wide. + if dhcp["is_amazon_provided"]: + dhcp_line = "AmazonProvidedDNS (VPC .2 resolver - no custom resolver)" + elif dhcp["custom_servers"]: + dhcp_line = ( + f"CUSTOM: {', '.join(dhcp['custom_servers'])} " + f"(VPC-intended resolver; compare against instance /etc/resolv.conf " + f"via dns_probe_compare - a mismatch means the box is not using it)" + ) + else: + dhcp_line = "(no domain-name-servers in the DHCP option set)" + dhcp_domain = dhcp["domain_name"] or "(none)" + + return ( + f"{lead}" + f"**VPC-attribute precondition ({vpc_id})**\n\n" + f"| attribute | value |\n| --- | --- |\n" + f"| enableDnsSupport | {support} |\n" + f"| enableDnsHostnames | {hostnames} |\n" + f"| instance addressing | {stack} |\n" + f"| DHCP option set | {dhcp['dhcp_options_id'] or '(none)'} |\n" + f"| DHCP domain-name-servers | {dhcp_line} |\n" + f"| DHCP domain-name | {dhcp_domain} |\n\n" + f"Probe the VPC resolver at: " + f"{VPC_RESOLVER_IPV4 if has_v4 else '(no IPv4)'} / " + f"{VPC_RESOLVER_IPV6 if has_v6 else '(no IPv6)'}." + + (f"\n\nAlso probe the DHCP-configured resolver(s): " + f"{', '.join(dhcp['custom_servers'])}." if dhcp["custom_servers"] else "") + ) + + +@mcp.tool() +def dns_probe_compare( + account_id: str, + region: str, + instance_id: str, + name: str, + resolvers: list[str] | None = None, + families: list[str] | None = None, + include_dhcp_dns: bool = True, +) -> str: + """ + Run the allowlisted DNS probe set inside an instance against multiple + resolvers and return a comparison. Use this to see what a name actually + resolves to from the subnet, which resolver answered, and how a custom + resolver's answer differs from the VPC .2 / IPv6 resolver. + + Only parameterized, validated probes run - no arbitrary commands. + + Resolver set assembly: + * `resolvers` you pass explicitly, PLUS + * when include_dhcp_dns is true (default), the VPC DHCP option set's + domain-name-servers are auto-added (the VPC-INTENDED custom resolver, so + you do not have to look it up). AmazonProvidedDNS is expanded to the VPC + .2 resolver address. If no resolver is supplied or discoverable, the + call errors rather than probing nothing. + + Args: + account_id: Target AWS account ID. + region: Target region. + instance_id: EC2 instance ID to run probes from (via SSM). + name: DNS name to resolve (validated against a strict DNS charset). + resolvers: Optional resolver IPs (or allowlisted hostnames) to compare. + families: Record families to probe; defaults to ["A", "AAAA"]. + include_dhcp_dns: Auto-add the VPC DHCP-configured resolver(s) (default true). + + Returns: + A markdown comparison of each resolver's answer and identity. + """ + ok, msg = _preflight(account_id, region, None) + if not ok: + return msg + if not _valid_name(name): + return f"ERROR: '{name}' is not a valid DNS name." + fams = families or ["A", "AAAA"] + for f in fams: + if not _valid_family(f): + return f"ERROR: invalid family '{f}'. Allowed: A, AAAA." + + session = _assume(account_id, region, PROBE_ROLE_ARN_PATTERN, "dns-probe-compare") + ec2 = session.client("ec2") + + # Look up the instance's VPC + addressing once. Used for the ALLOWED_VPCS + # gate (M2) and stack-aware resolver expansion (M3). + inst = ec2.describe_instances(InstanceIds=[instance_id]).get("Reservations", []) + if not inst or not inst[0].get("Instances"): + return f"ERROR: instance {instance_id} not found in {account_id}/{region}." + instance = inst[0]["Instances"][0] + vpc_id = instance.get("VpcId", "") + + # M2: enforce the VPC allowlist here too (mirrors dns_probe_context) so the + # allowlist is truly "enforced in one place" for both probe tools. + ok, msg = _validate(vpc_id, ALLOWED_VPCS, "VPC") + if not ok: + return msg + + has_v4 = bool(instance.get("PrivateIpAddress")) + has_v6 = any(ni.get("Ipv6Addresses") for ni in instance.get("NetworkInterfaces", [])) + + # Assemble the resolver set: explicit + DHCP-discovered (dedup, order-stable). + resolver_set: list[str] = list(resolvers or []) + dhcp_note = "" + if include_dhcp_dns and vpc_id: + dhcp = _read_dhcp_dns(ec2, vpc_id) + discovered = list(dhcp["custom_servers"]) + if dhcp["is_amazon_provided"]: + # M3: expand AmazonProvidedDNS to the resolver address(es) that + # actually exist for this instance's stack. IPv6-only subnets have + # no 169.254.169.253 - only fd00:ec2::253. + if has_v4: + discovered.append(VPC_RESOLVER_IPV4) + if has_v6: + discovered.append(VPC_RESOLVER_IPV6) + added = [r for r in discovered if r not in resolver_set] + resolver_set.extend(added) + if added: + dhcp_note = f" (auto-added from DHCP option set: {', '.join(added)})" + + if not resolver_set: + return ( + "ERROR: no resolvers to probe. Pass `resolvers` explicitly or leave " + "include_dhcp_dns=true on a VPC whose DHCP option set names a resolver." + ) + for r in resolver_set: + if not _valid_resolver(r): + return ( + f"ERROR: resolver '{r}' is not a literal IP or an allowlisted " + "hostname. The comparison feature must not become an " + "arbitrary-egress primitive." + ) + + # L3: check SSM reachability ONCE, not per (resolver, family) triple. + if not _ssm_reachable(session, instance_id): + return _SSM_UNREACHABLE_MSG.format(iid=instance_id) + + # M4: a single unreachable resolver must NOT abort the whole comparison - + # capture its error into that section and keep probing the rest. + sections = [] + for resolver in resolver_set: + for family in fams: + result = _ssm_run_probe(session, instance_id, name, resolver, family) + if "error" in result: + sections.append( + f"### resolver {resolver} ({family})\n" + f"status: ERROR\n\n" + f"```\n{result['error']}\n```" + ) + continue + sections.append( + f"### resolver {resolver} ({family})\n" + f"status: {result['status']}\n\n" + f"```\n{result['stdout'].strip()}\n```" + ) + header = ( + f"**DNS probe comparison for `{name}`** (instance {instance_id}, " + f"{account_id}/{region})\n\n" + f"Resolvers probed: {', '.join(resolver_set)}{dhcp_note}\n\n" + "Note: agreement is not the goal - the *correct* answer is. Judge each " + "resolver against the expected winner for the name's category " + "(AWS-service FQDN / PrivateLink-backed / PHZ / on-prem corp zone / public).\n" + ) + return header + "\n\n".join(sections) + + +# ============================================================ +# Mode B - tools (dns_simulate_*) +# ============================================================ + +from dns_model import ( # noqa: E402 + EffectiveModel, FirewallRule, ResolverRule, Phz, Vpce, + resolve, simulate, PROFILE_PROPAGATION_SECONDS, +) + + +def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None = None) -> EffectiveModel: + """ + Build the VPC's effective DNS model from live control-plane reads: the union + of directly-attached resources and Route 53 Profile-inherited resources. Each + construct is tagged with its source ("direct" or "profile:"). Read-only. + """ + r53r = session.client("route53resolver") + r53p = session.client("route53profiles") + r53 = session.client("route53") + ec2 = session.client("ec2") + lattice = session.client("vpc-lattice") + onprem = tuple((onprem_zones or [])) + + # --- VPC attribute: resolver on/off --- + dns_support = ec2.describe_vpc_attribute( + VpcId=vpc_id, Attribute="enableDnsSupport" + )["EnableDnsSupport"]["Value"] + + # --- SNVA PrivateDnsPreference (live) --- + # Read the VPC's service-network associations; the DNS-override gate is + # driven by dnsOptions.privateDnsPreference (+ specified domains). Default to + # VERIFIED_DOMAINS_ONLY when the VPC has no SNVA (the service default). + snva_preference = "VERIFIED_DOMAINS_ONLY" + specified_domains: tuple[str, ...] = () + try: + assocs = lattice.list_service_network_vpc_associations( + vpcIdentifier=vpc_id + ).get("items", []) + # Prefer an association that actually enables private DNS; else the first. + chosen = next((a for a in assocs if a.get("privateDnsEnabled")), assocs[0] if assocs else None) + if chosen: + dns_opts = chosen.get("dnsOptions") or {} + snva_preference = dns_opts.get("privateDnsPreference", "VERIFIED_DOMAINS_ONLY") + specified_domains = tuple(dns_opts.get("privateDnsSpecifiedDomains", []) or []) + except Exception: + # vpc-lattice not available / no permission -> keep the service default. + pass + + resolver_rules: list[ResolverRule] = [] + firewall_rules: list[FirewallRule] = [] + phzs: list[Phz] = [] + vpces: list[Vpce] = [] + + # --- directly-attached Resolver rules --- + for assoc in r53r.list_resolver_rule_associations( + Filters=[{"Name": "VPCId", "Values": [vpc_id]}] + ).get("ResolverRuleAssociations", []): + rid = assoc["ResolverRuleId"] + try: + rule = r53r.get_resolver_rule(ResolverRuleId=rid)["ResolverRule"] + resolver_rules.append(ResolverRule( + domain=rule.get("DomainName", "."), + rule_type=rule.get("RuleType", "FORWARD"), + target=",".join(t.get("Ip", "") for t in rule.get("TargetIps", [])) or "onprem", + source="direct", + )) + except Exception: + # Associated rule not readable from this account -> opaque marker. + resolver_rules.append(ResolverRule( + domain="", rule_type="FORWARD", target="", source="direct", opaque=True)) + + # --- directly-attached DNS Firewall rule groups --- + for fga in r53r.list_firewall_rule_group_associations( + VpcId=vpc_id + ).get("FirewallRuleGroupAssociations", []): + fgid = fga["FirewallRuleGroupId"] + try: + frules = r53r.list_firewall_rules(FirewallRuleGroupId=fgid).get("FirewallRules", []) + except Exception: + # Rule group associated but not readable (cross-account share) -> + # record an opaque BLOCK marker so it is not silently dropped. + firewall_rules.append(FirewallRule( + domains=(), action="BLOCK", block_response="NXDOMAIN", + priority=fga.get("Priority", 100), source="direct", opaque=True)) + continue + for fr in frules: + try: + dl = r53r.list_firewall_domains( + FirewallDomainListId=fr["FirewallDomainListId"] + ).get("Domains", []) + opaque = False + except Exception: + # Domain list not readable from this account (RAM-shared group / + # AWS Managed Domain List) -> present but opaque. + dl, opaque = [], True + firewall_rules.append(FirewallRule( + domains=tuple(dl), + action=fr.get("Action", "BLOCK"), + block_response=fr.get("BlockResponse", "NXDOMAIN"), + priority=fr.get("Priority", 100), + source="direct", + opaque=opaque, + )) + + # --- associated PHZs --- + for hz in r53.list_hosted_zones_by_vpc( + VPCId=vpc_id, VPCRegion=session.region_name + ).get("HostedZoneSummaries", []): + phzs.append(Phz(zone=hz["Name"], source="direct")) + + # --- VPC endpoints: every DNS shadow the CONSUMER VPC sees, derived purely + # from DescribeVpcEndpoints (consumer-side, no provider visibility). + # DnsEntries[].DnsName is the name actually installed into this VPC. + # Endpoint type drives the gate: + # Interface -> gated (AWS-service FQDN; SNVA preference applies) + # Resource / ServiceNetwork -> ungated custom-domain shadow + # This does NOT read resource configurations or gateways - those are + # provider-only constructs a consumer account cannot enumerate (a + # resource config may be RAM-shared and its gateway invisible here). + for ep in ec2.describe_vpc_endpoints( + Filters=[{"Name": "vpc-id", "Values": [vpc_id]}] + ).get("VpcEndpoints", []): + etype = ep.get("VpcEndpointType", "") + private = ep.get("PrivateDnsEnabled", False) + if etype == "Interface": + svc = ep.get("ServiceName", "") + apex = svc.split("com.amazonaws.")[-1] if "com.amazonaws." in svc else svc + vpces.append(Vpce(service_apex=apex, private_dns=private, + source="direct", gated=True)) + elif etype in ("Resource", "ServiceNetwork"): + # Custom-domain shadow the endpoint installs into the consumer VPC. + # Prefer DnsEntries (fully consumer-side); Resource endpoints do not + # populate DnsEntries, so fall back to a TARGETED Get on the ARN this + # endpoint already references (a construct the consumer is associated + # with - RAM-shared configs are readable). This is NOT provider-side + # enumeration and never touches the resource gateway. + src = ("resource-endpoint" if etype == "Resource" else "sn-endpoint") \ + + f":{ep.get('VpcEndpointId','')}" + names = [(de.get("DnsName") or "").lstrip("*.") for de in ep.get("DnsEntries", [])] + names = [n for n in names if n] + if not names and etype == "Resource" and ep.get("ResourceConfigurationArn"): + try: + rc = lattice.get_resource_configuration( + resourceConfigurationIdentifier=ep["ResourceConfigurationArn"] + ) + dom = rc.get("customDomainName") + if dom: + names = [dom] + except Exception: + pass # config not readable from this account -> skip, no guess + for name in names: + vpces.append(Vpce(service_apex=name, private_dns=True, + source=src, gated=False)) + + # --- Route 53 Profiles inherited resources (the union) --- + try: + profile_assocs = r53p.list_profile_associations().get("ProfileAssociations", []) + except Exception: + profile_assocs = [] + for pa in profile_assocs: + if pa.get("ResourceId") != vpc_id: + continue + pid = pa["ProfileId"] + src = f"profile:{pid}" + try: + pras = r53p.list_profile_resource_associations(ProfileId=pid).get("ProfileResourceAssociations", []) + except Exception: + pras = [] + for pr in pras: + rtype = pr.get("ResourceType", "") + if rtype == "ResolverRule": + # Profile-contained rules are typically owned by the sharing + # account and NOT readable from the consumer -> opaque marker. + try: + rule = r53r.get_resolver_rule(ResolverRuleId=pr["ResourceId"])["ResolverRule"] + resolver_rules.append(ResolverRule( + rule.get("DomainName", "."), rule.get("RuleType", "FORWARD"), + ",".join(t.get("Ip", "") for t in rule.get("TargetIps", [])) or "onprem", src, + )) + except Exception: + resolver_rules.append(ResolverRule( + domain="", rule_type="FORWARD", target="", source=src, opaque=True)) + elif rtype == "PrivateHostedZone": + try: + hz = r53.get_hosted_zone(Id=pr["ResourceId"])["HostedZone"] + phzs.append(Phz(zone=hz["Name"], source=src)) + except Exception: + # PHZ owned by the sharing account, not readable here. A PHZ + # with no readable name cannot be matched; record nothing + # resolvable but keep a note via an opaque resolver marker so + # the profile's influence is not silently zero. + resolver_rules.append(ResolverRule( + domain="", rule_type="FORWARD", target="", source=src, opaque=True)) + elif rtype == "FirewallRuleGroup": + try: + frules = r53r.list_firewall_rules(FirewallRuleGroupId=pr["ResourceId"]).get("FirewallRules", []) + except Exception: + firewall_rules.append(FirewallRule( + domains=(), action="BLOCK", block_response="NXDOMAIN", + priority=100, source=src, opaque=True)) + continue + for fr in frules: + try: + dl = r53r.list_firewall_domains( + FirewallDomainListId=fr["FirewallDomainListId"] + ).get("Domains", []) + opaque = False + except Exception: + dl, opaque = [], True + firewall_rules.append(FirewallRule( + tuple(dl), fr.get("Action", "BLOCK"), + fr.get("BlockResponse", "NXDOMAIN"), fr.get("Priority", 100), src, + opaque=opaque, + )) + + return EffectiveModel( + vpc_id=vpc_id, + firewall_rules=tuple(firewall_rules), + resolver_rules=tuple(resolver_rules), + phzs=tuple(phzs), + vpces=tuple(vpces), + snva_preference=snva_preference, + specified_domains=specified_domains, + dns_support=dns_support, + onprem_zones=onprem, + ) + + +def _derive_candidate_names(model: EffectiveModel) -> list[str]: + """Default candidate set from the config itself (no query logs needed).""" + names: set[str] = set() + for r in model.resolver_rules: + if r.domain not in (".", ""): + names.add(r.domain.rstrip(".")) + for p in model.phzs: + names.add(p.zone.rstrip(".")) + for v in model.vpces: + if v.service_apex: + names.add(v.service_apex.rstrip(".")) + for f in model.firewall_rules: + names.update(d.rstrip(".") for d in f.domains) + return sorted(n for n in names if n) + + +@mcp.tool() +def dns_simulate_effective_config( + account_id: str, region: str, vpc_id: str, onprem_zones: list[str] | None = None +) -> str: + """ + Report the VPC's EFFECTIVE DNS resolution config: the union of directly + attached resources and resources inherited via any associated Route 53 + Profile. Read-only. Each construct is tagged with its source (direct vs + profile:). + + Args: + account_id: Target AWS account ID. + region: Target region. + vpc_id: VPC to inspect. + onprem_zones: Optional operator-declared on-prem/corp zones (for + correct category judgement of names that should resolve on-prem). + + Returns: + A markdown summary of resolver rules, PHZ associations, VPCE private-DNS + flags, DNS Firewall rule groups, and Profile-inherited resources. + """ + ok, msg = _preflight(account_id, region, vpc_id) + if not ok: + return msg + session = _assume(account_id, region, READONLY_ROLE_ARN_PATTERN, "dns-sim-config") + m = _build_effective_model(session, vpc_id, onprem_zones) + + def _rows(items, fmt): + return "\n".join(fmt(i) for i in items) if items else "_(none)_" + + return ( + f"**Effective DNS config for {vpc_id}** ({account_id}/{region})\n\n" + f"enableDnsSupport: {m.dns_support} | SNVA preference: {m.snva_preference}" + f"{(' | specified domains: ' + ', '.join(m.specified_domains)) if m.specified_domains else ''}\n\n" + f"**Resolver rules** ({len(m.resolver_rules)}):\n" + f"{_rows(m.resolver_rules, lambda r: f'- `{r.domain}` {r.rule_type} -> {r.target} [{r.source}]')}\n\n" + f"**DNS Firewall rules** ({len(m.firewall_rules)}):\n" + f"{_rows(m.firewall_rules, lambda f: f'- {f.action}/{f.block_response} p{f.priority} on {len(f.domains)} domains [{f.source}]')}\n\n" + f"**PHZ associations** ({len(m.phzs)}):\n" + f"{_rows(m.phzs, lambda p: f'- `{p.zone}` [{p.source}]')}\n\n" + f"**Interface VPCEs** ({len(m.vpces)}):\n" + f"{_rows(m.vpces, lambda v: f'- `{v.service_apex}` privateDns={v.private_dns} [{v.source}]')}" + ) + + +@mcp.tool() +def dns_simulate_change( + account_id: str, + region: str, + vpc_id: str, + change: dict, + candidate_names: list[str] | None = None, + onprem_zones: list[str] | None = None, + volumes: dict | None = None, +) -> str: + """ + Predict which currently-resolving names a proposed DNS control-plane change + would alter or break, BEFORE it is applied. Symbolic and read-only. + + Candidate names default to API-derived (PHZ records, rule target domains, + VPCE service apexes, DNS Firewall domain lists). Supply `volumes` + (name -> query count) from Resolver Query Logs to rank by real traffic; + this is optional enrichment, never required for correctness. + + Change descriptor (validated against a fixed schema, not free text), e.g.: + {"type": "enable_vpce_private_dns", "service_apex": "secretsmanager.us-east-1.amazonaws.com"} + {"type": "add_resolver_rule", "rule_type": "FORWARD", "domain": ".", "target": "onprem"} + {"type": "associate_dns_firewall", "domains": ["bad.example."], "action": "BLOCK"} + {"type": "associate_profile", "profile_id": "rp-...", "resources": {...}} + {"type": "set_snva_preference", "preference": "ALL_DOMAINS"} + + Returns: + A per-name impact report (current -> post-change, delta, traps, + severity), Profile-sourced deltas annotated with the propagation window. + """ + ok, msg = _preflight(account_id, region, vpc_id) + if not ok: + return msg + ctype = (change or {}).get("type") + known = { + "enable_vpce_private_dns", "associate_phz", "add_resolver_rule", + "associate_dns_firewall", "associate_profile", "set_snva_preference", + "set_dhcp_dns", + } + if ctype not in known: + return f"ERROR: unknown change type '{ctype}'. Supported: {', '.join(sorted(known))}." + + session = _assume(account_id, region, READONLY_ROLE_ARN_PATTERN, "dns-sim-change") + model = _build_effective_model(session, vpc_id, onprem_zones) + names = candidate_names or _derive_candidate_names(model) + if not names: + return ( + f"No candidate names to simulate for {vpc_id}. Supply `candidate_names` " + "or enable Resolver Query Logging to derive them." + ) + + vols = {k.lower(): int(v) for k, v in (volumes or {}).items()} + impacts = simulate(model, change, names, vols) + + is_profile = ctype == "associate_profile" + lo, hi = PROFILE_PROPAGATION_SECONDS + header = ( + f"**Pre-change simulation for {vpc_id}** ({account_id}/{region})\n\n" + f"Change: `{json.dumps(change)}`\n" + f"Candidate names: {len(names)} " + f"({'operator-supplied' if candidate_names else 'API-derived'}" + f"{', query-log-ranked' if vols else ''}); " + f"{len(impacts)} impacted.\n" + ) + if is_profile: + header += ( + f"\n> Route 53 Profile change: deltas below propagate asynchronously " + f"(~{lo}s service-side target, up to ~{hi}s negative-cache worst case). " + f"Poll association status = COMPLETE before trusting resolution.\n" + ) + if not impacts: + return header + "\nNo currently-resolving names change or break. ✓" + + lines = [ + "\n| name | before | after | traps | severity | vol |", + "| --- | --- | --- | --- | --- | --- |", + ] + icon = {"high": "🔴", "medium": "🟡", "none": "🟢"} + for i in impacts: + lines.append( + f"| `{i.name}` | {i.before.answer_class} ({i.before.winner}) " + f"| {i.after.answer_class} ({i.after.winner}) " + f"| {', '.join(i.traps) or '-'} | {icon.get(i.severity,'')} {i.severity} | {i.volume or '-'} |" + ) + return header + "\n".join(lines) + + +# ============================================================ +# SOP runbooks (bundled with the deployment package) +# ============================================================ + +SOP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sops") + +# Slug -> one-line purpose. Kept explicit (rather than parsed from the files) so +# the catalogue is stable and cheap to return. +SOP_CATALOGUE: dict[str, str] = { + "Z-general-triage": ( + "START HERE for a vague DNS symptom. Precondition checks, then routes to " + "the specific failure-mode runbook." + ), + "A-critical-safety-rules": ( + "Non-negotiable rules constraining every recommendation. Read before " + "advising any DNS change." + ), + "A-mode-a-live-resolver-comparison": ( + "Mode A workflow: how to run and read a live multi-resolver comparison " + "inside an instance, and its SSM prerequisites." + ), + "A-name-category-classification": ( + "Judge correctness rather than agreement: expected winner and fault " + "condition per name category." + ), + "A-custom-resolver-divergence": ( + "Instance is not using the VPC-handed resolver, or a custom/hybrid " + "resolver returns different answers than the VPC resolver." + ), + "A-forward-vs-phz-precedence-collision": ( + "Internal zone name TIMES OUT while public names resolve: a FORWARD rule " + "outranking a private hosted zone." + ), + "A-address-family-divergence": ( + "A resolves but AAAA is empty, or an IPv4 resolver is absent on an " + "IPv6-only instance." + ), + "A-resolver-disabled-precondition": ( + "Every name fails from every instance: the enableDnsSupport / " + "enableDnsHostnames VPC-attribute precondition." + ), + "B-mode-b-pre-change-validation": ( + "Mode B workflow: predict which resolving names a proposed DNS change " + "would break, before applying it." + ), + "B-vpce-shadow-nxdomain": ( + "Enabling VPC endpoint private DNS shadows a service apex, so a name " + "that resolved publicly now returns NXDOMAIN." + ), + "B-broad-forward-sweep": ( + "A '.' or broad-suffix FORWARD rule captures AWS FQDNs and PHZ names " + "with no SYSTEM carve-out." + ), + "B-flag-and-mismatch": ( + "privateDnsEnabled AND PrivateDnsPreference leave a Lattice custom " + "domain uninstalled; create-only flag caveats." + ), + "B-dns-firewall-block": ( + "A DNS Firewall rule blocks a name before resolution completes, " + "including the cross-account opaque-domain-list case." + ), + "B-profile-propagation-timing": ( + "Route 53 Profile association shifts config in bulk and propagates " + "asynchronously (~300-350s, up to ~900s negative cache)." + ), + "C-cross-account-opaque-constructs": ( + "RAM-shared and Profile-contained constructs that are enumerable but " + "opaque; how to report 'cannot determine' correctly." + ), + "C-limitations-and-boundaries": ( + "What each mode cannot tell you, and the honest-reporting checklist to " + "apply before concluding." + ), +} + + +@mcp.tool() +def list_sops() -> str: + """ + List the available DNS diagnostic runbooks (SOPs) with a one-line purpose + for each. Call this first when you are unsure which procedure applies, then + fetch the relevant one with get_sop. + + Slug prefixes: Z = start-here triage, A = live diagnosis / safety rules, + B = pre-change validation, C = cross-cutting concerns. + + Returns: + A markdown table of runbook slugs and their purpose. + """ + lines = [ + "# Available DNS diagnostic runbooks", + "", + "Fetch one with `get_sop(slug)`. If the symptom is vague, start with " + "`Z-general-triage`.", + "", + "| slug | purpose |", + "| --- | --- |", + ] + for slug, purpose in SOP_CATALOGUE.items(): + lines.append(f"| `{slug}` | {purpose} |") + return "\n".join(lines) + + +@mcp.tool() +def get_sop(slug: str) -> str: + """ + Retrieve the full text of one DNS diagnostic runbook (SOP) by slug. + + Args: + slug: Runbook slug exactly as returned by list_sops (for example + 'Z-general-triage'). Do not include a path or file extension. + + Returns: + The runbook markdown, or an error listing the valid slugs. + """ + # Allowlist lookup: the slug must be a known catalogue key. This rejects any + # path traversal or absolute path outright - no filename is ever built from + # unvalidated caller input. + if slug not in SOP_CATALOGUE: + valid = ", ".join(sorted(SOP_CATALOGUE)) + return ( + f"ERROR: unknown runbook slug '{slug}'.\n\n" + f"Valid slugs: {valid}\n\n" + "Call list_sops() for the catalogue with descriptions." + ) + + path = os.path.join(SOP_DIR, f"{slug}.md") + # Defence in depth: confirm the resolved path stayed inside SOP_DIR. + if os.path.commonpath([os.path.realpath(path), os.path.realpath(SOP_DIR)]) != os.path.realpath(SOP_DIR): + return f"ERROR: refusing to read outside the runbook directory: '{slug}'" + + try: + with open(path, encoding="utf-8") as fh: + return fh.read() + except FileNotFoundError: + return ( + f"ERROR: runbook '{slug}' is catalogued but its file is missing from " + "the deployment package. This is a packaging bug." + ) + except OSError as exc: + return f"ERROR: could not read runbook '{slug}': {exc}" + + +# ============================================================ +# Entry point (Streamable HTTP via Lambda Web Adapter) +# ============================================================ + +try: + handler = mcp.streamable_http_handler() +except AttributeError: + # FastMCP 3.x: use the ASGI app for the Lambda Web Adapter. + handler = mcp.http_app() + +if __name__ == "__main__": + # Local testing. + mcp.run(transport="streamable-http", host="0.0.0.0", port=8000) diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-address-family-divergence.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-address-family-divergence.md new file mode 100644 index 0000000..889ba3b --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-address-family-divergence.md @@ -0,0 +1,47 @@ +# A — Address-Family Divergence (A vs AAAA, IPv4 vs IPv6 resolvers) + +## Two distinct issues share this symptom + +### Issue 1 — the record exists for one family only + +`db.internal.corp` returns an A record but an empty AAAA, or vice versa. The +resolver path is healthy; the zone simply has no record for the other family. +A private hosted zone holding only A records behaves exactly this way. + +This is a **data** problem, not a resolution problem. Confirm by probing a name +known to have both families (a dualstack public name); if that returns both, the +resolver path is fine and the gap is in the zone. + +Impact depends on the client. An application with `AI_ADDRCONFIG` or a +happy-eyeballs implementation usually falls back cleanly. One that requests AAAA +exclusively fails. Ask which behavior the caller has before ranking severity. + +### Issue 2 — the resolver address for one family does not exist + +In an IPv6-only subnet there is no `169.254.169.253`. Only `fd00:ec2::253` +answers. In a dualstack VPC both answer. Probing the IPv4 resolver from an +IPv6-only instance times out — **that is expected**, not a fault. + +## Diagnosis + +1. `dns_probe_context` — read the instance addressing family. This determines + which resolver addresses can exist at all. +2. `dns_probe_compare` — probe both families. Compare per-family results per + resolver. + +## Interpretation + +| Observation | Reading | +| --- | --- | +| A answers, AAAA empty, both resolvers agree | Zone has no AAAA record. Data gap. | +| A answers, AAAA empty, only on a custom resolver | The custom resolver is not forwarding AAAA for that zone. | +| IPv4 resolver times out on an IPv6-only instance | Expected. Not a fault. | +| Both resolver families time out | Resolver path problem. Check `enableDnsSupport` first — see `A-resolver-disabled-precondition`. | +| AAAA answers, A empty for an AWS FQDN | Verify the endpoint's configured address family; a dualstack endpoint is required for both. | + +## Reporting rule + +Never report the absent IPv4 resolver as a fault on an IPv6-only instance, and +never report an empty AAAA as a resolver failure without first checking whether +the zone holds a record for that family. State which family you tested; a claim +of "DNS works" that only covers A is incomplete on a dualstack VPC. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-critical-safety-rules.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-critical-safety-rules.md new file mode 100644 index 0000000..a3872ce --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-critical-safety-rules.md @@ -0,0 +1,59 @@ +# A — Critical Safety Rules (apply to ALL recommendations) + +These rules constrain every recommendation this server's output feeds into. They +are not optional heuristics. + +## 1. Never recommend applying a DNS change without running Mode B first + +If the proposed change touches a VPC endpoint private-DNS flag, a Route 53 +Resolver rule, a private hosted zone, a DNS Firewall rule group, or a Route 53 +Profile association, predict the blast radius before acting. Call +`dns_simulate_change` and report which currently-resolving names would break. + +## 2. Agreement between resolvers is not the goal — the correct answer is + +For an on-premises or corporate zone, the custom resolver *should* win and the +VPC resolver *should* return NXDOMAIN. That is correct behavior, not a fault. +Never flag divergence as a problem without first classifying the name. See +`A-name-category-classification`. + +## 3. Check the VPC-attribute precondition first + +If `enableDnsSupport=false`, the VPC resolver is intentionally dark. Lead with +that finding. Do not diagnose it as a routing or security-group problem. + +## 4. Never rely on the public internet path for SSM + +Mode A requires SSM connectivity through VPC interface endpoints for `ssm`, +`ssmmessages`, and `ec2messages`. If SSM is unreachable, report that as the +blocker. Do not propose opening egress or attaching a public path as a workaround +to make the diagnostic run. + +An EC2 Instance Connect Endpoint does **not** satisfy this requirement. Run +Command depends on the SSM Agent polling outbound to `ssmmessages` and +`ec2messages`; EICE is an inbound interactive tunnel and carries no SSM +control-plane traffic. If an instance has EICE but is `Not connected` in SSM, the +remediation is to add the three interface endpoints, not to reach for EICE. + +## 5. Respect address family + +In IPv6-only subnets `169.254.169.253` does not exist; only `fd00:ec2::253` +answers. Do not report the absent IPv4 resolver as a fault in that context. + +## 6. Route 53 Profile changes propagate asynchronously + +Service-side propagation runs roughly 300–350 seconds, with a worst case up to +about 900 seconds when a premature query populates a negative cache. State +*when* a change takes effect, not only what the end state will be. See +`B-profile-propagation-timing`. + +## 7. Mode A observes one instance + +Results reflect the resolver path of the single instance probed, including its +`resolv.conf` and any local stub resolver. Do not generalize to the whole VPC +without probing an instance in each relevant subnet. + +## 8. Never present a Mode B prediction as an observed fact + +Mode B is symbolic. Say "this change is predicted to break N names" and, where +it matters, confirm with Mode A before the operator acts. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-custom-resolver-divergence.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-custom-resolver-divergence.md new file mode 100644 index 0000000..9279ade --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-custom-resolver-divergence.md @@ -0,0 +1,53 @@ +# A — Custom / Hybrid Resolver Divergence + +## Symptom + +The same name returns different answers depending on which resolver is queried, +or an application resolves a name differently than a manual `dig` against the +VPC resolver suggests it should. + +## Why describe APIs cannot find this + +The DHCP option set says which resolver the VPC *hands out*. It does not say +which resolver the instance is *using*. An instance can point `resolv.conf` at +`127.0.0.1` (a local dnsmasq, unbound, or systemd-resolved stub), at a domain +controller, or at an on-premises forwarder — none of which is visible from any +AWS API. Only an in-instance probe resolves the ambiguity. + +## Diagnosis + +1. `dns_probe_context` — record the DHCP-handed resolver (VPC-intended). +2. `dns_probe_compare` — record the instance's actual `resolv.conf` and the + per-resolver answer matrix. +3. Compare the two. A mismatch means the instance is not using the VPC-handed + resolver. +4. Read `hostname.bind` per resolver to identify each responder. +5. Check the OS-effective answer (`getent`), which follows the real NSS path + including `resolv.conf` order, `nsswitch.conf`, and `/etc/hosts`. The + OS-effective answer is what the application gets — it can differ from every + individual `dig` result. + +## Interpretation + +| Pattern | Reading | +| --- | --- | +| Custom resolver answers a corporate zone; VPC resolver NXDOMAINs | Correct by design. Not a fault. | +| Custom resolver NXDOMAINs a PHZ name the VPC resolver answers | The custom resolver does not forward that zone back to the VPC resolver. Add a forward for the PHZ zone. | +| Custom resolver returns a public IP for an AWS FQDN with a VPC endpoint present | The endpoint is being bypassed. The custom resolver must forward `amazonaws.com` to the VPC resolver. See `B-vpce-shadow-nxdomain`. | +| Both answer, different private IPs | Two authoritative sources for one zone. Determine which is intended; report both. | +| `getent` disagrees with every `dig` | Inspect `/etc/hosts`, `nsswitch.conf`, and a local stub resolver's cache. | + +## Resolver address caveat + +When a local stub resolver forwards upstream, the link-local VPC resolver +address (`169.254.169.253`) may not answer directly from the instance while the +VPC+2 address does. If a probe against the link-local address times out but the +VPC+2 address answers, treat that as a local resolver-path artifact rather than +evidence the VPC resolver is down. Probe both before concluding. + +## Remediation shape + +Do not recommend removing the custom resolver as a first move — it usually +exists for corporate zone resolution. Recommend the specific forward rules that +make both zone sets resolve: corporate zones to the custom resolver, +`amazonaws.com` and PHZ zones back to the VPC resolver. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-forward-vs-phz-precedence-collision.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-forward-vs-phz-precedence-collision.md new file mode 100644 index 0000000..38091a5 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-forward-vs-phz-precedence-collision.md @@ -0,0 +1,62 @@ +# A — FORWARD-vs-PHZ Precedence Collision + +## Symptom + +An internal zone name **times out** rather than returning NXDOMAIN, while public +names and AWS service FQDNs continue to resolve normally. A private hosted zone +for the affected zone exists and is associated with the VPC, so the name "should" +resolve. + +## Cause + +A Resolver FORWARD rule and a private hosted zone both claim the same domain. A +specific FORWARD rule **outranks** an associated PHZ in the resolution +precedence order. The query is therefore sent to the FORWARD rule's target +resolver instead of being answered from the PHZ. If that target is unreachable or +does not host the zone, the query times out. + +The timeout signature is diagnostic. A missing record yields NXDOMAIN quickly; a +forward to a dead target hangs until it times out. + +## Diagnosis + +1. `dns_simulate_effective_config` — inventory the VPC's effective configuration + and look for a FORWARD rule whose domain equals or is a parent of the PHZ zone + name. +2. `dns_probe_compare` — confirm the timeout, and confirm that names outside the + contested zone still resolve. That narrows the fault to one zone rather than + the resolver path as a whole. +3. Check the FORWARD rule's target IPs for reachability from the outbound + endpoint's subnets, including security group egress on UDP/TCP 53. + +## Precedence order (highest to lowest) + +1. DNS Firewall (BLOCK or OVERRIDE, applied before resolution completes) +2. Specific FORWARD rule +3. SYSTEM rule +4. VPC endpoint private DNS +5. Associated private hosted zone +6. Service network VPC association `PrivateDnsPreference` gate (AND-ed with + `privateDnsEnabled`) +7. VPC resolver recursion (default) + +The PHZ sits at level 5, below the FORWARD rule at level 2. This ordering is why +the collision resolves in favor of the forward. + +## Remediation + +Choose one: + +- **Narrow the FORWARD rule** so it no longer covers the PHZ zone. Preferred when + the PHZ is intended to be authoritative. +- **Add a SYSTEM rule** for the specific PHZ zone. A SYSTEM rule at level 3 + outranks the PHZ but is itself outranked by a more specific FORWARD, so verify + specificity carefully. +- **Separate the domains** so the forward targets a distinct zone (for example, + forward `onprem.corp` while the PHZ serves `internal.corp`). + +## Validate before applying + +Run `dns_simulate_change` with the proposed rule modification. Narrowing a +FORWARD rule can un-break this zone while breaking names that legitimately +depended on the broader sweep. See `B-broad-forward-sweep`. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-mode-a-live-resolver-comparison.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-mode-a-live-resolver-comparison.md new file mode 100644 index 0000000..1aab96d --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-mode-a-live-resolver-comparison.md @@ -0,0 +1,73 @@ +# A — Mode A: Live Multi-Resolver Comparison + +## When to use + +- A name resolves differently than expected from an instance. +- The environment runs a custom or hybrid resolver (Active Directory DNS, + Infoblox, an on-premises forwarder, a local dnsmasq/unbound stub). +- A PrivateLink or VPC endpoint FQDN returns a public IP or NXDOMAIN + unexpectedly. +- You need ground truth. The EC2, VPC, and Route 53 describe APIs return DNS + *configuration*. They never return what a name actually resolves to from a + given subnet right now, or which resolver answered. Mode A does. + +## SSM prerequisites + +The probe executes inside the instance via SSM Run Command. It requires: + +- SSM Agent running, with `AmazonSSMManagedInstanceCore` (or equivalent) on the + instance role. +- SSM reachability through VPC interface endpoints for `ssm`, `ssmmessages`, and + `ec2messages`. + +An EC2 Instance Connect Endpoint does not satisfy the third requirement. Run +Command works by the agent polling outbound to `ssmmessages` and `ec2messages`; +EICE is an inbound SSH/RDP tunnel and carries no SSM control-plane traffic. An +instance reachable only by EICE shows as `Not connected` in SSM and cannot be +probed. + +If SSM is unreachable, report that as the blocker. Do not route around it. + +## Workflow + +### Step 1 — Establish the precondition + +Call `dns_probe_context(account_id, region, instance_id)`. Read: + +- `enableDnsSupport` / `enableDnsHostnames` — if support is false, stop and see + `A-resolver-disabled-precondition`. +- Instance addressing family — determines which resolver addresses can exist. +- DHCP option set `domain-name-servers` — the **VPC-intended** resolver. + +### Step 2 — Compare resolvers + +Call `dns_probe_compare(account_id, region, instance_id, name)`. With +`include_dhcp_dns=true` (the default) the DHCP-configured resolvers are added +automatically, expanding `AmazonProvidedDNS` to the VPC resolver address +appropriate for the instance's stack. Pass extra `resolvers` only to compare +additional targets, such as a Resolver outbound endpoint IP or an allowlisted +on-premises resolver. Both A and AAAA are probed per resolver. + +### Step 3 — Confirm who answered + +Read the `hostname.bind` identity line. This distinguishes "the VPC resolver +answered" from "a local stub answered and happened to return the same thing." +Never infer the responder from the answer alone. + +### Step 4 — Compare intended vs actual + +If the DHCP-handed resolver from Step 1 differs from the instance's actual +`resolv.conf`, the instance is not using the resolver the VPC hands out. That +mismatch is frequently the root cause. See `A-custom-resolver-divergence`. + +### Step 5 — Judge against the name category + +Classify the name and check the observed winner against the expected winner. See +`A-name-category-classification`. + +## What Mode A cannot tell you + +- Anything about instances other than the one probed. +- Whether a *future* change is safe — that is Mode B. +- Provider-side configuration of constructs shared into this account. See + `C-cross-account-opaque-constructs`. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-name-category-classification.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-name-category-classification.md new file mode 100644 index 0000000..ad87f12 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-name-category-classification.md @@ -0,0 +1,46 @@ +# A — Name-Category Classification (judge correctness, not agreement) + +Divergence between resolvers is not inherently a fault. Classify the name first, +then check the observed winner against the expected winner. + +## Decision table + +| Category | Expected winner | Fault condition | +| --- | --- | --- | +| AWS service FQDN, VPC endpoint present | resolver forwards `amazonaws.com` to the VPC resolver, which returns the endpoint ENI private IP | a custom resolver returns a **public** IP (endpoint bypassed) | +| AWS service FQDN, no VPC endpoint | public IP | swept to on-premises or NXDOMAIN (over-broad FORWARD rule) | +| Private hosted zone / VPC-internal | private IP from the PHZ | custom resolver NXDOMAINs because it does not forward the PHZ zone back to the VPC resolver | +| On-premises / corporate zone | the custom or on-premises resolver answers; the VPC resolver NXDOMAIN is **correct** | the VPC resolver leaks an answer, **or** the custom resolver NXDOMAINs | +| Public | all resolvers agree | disagreement indicates split-horizon, a stale cache, or hijack | + +## How to classify + +1. **AWS service FQDN** — matches an AWS service endpoint pattern such as + `..amazonaws.com`. Determine whether an interface endpoint + for that service exists in the VPC, and whether its private DNS is enabled; + that decides which of the two AWS rows applies. +2. **Private hosted zone name** — falls within the zone name of a PHZ associated + with the VPC. +3. **On-premises / corporate zone** — falls within a zone targeted by a FORWARD + rule pointing at a non-AWS resolver. +4. **Public** — everything else. + +## Worked example + +An instance runs a local split-horizon resolver. `db.internal.corp` returns +`10.42.200.99` via the custom resolver and `10.42.200.10` via the VPC resolver, +where the PHZ holds the `.10` record. `getent` returns `.99`, so the OS path +follows the custom resolver. + +This is *not* automatically a fault. Two readings are possible: + +- If the custom resolver is authoritative for `internal.corp` by design, `.99` + is correct and the PHZ record is redundant or stale. +- If the PHZ is meant to be authoritative, the custom resolver is shadowing it + and should forward that zone back to the VPC resolver. + +Resolve the ambiguity by asking which source is intended to be authoritative. +Report both answers and the OS-effective result rather than picking one. + +Note that no describe API surfaces this divergence — only an in-instance probe +does. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-resolver-disabled-precondition.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-resolver-disabled-precondition.md new file mode 100644 index 0000000..22bc609 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/A-resolver-disabled-precondition.md @@ -0,0 +1,52 @@ +# A — Resolver Disabled: the VPC-attribute precondition + +## Why this is checked first + +If `enableDnsSupport=false` on the VPC, the Amazon-provided resolver does not +answer at all. Every name fails, from every instance in the VPC, regardless of +private hosted zones, Resolver rules, or endpoint configuration. Diagnosing this +as a security-group, route-table, or rule-precedence problem wastes the +operator's time and can lead to changes that are not related to the cause. + +`dns_probe_context` reads this attribute before any resolution is attempted. + +## The two attributes + +| Attribute | When false | +| --- | --- | +| `enableDnsSupport` | The VPC resolver does not answer. Total resolution failure inside the VPC. Private hosted zones cannot resolve. | +| `enableDnsHostnames` | Instances do not receive public DNS hostnames. Resolution still works. Private hosted zone resolution requires **both** attributes to be enabled. | + +## Signature + +Every name fails from every instance, including public names, with no successful +resolver identity line. Contrast with a scoped failure — one zone failing while +public names resolve points at a rule or PHZ issue instead. + +## Remediation + +Enabling `enableDnsSupport` is a VPC-wide change. Note two consequences before +recommending it: + +- It affects every instance in the VPC, not only the one under investigation. +- If the VPC was deliberately configured with the resolver dark (some + environments do this to force all resolution through a custom resolver), + enabling it changes the intended security posture. Confirm intent before + recommending the change. + +If a custom resolver is intended to serve the VPC entirely, the correct fix may +be to leave `enableDnsSupport=false` and repair the custom resolver path instead. +Verify which design was intended. + +## Simulation note + +Mode B models this condition: a change that turns the VPC resolver dark is +reported by the `resolver-disabled` trap detector, which predicts NXDOMAIN for +affected names. See `B-mode-b-pre-change-validation`. + +## Testing caveat + +This condition is difficult to demonstrate safely in a live fixture, because +disabling `enableDnsSupport` is VPC-wide and severs SSM connectivity to every +instance in that VPC — including the instance you would use to observe the +effect. Verify it by reading the attribute rather than by inducing it. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-broad-forward-sweep.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-broad-forward-sweep.md new file mode 100644 index 0000000..a374cd2 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-broad-forward-sweep.md @@ -0,0 +1,66 @@ +# B — Broad FORWARD Sweep + +## Mechanism + +A FORWARD rule for `.` (the root) or for a broad suffix such as `amazonaws.com` +captures every name beneath it and sends those queries to an on-premises or +custom resolver. A specific FORWARD rule sits at precedence level 2 — above +SYSTEM rules, endpoint private DNS, and private hosted zones. Anything the sweep +covers is diverted before those lower levels are consulted. + +If the target resolver does not host the swept zones, the names time out. If it +answers with public-internet results, AWS service traffic silently bypasses VPC +endpoints and takes the public path. + +## Signature + +- AWS service FQDNs time out or NXDOMAIN after a hybrid DNS change +- Internal PHZ names time out while public names still resolve +- Endpoint private IPs stop being returned even though the endpoint is healthy +- A timeout rather than a fast NXDOMAIN, indicating a forward to an unresponsive + target + +## The carve-out pattern + +A `.` FORWARD rule requires SYSTEM carve-outs for everything that must stay with +the VPC resolver. A SYSTEM rule at level 3 returns the query to VPC recursion, +but it is outranked by any *more specific* FORWARD rule, so specificity matters +more than rule order. + +Typical carve-outs: + +| Carve-out | Why | +| --- | --- | +| `amazonaws.com` | keeps AWS service FQDNs and endpoint private DNS resolving | +| Alternate AWS service domains in use | a differently-suffixed service alias is not covered by an `amazonaws.com` carve-out | +| Each associated PHZ zone | a broad sweep otherwise outranks the PHZ | +| Service-managed endpoint zones | private DNS zones installed by interface endpoints | + +The second row is the one most often missed. A team adopts an alternate service +FQDN to work around an endpoint shadow (see `B-vpce-shadow-nxdomain`), then a +root FORWARD rule with only an `amazonaws.com` carve-out sweeps that alternate +name on-premises. Each change looks correct in isolation. + +## Detection + +`dns_simulate_change` reports the `broad-FORWARD-sweep` trap, lists the AWS FQDNs +and PHZ names that lose resolution, and identifies the missing carve-outs. + +## Remediation + +1. Prefer narrowing the FORWARD rule to the specific corporate zones that need + on-premises resolution, rather than forwarding `.` and carving back. +2. Where a root forward is required, add a SYSTEM rule for each domain that must + stay local, including alternate AWS service domains and every associated PHZ + zone. +3. Re-run `dns_simulate_change` after drafting the carve-outs. Narrowing a sweep + can restore one set of names while breaking names that relied on the broad + forward — the same collision described in + `A-forward-vs-phz-precedence-collision`. + +## Confirm with Mode A + +After applying carve-outs, probe one name per category — an AWS service FQDN, a +PHZ name, a corporate zone name, and a public name — and read `hostname.bind` on +each to confirm the intended resolver answered. Configuration that looks correct +can still route unexpectedly. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-dns-firewall-block.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-dns-firewall-block.md new file mode 100644 index 0000000..851ab57 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-dns-firewall-block.md @@ -0,0 +1,57 @@ +# B — DNS Firewall Block + +## Mechanism + +DNS Firewall evaluates at the top of the precedence order, before resolution +completes. A BLOCK action returns NXDOMAIN, NODATA, or an OVERRIDE answer +regardless of what any private hosted zone, endpoint private DNS, or Resolver +rule would have returned. Nothing downstream can recover the query. + +Because the block wins first, the symptom is indistinguishable from a missing +record unless you inspect the firewall configuration or the query logs. + +## Signature + +- A name returns NXDOMAIN immediately, with correct configuration everywhere else +- The name resolves from an instance in a VPC without the rule group associated +- Query logs show a BLOCK action for the name + +The second point is the cheapest discriminator when a comparable VPC exists. + +## Detection + +`dns_simulate_change` reports the `DNS-Firewall-block` trap when a rule-group +change blocks a candidate name. Report the domain list and rule that matched. + +## Cross-account limitation — read this before concluding + +When a DNS Firewall rule group is shared into the account via AWS RAM, the +association and its rules are visible, but the **domain lists are not**. The +`list_firewall_domains` call is denied cross-account, so the consumer cannot +enumerate which domains are blocked. + +The server models this as an `OPAQUE` answer class rather than crashing or +silently reporting the name as unaffected. An opaque firewall rule is treated as +OPAQUE **first**, because a hidden block list may cover any name. + +Practical consequence: when a shared rule group is present and opaque, you cannot +prove a name is unblocked from the consumer side. Say so explicitly. Do not +report "not blocked" when the correct statement is "cannot determine from this +account." See `C-cross-account-opaque-constructs`. + +## Diagnosis + +1. `dns_simulate_effective_config` — identify associated rule groups and whether + any are opaque. +2. If the rule group is owned locally, read the domain lists and match the name. +3. If opaque, ask the rule-group owner to confirm, or resolve the same name from + a VPC without the association. +4. `dns_probe_compare` — a fast, uniform NXDOMAIN across all resolvers is + consistent with a firewall block; per-resolver divergence points elsewhere. + +## Remediation + +Removing a domain from a block list is a security-relevant change. Confirm with +the rule group's owner rather than recommending removal directly, and prefer a +scoped exception over disabling the rule. If the block is intentional, the +correct outcome is to change the workload, not the firewall. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-flag-and-mismatch.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-flag-and-mismatch.md new file mode 100644 index 0000000..b7cd326 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-flag-and-mismatch.md @@ -0,0 +1,61 @@ +# B — Flag-AND Mismatch (privateDnsEnabled × PrivateDnsPreference) + +## Mechanism + +Two independent flags govern whether a custom domain is published into a +consumer VPC through VPC Lattice, and they are **AND-ed**. Setting one without +the other leaves the domain uninstalled, while both the resource configuration +and the service network association appear correctly configured. + +| Flag | Level | Effect | +| --- | --- | --- | +| `privateDnsEnabled` | service network resource association | publishes the resource configuration's custom domain into the consumer VPC | +| `PrivateDnsPreference` | service network VPC association | gates which domains may be overridden in that VPC | + +If `privateDnsEnabled=false`, the custom domain is not installed **even when** +the VPC association permits all domains. Conversely, a permissive preference does +nothing on its own. + +## PrivateDnsPreference values + +| Value | Behavior | +| --- | --- | +| `VERIFIED_DOMAINS_ONLY` (default) | blocks AWS-owned FQDNs from being overridden | +| `SPECIFIED_DOMAINS_ONLY` | scoped middle ground; only listed domains | +| `ALL_DOMAINS` | forces override for any domain, including AWS FQDNs | + +Under `ALL_DOMAINS`, only **published** resource-configuration domains are +redirected. An unpublished AWS service name is not black-holed; it times out. + +## Immutability warning + +Several of these properties are create-only. `privateDnsEnabled` on a service +network resource association has no update API. On the VPC association, +`PrivateDnsEnabled` and DNS options are likewise create-only, so correcting them +requires delete and recreate — in CloudFormation, with a new logical ID. Plan the +change as a replacement, not an in-place update, and account for the resolution +gap during recreation. + +Note also that `privateDnsEnabled` is set to true automatically when a custom +domain name is present at create time. + +## Misleading field + +The `privateDnsEntry.domainName` field on a service network resource association +is populated **even when private DNS is disabled**. Do not treat its presence as +evidence that the domain is published. Verify the flag itself, and confirm +resolution with Mode A. + +## Detection + +`dns_simulate_change` reports the `flag-AND-mismatch` trap when a proposed change +leaves the two flags in a combination that does not install the intended domain. + +## Diagnosis and remediation + +1. `dns_simulate_effective_config` — read both flags as they currently stand. +2. `dns_probe_compare` on the custom domain. NXDOMAIN or a timeout, with a + populated `privateDnsEntry.domainName`, confirms the mismatch. +3. Set both flags consistently. Because they are create-only, schedule the + recreate and validate afterwards with Mode A rather than assuming the change + took effect. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-mode-b-pre-change-validation.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-mode-b-pre-change-validation.md new file mode 100644 index 0000000..97894a8 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-mode-b-pre-change-validation.md @@ -0,0 +1,87 @@ +# B — Mode B: Pre-Change Validation + +## When to use + +Before any of the following is applied: + +- Enabling private DNS on a VPC interface endpoint +- Associating or disassociating a private hosted zone +- Adding, altering, or removing a Route 53 Resolver rule +- Associating a DNS Firewall rule group +- Associating or disassociating a Route 53 Profile +- Changing the VPC DHCP option set's `domain-name-servers` + +Also use it whenever the operator asks "what will break if I make this change?" + +## The gap this fills + +The highest-impact DNS outages are invisible before the change. The +configuration looks correct, the change looks additive, and the breakage only +appears once a name that used to resolve stops resolving. Mode B builds the +effective model, applies the proposed change symbolically, and diffs the +resolution outcome per name — without touching anything. + +Mode B is **read-only**. It performs describe, get, and list calls only, on a +role that never holds `ssm:SendCommand`. + +## Workflow + +### Step 1 — Inventory the effective configuration + +Call `dns_simulate_effective_config(account_id, region, vpc_id)`. This returns +the union of directly attached and Route 53 Profile-inherited constructs, each +tagged with its source (`direct` or `profile:`). Read the source tags: a +construct inherited from a Profile may be changed by a Profile owner outside this +account's control. + +### Step 2 — Simulate the change + +Call `dns_simulate_change(account_id, region, vpc_id, change, ...)` with the +structured change descriptor. Candidate names default to those derived from +configuration. Supply an explicit `candidate_names` list to focus the analysis on +names the operator cares about, and `volumes` to weight the ranking. + +### Step 3 — Read the traps before the diff + +A triggered trap detector is more informative than the raw name diff, because it +names the *mechanism*. Six detectors run: + +| Detector | Meaning | Runbook | +| --- | --- | --- | +| `VPCE-shadow-NXDOMAIN` | Enabling private DNS shadows a service apex still queried the old way | `B-vpce-shadow-nxdomain` | +| `broad-FORWARD-sweep` | A new `.` or `amazonaws.com` FORWARD rule captures AWS FQDNs with no SYSTEM carve-out | `B-broad-forward-sweep` | +| `flag-AND-mismatch` | `privateDnsEnabled` and `PrivateDnsPreference` combine to leave a custom domain uninstalled | `B-flag-and-mismatch` | +| `DNS-Firewall-block` | A rule-group change blocks a candidate name | `B-dns-firewall-block` | +| `Profile-union-shift` | An association change shifts the effective set in bulk | `B-profile-propagation-timing` | +| `resolver-disabled` | A DHCP change turns the VPC resolver dark | `A-resolver-disabled-precondition` | + +### Step 4 — Rank and report + +Any triggered trap escalates the finding to high severity; ties break on query +volume. Report the mechanism, the affected names, and the propagation window +where a Profile is involved. + +### Step 5 — Confirm with Mode A where it matters + +For a high-severity prediction on a name the operator cannot afford to lose, +confirm current ground truth with `dns_probe_compare` before the change, and +again after. The simulator models documented precedence; observed behavior wins. + +## Precedence model used + +1. DNS Firewall (BLOCK / OVERRIDE) +2. Specific FORWARD rule +3. SYSTEM rule +4. VPC endpoint private DNS +5. Associated private hosted zone +6. Service network VPC association `PrivateDnsPreference` gate (AND-ed with + `privateDnsEnabled`) +7. VPC resolver recursion (default) + +## Coverage limit — state this to the operator + +Mode B's coverage equals its candidate name set. A name that appears in neither +the configuration nor the operator-supplied list is not simulated, and its +absence from the report is not evidence that it is safe. Query logs, when +available, are optional enrichment used for volume ranking — they are not +required for correctness. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-profile-propagation-timing.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-profile-propagation-timing.md new file mode 100644 index 0000000..5bd5033 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-profile-propagation-timing.md @@ -0,0 +1,68 @@ +# B — Route 53 Profile Propagation and Union Shift + +## Two effects, one change + +Associating or disassociating a Route 53 Profile does two things at once: it +shifts the effective configuration in bulk (the `Profile-union-shift` trap), and +it takes effect asynchronously rather than immediately. + +## Union shift + +The effective model is the union of directly attached constructs and +Profile-inherited ones. Associating a Profile can add resolver rules, private +hosted zones, and firewall rule groups in a single operation — any of which may +outrank a directly attached construct and change what an existing name resolves +to. Disassociating removes them just as broadly. + +`dns_simulate_effective_config` tags every construct with its source (`direct` or +`profile:`). Read those tags before recommending a Profile change: a +Profile-sourced construct can be altered by the Profile's owner, outside this +account's control, with the same bulk effect and no local change record. + +## Propagation timing + +Propagation runs through a multi-stage asynchronous pipeline: + +| Stage | Typical window | +| --- | --- | +| Service-side propagation | ~300–350 seconds | +| Negative-cache worst case | up to ~900 seconds | + +The worst case occurs when a query fires before propagation completes: the +negative answer is cached against the zone's SOA minimum TTL, and the name keeps +failing after the configuration is already correct. Total observed delay can +therefore reach roughly 20 minutes even though service-side work finished in +about 5. + +Report *when* a change takes effect, not only the end state. An operator who +tests at 60 seconds and sees failure will often revert a change that was working. + +## Guidance to give the operator + +1. Poll the association status API until it reports complete before testing. + Do not test on a timer. +2. Avoid querying the affected names before propagation completes, to keep a + negative answer out of cache. +3. Where the zone is under your control, lower the SOA minimum TTL in advance to + shrink the negative-cache window. +4. Flush client caches after propagation. On Kubernetes with CoreDNS, restart the + CoreDNS deployment; CoreDNS applies its own cache on top of the resolver's. + +## Detection + +`dns_simulate_change` reports `Profile-union-shift` for association and +disassociation changes and annotates the affected deltas with the propagation +window. Profile-sourced deltas always carry the timing annotation — surface it in +the report rather than only the name diff. + +## Cross-account limitation + +Profile **contents** are opaque to a consumer account. A profile-contained +resolver rule or private hosted zone is enumerable, but `get_resolver_rule` and +`get_hosted_zone` are denied cross-account. No Route 53 Profiles API action +exposes a profile's zone, record, or rule contents to a consumer. + +"Enumerable but opaque" is the complete and correct model. When simulating a +Profile change in a consumer account, state that the contents could not be read +and that the prediction is therefore bounded. See +`C-cross-account-opaque-constructs`. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-vpce-shadow-nxdomain.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-vpce-shadow-nxdomain.md new file mode 100644 index 0000000..20a8547 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/B-vpce-shadow-nxdomain.md @@ -0,0 +1,62 @@ +# B — VPC Endpoint Shadow NXDOMAIN + +## Mechanism + +Enabling private DNS on a VPC interface endpoint installs a service-managed +private hosted zone for the service's DNS name into the VPC. From that point the +service FQDN resolves to the endpoint's ENI private IPs instead of the public +service IPs. + +The failure mode: the installed zone **shadows** the public name. Any name under +that zone which the endpoint does not serve now returns NXDOMAIN rather than +falling through to public resolution. The zone is authoritative, so there is no +fallback. + +## Classic case + +An interface endpoint is created for a service, private DNS is enabled, and a +name that used to resolve publicly stops resolving. The endpoint serves the +regional service endpoint but not the additional name the workload was using — +for example an alternate service alias, a differently-suffixed FQDN, or a +sibling API name that shares the shadowed apex. + +This affects any workload path that depended on the shadowed name resolving +publicly, including bootstrap, package fetch, and OIDC/token endpoints. + +## Detection + +`dns_simulate_change` with the endpoint private-DNS change reports the +`VPCE-shadow-NXDOMAIN` trap and lists the names predicted to move from a +resolving answer to NXDOMAIN. + +## Confirming after the fact + +If private DNS is already enabled and the symptom is present, use Mode A: + +- `dns_probe_compare` on the failing name. A private ENI IP confirms the endpoint + path; NXDOMAIN confirms the shadow. +- Probe a name known to be served by the endpoint. If it returns a private IP + while the failing name NXDOMAINs, the shadow is confirmed rather than a general + resolver failure. + +## Remediation options + +| Option | Trade-off | +| --- | --- | +| Use an alternate service FQDN outside the shadowed zone, where one exists | Simplest; depends on the service publishing one | +| Disable endpoint private DNS and reach the service publicly | Loses the private path; may violate a no-public-egress requirement | +| Add a PHZ record for the shadowed name pointing at the endpoint | Only valid if the endpoint actually serves that name | +| Add a more specific FORWARD or SYSTEM rule for the name | A specific FORWARD outranks the endpoint zone; verify against `A-forward-vs-phz-precedence-collision` | + +## Interaction warning + +If an alternate FQDN is adopted as the workaround, verify that no broad FORWARD +rule sweeps that alternate name to an on-premises resolver. A `.` FORWARD rule +with only an `amazonaws.com` SYSTEM carve-out will capture a differently-suffixed +alternate name, breaking the workaround in a way that looks unrelated. See +`B-broad-forward-sweep`. + +## Validate first + +Run `dns_simulate_change` before enabling private DNS. This trap is +straightforward to predict and expensive to discover in production. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/C-cross-account-opaque-constructs.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/C-cross-account-opaque-constructs.md new file mode 100644 index 0000000..e2bcb12 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/C-cross-account-opaque-constructs.md @@ -0,0 +1,71 @@ +# C — Cross-Account Opaque Constructs + +## The consumer-side rule + +When DNS constructs are shared into an account via AWS RAM or contributed through +a Route 53 Profile, some are fully readable and some are **enumerable but +opaque** — you can see that they exist, but not what is inside them. + +Maintain a strict consumer-side perspective. Never assume visibility into +provider-owned internals, and never enumerate provider-side constructs from an +account that does not own them. + +## Measured visibility + +| Construct | Consumer-side visibility | +| --- | --- | +| Directly associated private hosted zone | fully readable (name, id, owner) | +| RAM-shared resolver rule | fully readable (domain, target, owner) | +| RAM-shared DNS Firewall rule group | association and rules visible; **domain lists denied** (`list_firewall_domains`) | +| Profile-contained resolver rule | enumerable; **`get_resolver_rule` denied** | +| Profile-contained private hosted zone | enumerable; **`get_hosted_zone` denied** | + +No Route 53 Profiles API action exposes a profile's zone, record, or rule contents +to a consumer account. + +## How the server handles this + +Every per-resource detail read is wrapped so a denial produces an `OPAQUE` +marker rather than a failed model build. The resolution engine then applies two +rules: + +- An **opaque firewall rule** is treated as OPAQUE **first**, ahead of everything + else, because a hidden block list may cover any name. +- An **opaque resolver rule** is treated as OPAQUE only when no concrete rule + matched. + +A model containing opaque markers is a valid, complete model of what the consumer +can actually see. It is not a degraded result to be apologized for — it is the +correct answer to "what is visible from here." + +## Reporting rules + +1. When a name resolves to OPAQUE, report "cannot determine from this account," + never "not affected." The distinction matters: absence of visible evidence is + not evidence of absence. +2. Name the owning account or Profile where the API returns it, so the operator + knows whom to ask. +3. State that the Mode B prediction is bounded by the opaque constructs. A + prediction that cannot see a block list cannot promise a name will resolve. +4. Where ground truth is needed and an instance is available, use Mode A. An + in-instance probe observes the *result* of an opaque construct even when its + configuration cannot be read. This is the most reliable way around opacity. + +## PHZ-in-Profile note + +A private hosted zone can be associated with a Route 53 Profile, but the zone +must be **private**. Attempting to associate a public zone produces a misleading +error suggesting the operation is unsupported. In CloudFormation, a hosted zone +requires a `VPCs` property to be created as private — omitting it silently creates +a public zone, which the Profile then rejects. If a Profile association fails, +check the zone's type before concluding the feature is unavailable. + +## Consumer-side derivation for Lattice + +For VPC Lattice shadows, derive what is visible from the consumer's own endpoint +records rather than enumerating provider-side resource configurations. Query the +endpoints the consumer owns and read the configuration for those specific ARNs. +Do not attempt provider-side enumeration such as listing resource gateways or +resource configurations from a consumer account — those calls are denied by +design, and treating a denial as an error rather than an expected boundary +produces false failures. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/C-limitations-and-boundaries.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/C-limitations-and-boundaries.md new file mode 100644 index 0000000..a444a91 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/C-limitations-and-boundaries.md @@ -0,0 +1,48 @@ +# C — Limitations and Boundaries (state these to the operator) + +Report these boundaries explicitly. A confident answer that overstates its scope +is worse than a qualified one. + +## Mode A boundaries + +| Limitation | Consequence | +| --- | --- | +| Requires SSM connectivity via `ssm` + `ssmmessages` + `ec2messages` interface endpoints (an EC2 Instance Connect Endpoint does not qualify) | If SSM is unreachable, Mode A cannot run. Report the blocker; do not route around it. | +| Observes one instance | Results reflect that instance's resolver path, `resolv.conf`, local stub, and `/etc/hosts`. Probe one instance per relevant subnet before generalizing. | +| Read-only, fixed probe set | Only the allowlisted probes run. Arbitrary commands cannot be executed by design. | +| Point-in-time | A passing probe does not mean the name resolves reliably. Intermittent failures need repeated observation. | + +## Mode B boundaries + +| Limitation | Consequence | +| --- | --- | +| Coverage equals the candidate name set | A name in neither the configuration nor the operator-supplied list is not simulated. Its absence from the report is **not** evidence it is safe. | +| Models documented precedence | Undocumented or newly changed service behavior may differ. Confirm with Mode A when the stakes are high. | +| Query logs are optional enrichment | Without them, volume ranking is unavailable; correctness is unaffected. | +| Cross-account constructs may be opaque | Predictions are bounded by what the consumer can read. See `C-cross-account-opaque-constructs`. | +| Symbolic, not observed | Always phrase output as a prediction. | + +## Where the two modes disagree + +Mode A wins. It observes actual behavior; Mode B infers from configuration. A +disagreement is itself a finding — it usually means either an undocumented +service behavior or a construct the model could not read. + +## What neither mode covers + +- DNS resolution from outside the VPC (on-premises clients, other VPCs). +- Application-layer caching. A JVM or a service mesh sidecar may hold a stale + answer long after the resolver returns a new one. +- Authoritative zone data correctness at an on-premises resolver. +- Whether an intended design is *correct* — only whether it behaves as + configured. + +## Honest reporting checklist + +Before delivering a conclusion, confirm you have stated: + +1. Which instance and subnet the observation came from. +2. Which address families were tested. +3. Whether any construct was opaque, and which account or Profile owns it. +4. Whether the finding is observed (Mode A) or predicted (Mode B). +5. For a Profile-related change, the propagation window. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/Z-general-triage.md b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/Z-general-triage.md new file mode 100644 index 0000000..791a865 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/sops/Z-general-triage.md @@ -0,0 +1,66 @@ +# Z — General DNS Triage (start here) + +Use this runbook when the symptom is vague ("DNS is broken", "the app can't +reach the database", "resolution is flaky") and you do not yet know which +specific failure mode applies. + +## Step 0 — Establish the precondition before diagnosing anything + +Call `dns_probe_context` for an affected instance. Read three things: + +1. **`enableDnsSupport` / `enableDnsHostnames`.** If `enableDnsSupport=false`, + the VPC resolver is intentionally dark. Lead with that. Do not diagnose it as + a security-group, routing, or resolver-configuration problem. See + `A-resolver-disabled-precondition`. +2. **Instance addressing family.** In an IPv6-only subnet there is no + `169.254.169.253`; only `fd00:ec2::253` answers. See + `A-address-family-divergence`. +3. **DHCP option set `domain-name-servers`.** This is the resolver the VPC + *intends* the instance to use. If it differs from the instance's actual + `resolv.conf` (returned by `dns_probe_compare`), the instance is not using + the VPC-handed resolver — that discrepancy is often the whole answer. + +If SSM is unreachable, stop and report it. Do not attempt a workaround over the +public internet path. See `A-mode-a-live-resolver-comparison`, "SSM +prerequisites". + +## Step 1 — Classify the question as reactive or predictive + +| The operator is asking | Use | Runbook | +| --- | --- | --- | +| "Why is this name resolving wrong *right now*?" | Mode A (`dns_probe_*`) | `A-mode-a-live-resolver-comparison` | +| "What will break if I make this change?" | Mode B (`dns_simulate_*`) | `B-mode-b-pre-change-validation` | + +These are different tools with different guarantees. Mode A returns ground truth +observed from inside a subnet. Mode B returns a prediction derived from +control-plane configuration. When they disagree, Mode A wins — the service may +behave in ways the documented precedence model does not capture. + +## Step 2 — Get the observed answer matrix + +Call `dns_probe_compare` with the failing name. By default it auto-adds the +DHCP-configured resolver(s), so you usually pass no `resolvers` argument. Read +the `hostname.bind` identity line to confirm *which* resolver actually answered +rather than assuming. + +## Step 3 — Match the observation to a failure mode + +| Observation | Likely mode | Runbook | +| --- | --- | --- | +| Same name, different answers per resolver | custom-resolver divergence | `A-custom-resolver-divergence` | +| AWS service FQDN returns a public IP where a VPC endpoint exists | VPCE private DNS not in effect | `B-vpce-shadow-nxdomain` | +| AWS service FQDN NXDOMAINs or times out | over-broad FORWARD rule | `B-broad-forward-sweep` | +| Internal zone name times out (does not NXDOMAIN) | FORWARD outranking a PHZ | `A-forward-vs-phz-precedence-collision` | +| A resolves but AAAA is empty (or vice versa) | per-family record gap | `A-address-family-divergence` | +| Config reads return "opaque" markers | shared cross-account construct | `C-cross-account-opaque-constructs` | +| Change was applied but has not taken effect | Profile propagation delay | `B-profile-propagation-timing` | + +## Step 4 — Judge correctness, not agreement + +Before calling anything a fault, classify the name and check it against the +expected winner. Resolvers disagreeing is frequently the *correct* design. See +`A-name-category-classification`. + +## Step 5 — Report limitations honestly + +State the boundaries of what you verified. See `C-limitations-and-boundaries`. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/ssm-document/dns-diagnostic-probe.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/ssm-document/dns-diagnostic-probe.yaml new file mode 100644 index 0000000..882d394 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/ssm-document/dns-diagnostic-probe.yaml @@ -0,0 +1,66 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +schemaVersion: '2.2' +description: > + DNS diagnostic probe runner for the DNS Diagnostic MCP Server (Mode A). + Runs a FIXED, read-only DNS probe set inside the instance for a single + (Name, Resolver, Family) triple. This document is the on-instance enforcement + boundary: it accepts only three structured, pattern-validated parameters and + never a free command string, so nothing beyond the predefined probes can run. + The MCP server also validates these inputs before calling; the allowedPattern + below is the second, independent layer. + +parameters: + Name: + type: String + description: DNS name to resolve. Strict DNS charset only. + # Labels of 1-63 chars of [A-Za-z0-9_-], dot-separated, max 253 chars, + # optional trailing dot. No shell metacharacters can match. + allowedPattern: '^([A-Za-z0-9_-]{1,63}\.)*[A-Za-z0-9_-]{1,63}\.?$' + maxChars: 253 + Resolver: + type: String + description: Resolver IP (v4/v6) or hostname to query. No shell metacharacters. + # Digits, letters, dot, colon (IPv6), hyphen, underscore only. + allowedPattern: '^[A-Za-z0-9_.:-]{1,253}$' + maxChars: 253 + Family: + type: String + description: DNS record family. + allowedValues: + - A + - AAAA + +mainSteps: + - action: aws:runShellScript + name: dnsProbe + precondition: + StringEquals: + - platformType + - Linux + inputs: + timeoutSeconds: '60' + runCommand: + - '#!/bin/bash' + # No `set -e`: we want every probe to run even if one returns non-zero + # (NXDOMAIN / SERVFAIL are informative, not fatal). + - 'set -u' + # Parameters are substituted by SSM as literals AFTER allowedPattern + # validation, then quoted here. The pattern guarantees no quotes, + # semicolons, backticks, pipes, spaces, or newlines can appear. + - 'NAME="{{ Name }}"' + - 'RESOLVER="{{ Resolver }}"' + - 'FAMILY="{{ Family }}"' + - 'echo "=== resolv.conf ==="' + - 'cat /etc/resolv.conf 2>/dev/null || echo "(no /etc/resolv.conf)"' + - 'echo "=== resolvectl ==="' + - 'if command -v resolvectl >/dev/null 2>&1; then resolvectl status 2>/dev/null || true; else echo "(resolvectl not present)"; fi' + - 'echo "=== dig answer ==="' + - 'dig +short "$NAME" "$FAMILY" @"$RESOLVER" || true' + - 'echo "=== dig stats ==="' + - 'dig "$NAME" "$FAMILY" @"$RESOLVER" +stats || true' + - 'echo "=== resolver identity (hostname.bind) ==="' + - 'dig hostname.bind CH TXT @"$RESOLVER" +short || true' + - 'echo "=== getent hosts ==="' + - 'getent hosts "$NAME" || echo "(getent: no match)"' diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/template.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/template.yaml new file mode 100644 index 0000000..bee79cd --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/template.yaml @@ -0,0 +1,172 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + DNS Diagnostic MCP Server for AWS DevOps Agent. + + A single Lambda exposes two tool families over Streamable HTTP (via Lambda + Web Adapter + a Function URL with AWS_IAM / SigV4 auth): + + * dns_probe_* (Mode A) - live, comparative, multi-resolver DNS diagnosis + run inside a target EC2 instance via SSM Run Command. + * dns_simulate_* (Mode B) - symbolic pre-change validation of the VPC's + effective DNS resolution (control-plane read only). + + Least privilege is preserved by per-tool-family credential scoping: the + function's own execution role holds NO diagnostic permissions and may only + assume one of two scoped roles per call - a read-only role (simulate) or a + probe role whose sole privileged grant is a resource-scoped ssm:SendCommand + to a single diagnostic SSM document. + +Globals: + Function: + Timeout: 180 + Runtime: python3.12 + MemorySize: 1024 + +Parameters: + StageName: + Type: String + Default: prod + AllowedValues: [dev, staging, prod] + + AllowedAccounts: + Type: CommaDelimitedList + Description: > + Account IDs the tools may inspect (via AssumeRole into the scoped roles + deployed in each target account). Use '*' only for dev/testing. + Default: '*' + + AllowedRegions: + Type: CommaDelimitedList + Description: > + Regions the tools may operate in. Use '*' only for dev/testing. + Default: '*' + + AllowedVpcs: + Type: CommaDelimitedList + Description: > + VPC IDs the tools may target. Use '*' only for dev/testing. + Default: '*' + + AllowedResolvers: + Type: CommaDelimitedList + Description: > + Extra resolver IPs/hostnames the probe tools may query beyond those + discovered on the instance and the VPC .2 / IPv6 resolver. Use '*' only + for dev/testing. + Default: '*' + + DiagnosticDocumentName: + Type: String + Default: dns-diagnostic-probe + Description: > + Name of the SSM document the probe role is permitted to send. The probe + role's ssm:SendCommand is resource-scoped to THIS document only. + + ProbeRoleArnPattern: + Type: String + Default: 'arn:aws:iam::*:role/DnsDiagnosticProbeRole' + Description: > + ARN (or ARN pattern) of the per-account probe role the function may + assume for Mode A. Deploy this role separately in each allowed account. + + ReadOnlyRoleArnPattern: + Type: String + Default: 'arn:aws:iam::*:role/DnsDiagnosticReadOnlyRole' + Description: > + ARN (or ARN pattern) of the per-account read-only role the function may + assume for Mode B. Deploy this role separately in each allowed account. + +Resources: + # ============================================================ + # Lambda function with Lambda Web Adapter (Streamable HTTP) + # ============================================================ + DnsDiagnosticMCPFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub aws-vpc-dns-diagnostics-mcp-${StageName} + Handler: run.sh + CodeUri: src/ + Description: DNS Diagnostic MCP Server - Streamable HTTP via Lambda Web Adapter + Architectures: + - x86_64 + Layers: + # Lambda Web Adapter layer + - !Sub arn:aws:lambda:${AWS::Region}:753240598075:layer:LambdaAdapterLayerX86:24 + - !Ref DependenciesLayer + Environment: + Variables: + # Lambda Web Adapter config + AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap + AWS_LWA_PORT: "8000" + AWS_LWA_READINESS_CHECK_PATH: /mcp + AWS_LWA_READINESS_CHECK_MIN_UNHEALTHY_STATUS: 500 + AWS_LWA_INVOKE_MODE: response_stream + AWS_LWA_READINESS_CHECK_PROTOCOL: http + AWS_LWA_ASYNC_INIT: "true" + # MCP server config + PYTHONPATH: /opt/python + STAGE_NAME: !Ref StageName + # Tool-level allowlists (defense-in-depth on top of IAM) + ALLOWED_ACCOUNTS: !Join [",", !Ref AllowedAccounts] + ALLOWED_REGIONS: !Join [",", !Ref AllowedRegions] + ALLOWED_VPCS: !Join [",", !Ref AllowedVpcs] + ALLOWED_RESOLVERS: !Join [",", !Ref AllowedResolvers] + # Per-tool-family scoped roles (assumed per call; see server.py) + PROBE_ROLE_ARN_PATTERN: !Ref ProbeRoleArnPattern + READONLY_ROLE_ARN_PATTERN: !Ref ReadOnlyRoleArnPattern + DIAGNOSTIC_DOCUMENT_NAME: !Ref DiagnosticDocumentName + Policies: + # The function's OWN role holds NO diagnostic permissions. It may only + # assume the two scoped roles. This is the core least-privilege split: + # a Mode B (read-only) call never rides on a role holding ssm:SendCommand. + - Statement: + - Effect: Allow + Action: + - sts:AssumeRole + Resource: + - !Ref ProbeRoleArnPattern + - !Ref ReadOnlyRoleArnPattern + # Function URL with RESPONSE_STREAM for streamable-HTTP / SSE support. + # AWS_IAM auth == callers (the DevOps Agent) sign requests with SigV4. + FunctionUrlConfig: + AuthType: AWS_IAM + InvokeMode: RESPONSE_STREAM + + # ============================================================ + # Dependencies layer (FastMCP + boto3) + # ============================================================ + DependenciesLayer: + Type: AWS::Serverless::LayerVersion + Properties: + LayerName: !Sub aws-vpc-dns-diagnostics-mcp-deps-${StageName} + Description: FastMCP and dependencies for the DNS Diagnostic MCP server + ContentUri: layers/dependencies/ + CompatibleRuntimes: + - python3.12 + CompatibleArchitectures: + - x86_64 + Metadata: + BuildMethod: makefile + +Outputs: + MCPEndpointUrl: + Description: > + MCP server endpoint URL. Register this in your DevOps Agent Space as the + MCP server endpoint (SigV4 / AWS_IAM auth). + Value: !GetAtt DnsDiagnosticMCPFunctionUrl.FunctionUrl + + FunctionArn: + Description: Lambda function ARN + Value: !GetAtt DnsDiagnosticMCPFunction.Arn + + FunctionRoleArn: + Description: > + Central Lambda execution-role ARN. Pass this as CentralFunctionRoleArn when + deploying scoped-roles.yaml in each target account (it is the trust + principal the read-only and probe roles allow). SAM auto-generates this + role as Role. + Value: !GetAtt DnsDiagnosticMCPFunctionRole.Arn diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/01-base-network.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/01-base-network.yaml new file mode 100644 index 0000000..500e295 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/01-base-network.yaml @@ -0,0 +1,226 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +AWSTemplateFormatVersion: '2010-09-09' +Description: > + DNS Diagnostic MCP - test infrastructure module 01: base dualstack network. + + A dualstack VPC with two private subnets across two AZs, SSM connectivity via + interface VPC endpoints (ssm, ssmmessages, ec2messages), and an instance IAM + role with AmazonSSMManagedInstanceCore. Also creates an EC2 Instance Connect + Endpoint as a break-glass path for a human to inspect an instance; EICE is NOT + a substitute for the SSM endpoints, which Run Command requires. + Instance connectivity NEVER relies on the public internet path. + + Exports VPC / subnet / security-group / role IDs for modules 02 and 03. + +Parameters: + NamePrefix: + Type: String + Default: dns-diag-test + VpcCidr: + Type: String + Default: 10.42.0.0/16 + +Resources: + Vpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: !Ref VpcCidr + EnableDnsSupport: true + EnableDnsHostnames: true + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-vpc + + # IPv6 for dualstack / IPv6-only probe coverage. + Ipv6Cidr: + Type: AWS::EC2::VPCCidrBlock + Properties: + VpcId: !Ref Vpc + AmazonProvidedIpv6CidrBlock: true + + SubnetA: + Type: AWS::EC2::Subnet + DependsOn: Ipv6Cidr + Properties: + VpcId: !Ref Vpc + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: !Select [0, !Cidr [!Ref VpcCidr, 4, 8]] + Ipv6CidrBlock: !Select [0, !Cidr [!Select [0, !GetAtt Vpc.Ipv6CidrBlocks], 4, 64]] + AssignIpv6AddressOnCreation: true + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-subnet-a + + SubnetB: + Type: AWS::EC2::Subnet + DependsOn: Ipv6Cidr + Properties: + VpcId: !Ref Vpc + AvailabilityZone: !Select [1, !GetAZs ''] + CidrBlock: !Select [1, !Cidr [!Ref VpcCidr, 4, 8]] + Ipv6CidrBlock: !Select [1, !Cidr [!Select [0, !GetAtt Vpc.Ipv6CidrBlocks], 4, 64]] + AssignIpv6AddressOnCreation: true + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-subnet-b + + RouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref Vpc + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-rt + + AssocA: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: !Ref RouteTable + SubnetId: !Ref SubnetA + AssocB: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: !Ref RouteTable + SubnetId: !Ref SubnetB + + # Security group for interface endpoints: allow HTTPS from within the VPC. + EndpointSg: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: 'DNS diag test - interface endpoint SG (HTTPS from VPC)' + VpcId: !Ref Vpc + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: !Ref VpcCidr + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-endpoint-sg + + # Security group for instances: all outbound (needed for SSM agent to reach + # the interface endpoints), no inbound required (SSM/EICE are agent-initiated). + InstanceSg: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: 'DNS diag test - instance SG (all egress, SSM via endpoints)' + VpcId: !Ref Vpc + SecurityGroupEgress: + - IpProtocol: '-1' + CidrIp: 0.0.0.0/0 + - IpProtocol: '-1' + CidrIpv6: ::/0 + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-instance-sg + + # Allow instances to reach the endpoints on 443. + EndpointIngressFromInstances: + Type: AWS::EC2::SecurityGroupIngress + Properties: + GroupId: !Ref EndpointSg + IpProtocol: tcp + FromPort: 443 + ToPort: 443 + SourceSecurityGroupId: !Ref InstanceSg + + # ---- S3 gateway endpoint (free) so dnf can reach AL2023 regional repos + # (served from same-region S3) without any public/NAT path ---- + S3GatewayEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !Ref Vpc + ServiceName: !Sub com.amazonaws.${AWS::Region}.s3 + VpcEndpointType: Gateway + RouteTableIds: [!Ref RouteTable] + + # ---- SSM interface endpoints (no public path) ---- + SsmEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !Ref Vpc + ServiceName: !Sub com.amazonaws.${AWS::Region}.ssm + VpcEndpointType: Interface + PrivateDnsEnabled: true + SubnetIds: [!Ref SubnetA, !Ref SubnetB] + SecurityGroupIds: [!Ref EndpointSg] + + SsmMessagesEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !Ref Vpc + ServiceName: !Sub com.amazonaws.${AWS::Region}.ssmmessages + VpcEndpointType: Interface + PrivateDnsEnabled: true + SubnetIds: [!Ref SubnetA, !Ref SubnetB] + SecurityGroupIds: [!Ref EndpointSg] + + Ec2MessagesEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !Ref Vpc + ServiceName: !Sub com.amazonaws.${AWS::Region}.ec2messages + VpcEndpointType: Interface + PrivateDnsEnabled: true + SubnetIds: [!Ref SubnetA, !Ref SubnetB] + SecurityGroupIds: [!Ref EndpointSg] + + # ---- EC2 Instance Connect Endpoint (free; additional connectivity path) ---- + Eice: + Type: AWS::EC2::InstanceConnectEndpoint + Properties: + SubnetId: !Ref SubnetA + SecurityGroupIds: [!Ref InstanceSg] + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-eice + + # ---- Instance role with SSM core managed policy ---- + InstanceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-instance-role + + InstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + Roles: [!Ref InstanceRole] + +Outputs: + VpcId: + Value: !Ref Vpc + Export: {Name: !Sub '${NamePrefix}-VpcId'} + VpcCidr: + Value: !Ref VpcCidr + Export: {Name: !Sub '${NamePrefix}-VpcCidr'} + SubnetAId: + Value: !Ref SubnetA + Export: {Name: !Sub '${NamePrefix}-SubnetAId'} + SubnetBId: + Value: !Ref SubnetB + Export: {Name: !Sub '${NamePrefix}-SubnetBId'} + InstanceSgId: + Value: !Ref InstanceSg + Export: {Name: !Sub '${NamePrefix}-InstanceSgId'} + EndpointSgId: + Value: !Ref EndpointSg + Export: {Name: !Sub '${NamePrefix}-EndpointSgId'} + InstanceProfileArn: + Value: !GetAtt InstanceProfile.Arn + Export: {Name: !Sub '${NamePrefix}-InstanceProfileArn'} + InstanceProfileName: + Value: !Ref InstanceProfile + Export: {Name: !Sub '${NamePrefix}-InstanceProfileName'} diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/02-mode-a-scenarios.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/02-mode-a-scenarios.yaml new file mode 100644 index 0000000..bcca2bb --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/02-mode-a-scenarios.yaml @@ -0,0 +1,129 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +AWSTemplateFormatVersion: '2010-09-09' +Description: > + DNS Diagnostic MCP - test infrastructure module 02: Mode A scenarios. + + Stands up the ground-truth cases the dns_probe_* family diagnoses: + * baseline instance using the VPC .2 resolver (control). + * custom-resolver instance running dnsmasq that forwards *.internal.corp to + itself and everything else to the VPC .2 resolver - the custom/hybrid + resolver comparison case. + * a Secrets Manager interface VPCE with private DNS enabled - the + PrivateLink private-DNS resolution case. + * a PHZ 'internal.corp' with a test record - the PHZ resolution case. + + Imports the network from module 01. + +Parameters: + NamePrefix: + Type: String + Default: dns-diag-test + InstanceType: + Type: String + Default: t3.micro + LatestAl2023Ami: + Type: AWS::SSM::Parameter::Value + Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 + +Resources: + # ---- PHZ: internal.corp (PHZ resolution case) ---- + InternalPhz: + Type: AWS::Route53::HostedZone + Properties: + Name: internal.corp. + VPCs: + - VPCId: !ImportValue + 'Fn::Sub': '${NamePrefix}-VpcId' + VPCRegion: !Ref AWS::Region + + InternalRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref InternalPhz + Name: db.internal.corp. + Type: A + TTL: '60' + ResourceRecords: ['10.42.200.10'] + + # ---- Secrets Manager interface VPCE with private DNS (PrivateLink case) ---- + SecretsManagerEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !ImportValue + 'Fn::Sub': '${NamePrefix}-VpcId' + ServiceName: !Sub com.amazonaws.${AWS::Region}.secretsmanager + VpcEndpointType: Interface + PrivateDnsEnabled: true + SubnetIds: + - !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetAId'} + - !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetBId'} + SecurityGroupIds: + - !ImportValue {'Fn::Sub': '${NamePrefix}-EndpointSgId'} + + # ---- Baseline instance (uses VPC .2 resolver) ---- + BaselineInstance: + Type: AWS::EC2::Instance + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref LatestAl2023Ami + IamInstanceProfile: !ImportValue {'Fn::Sub': '${NamePrefix}-InstanceProfileName'} + SubnetId: !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetAId'} + SecurityGroupIds: + - !ImportValue {'Fn::Sub': '${NamePrefix}-InstanceSgId'} + UserData: + Fn::Base64: | + #!/bin/bash + dnf install -y bind-utils + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-baseline + + # ---- Custom-resolver instance (dnsmasq split forwarding) ---- + CustomResolverInstance: + Type: AWS::EC2::Instance + Properties: + InstanceType: !Ref InstanceType + ImageId: !Ref LatestAl2023Ami + IamInstanceProfile: !ImportValue {'Fn::Sub': '${NamePrefix}-InstanceProfileName'} + SubnetId: !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetBId'} + SecurityGroupIds: + - !ImportValue {'Fn::Sub': '${NamePrefix}-InstanceSgId'} + UserData: + Fn::Base64: | + #!/bin/bash + set -x + # S3 gateway endpoint (module 01) makes the AL2023 repos reachable. + dnf install -y bind-utils dnsmasq + # Split forwarding: internal.corp answered locally with a DISTINCT + # address (10.42.200.99) so the probe comparison differs from the PHZ + # answer (.10); everything else forwarded to the VPC .2 resolver. + cat >/etc/dnsmasq.d/split.conf <<'EOF' + no-resolv + server=10.42.0.2 + address=/db.internal.corp/10.42.200.99 + EOF + systemctl enable --now dnsmasq + # On AL2023 /etc/resolv.conf is a systemd-resolved-managed symlink that + # regenerates. Replace it with a static file pointing at the local + # dnsmasq so this instance emulates a custom VPC resolver without + # touching the DHCP option set. Stop resolved so it cannot reclaim it. + systemctl stop systemd-resolved || true + systemctl disable systemd-resolved || true + rm -f /etc/resolv.conf + printf 'nameserver 127.0.0.1\n' >/etc/resolv.conf + chattr +i /etc/resolv.conf || true + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-custom-resolver + +Outputs: + BaselineInstanceId: + Value: !Ref BaselineInstance + CustomResolverInstanceId: + Value: !Ref CustomResolverInstance + SecretsManagerEndpointId: + Value: !Ref SecretsManagerEndpoint + PhzId: + Value: !Ref InternalPhz diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/03-mode-b-config.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/03-mode-b-config.yaml new file mode 100644 index 0000000..ac36008 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/03-mode-b-config.yaml @@ -0,0 +1,133 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +AWSTemplateFormatVersion: '2010-09-09' +Description: > + DNS Diagnostic MCP - test infrastructure module 03: Mode B config surface. + + Stands up the control-plane constructs the dns_simulate_* family reads and + reasons over: + * a Route 53 Resolver outbound endpoint (2 ENIs) + security group. + * a FORWARD rule for internal.corp -> a placeholder on-prem target, and a + SYSTEM rule carve-out for amazonaws.com, both associated with the VPC. + * a DNS Firewall domain list + rule group (BLOCK) associated with the VPC. + + These give the effective-config builder real resolver rules, a SYSTEM + carve-out, and a DNS Firewall association to inventory and simulate against. + + NOTE: the outbound resolver endpoint (2 ENIs) is the main cost driver of the + test infra (~$0.25/ENI-hour => ~$12/day). Delete module 03 first when done. + + Imports the network from module 01. + +Parameters: + NamePrefix: + Type: String + Default: dns-diag-test + OnPremTargetIp: + Type: String + Default: 10.99.0.53 + Description: Placeholder on-prem DNS IP for the FORWARD rule target. + +Resources: + ResolverEndpointSg: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: 'DNS diag test - resolver outbound endpoint SG (DNS egress)' + VpcId: !ImportValue {'Fn::Sub': '${NamePrefix}-VpcId'} + SecurityGroupEgress: + - IpProtocol: udp + FromPort: 53 + ToPort: 53 + CidrIp: 0.0.0.0/0 + - IpProtocol: tcp + FromPort: 53 + ToPort: 53 + CidrIp: 0.0.0.0/0 + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-resolver-ep-sg + + OutboundEndpoint: + Type: AWS::Route53Resolver::ResolverEndpoint + Properties: + Name: !Sub ${NamePrefix}-outbound + Direction: OUTBOUND + SecurityGroupIds: [!Ref ResolverEndpointSg] + IpAddresses: + - SubnetId: !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetAId'} + - SubnetId: !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetBId'} + + # FORWARD rule: onprem.corp -> placeholder on-prem target. Deliberately a + # DIFFERENT domain from module 02's PHZ (internal.corp) so the PHZ resolution + # case and the FORWARD case stay isolated. To demonstrate the FORWARD-vs-PHZ + # precedence collision on purpose, simulate/add a FORWARD rule for + # internal.corp via Mode B rather than baking the collision into the fixture. + ForwardRule: + Type: AWS::Route53Resolver::ResolverRule + Properties: + Name: !Sub ${NamePrefix}-fwd-onprem-corp + DomainName: onprem.corp. + RuleType: FORWARD + ResolverEndpointId: !Ref OutboundEndpoint + TargetIps: + - Ip: !Ref OnPremTargetIp + Port: '53' + + ForwardRuleAssoc: + Type: AWS::Route53Resolver::ResolverRuleAssociation + Properties: + ResolverRuleId: !Ref ForwardRule + VPCId: !ImportValue {'Fn::Sub': '${NamePrefix}-VpcId'} + + # SYSTEM rule carve-out for amazonaws.com (keeps AWS FQDNs on VPC-native + # resolution even if a broad FORWARD rule is later added - the exact trap the + # broad-FORWARD-sweep detector reasons about). + SystemRule: + Type: AWS::Route53Resolver::ResolverRule + Properties: + Name: !Sub ${NamePrefix}-system-amazonaws + DomainName: amazonaws.com. + RuleType: SYSTEM + + SystemRuleAssoc: + Type: AWS::Route53Resolver::ResolverRuleAssociation + Properties: + ResolverRuleId: !Ref SystemRule + VPCId: !ImportValue {'Fn::Sub': '${NamePrefix}-VpcId'} + + # ---- DNS Firewall ---- + BlockDomainList: + Type: AWS::Route53Resolver::FirewallDomainList + Properties: + Name: !Sub ${NamePrefix}-block-list + Domains: + - blocked.example.com. + + FirewallRuleGroup: + Type: AWS::Route53Resolver::FirewallRuleGroup + Properties: + Name: !Sub ${NamePrefix}-frg + FirewallRules: + - FirewallDomainListId: !Ref BlockDomainList + Priority: 100 + Action: BLOCK + BlockResponse: NXDOMAIN + + FirewallAssoc: + Type: AWS::Route53Resolver::FirewallRuleGroupAssociation + Properties: + Name: !Sub ${NamePrefix}-frg-assoc + FirewallRuleGroupId: !Ref FirewallRuleGroup + VpcId: !ImportValue {'Fn::Sub': '${NamePrefix}-VpcId'} + Priority: 101 + +Outputs: + OutboundEndpointId: + Value: !Ref OutboundEndpoint + ForwardRuleId: + Value: !Ref ForwardRule + SystemRuleId: + Value: !Ref SystemRule + FirewallRuleGroupId: + Value: !Ref FirewallRuleGroup diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/04-mode-b-lattice.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/04-mode-b-lattice.yaml new file mode 100644 index 0000000..a30fbb4 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/04-mode-b-lattice.yaml @@ -0,0 +1,92 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +AWSTemplateFormatVersion: '2010-09-09' +Description: > + DNS Diagnostic MCP - test infrastructure module 04: VPC Lattice resource + endpoint (resources-only path) for exercising the ungated custom-domain shadow. + + Stands up a resource gateway in the test VPC, a resource configuration with a + CustomDomainName (the shadow apex), and a Resource-type VPC endpoint so that + _build_effective_model's list_resource_endpoint_associations read has a live + target scoped to this VPC. This is the "resources only, no service network" + case that reading service-network associations alone would miss. + + Imports the network from module 01. + +Parameters: + NamePrefix: + Type: String + Default: dns-diag-test + CustomDomainName: + Type: String + Default: app.lattice-test.internal + Description: Custom domain the resource configuration publishes (shadow apex). + +Resources: + # Resource gateway lives in the (provider) VPC; here we reuse the test VPC. + ResourceGatewaySg: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: 'DNS diag test - Lattice resource gateway SG' + VpcId: !ImportValue {'Fn::Sub': '${NamePrefix}-VpcId'} + SecurityGroupEgress: + - IpProtocol: '-1' + CidrIp: 0.0.0.0/0 + Tags: + - Key: Name + Value: !Sub ${NamePrefix}-rgw-sg + + ResourceGateway: + Type: AWS::VpcLattice::ResourceGateway + Properties: + Name: !Sub ${NamePrefix}-rgw + VpcIdentifier: !ImportValue {'Fn::Sub': '${NamePrefix}-VpcId'} + SubnetIds: + - !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetAId'} + - !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetBId'} + SecurityGroupIds: + - !Ref ResourceGatewaySg + IpAddressType: IPV4 + + # Resource configuration with a custom domain - this domain becomes the shadow + # apex the tool must detect via the resource-endpoint read. + ResourceConfiguration: + Type: AWS::VpcLattice::ResourceConfiguration + Properties: + Name: !Sub ${NamePrefix}-rc + ResourceGatewayId: !Ref ResourceGateway + ResourceConfigurationType: SINGLE + CustomDomainName: !Ref CustomDomainName + ResourceConfigurationDefinition: + DnsResource: + DomainName: !Ref CustomDomainName + IpAddressType: IPV4 + PortRanges: + - '443' + + # Consumer-side Resource-type VPC endpoint pointing at the config. This is + # what the tool actually reads (DescribeVpcEndpoints -> DnsEntries); the + # gateway/config above are the provider side (in a real cross-account setup + # they live in another account and the consumer never sees them). + ResourceEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !ImportValue {'Fn::Sub': '${NamePrefix}-VpcId'} + VpcEndpointType: Resource + ResourceConfigurationArn: !GetAtt ResourceConfiguration.Arn + SubnetIds: + - !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetAId'} + - !ImportValue {'Fn::Sub': '${NamePrefix}-SubnetBId'} + SecurityGroupIds: + - !Ref ResourceGatewaySg + +Outputs: + ResourceGatewayId: + Value: !Ref ResourceGateway + ResourceConfigurationId: + Value: !Ref ResourceConfiguration + ResourceEndpointId: + Value: !Ref ResourceEndpoint + CustomDomainName: + Value: !Ref CustomDomainName diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/xacct/consumer.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/xacct/consumer.yaml new file mode 100644 index 0000000..0340ef1 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/xacct/consumer.yaml @@ -0,0 +1,45 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +AWSTemplateFormatVersion: '2010-09-09' +Description: > + CONSUMER-side VPC for the cross-account DNS visibility test. Deploy this in + the consumer account. A dedicated VPC that receives the shared/associated + Route 53 constructs from the provider account, so you can enumerate what is + actually visible from the consumer perspective. + +Parameters: + NamePrefix: + Type: String + Default: xacct-dns-consumer + VpcCidr: + Type: String + Default: 10.91.0.0/16 + +Resources: + Vpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: !Ref VpcCidr + EnableDnsSupport: true + EnableDnsHostnames: true + Tags: [{Key: Name, Value: !Sub '${NamePrefix}-vpc'}] + SubnetA: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref Vpc + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: !Select [0, !Cidr [!Ref VpcCidr, 4, 8]] + Tags: [{Key: Name, Value: !Sub '${NamePrefix}-sn-a'}] + SubnetB: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref Vpc + AvailabilityZone: !Select [1, !GetAZs ''] + CidrBlock: !Select [1, !Cidr [!Ref VpcCidr, 4, 8]] + Tags: [{Key: Name, Value: !Sub '${NamePrefix}-sn-b'}] + +Outputs: + VpcId: {Value: !Ref Vpc} + SubnetAId: {Value: !Ref SubnetA} + SubnetBId: {Value: !Ref SubnetB} diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/xacct/provider.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/xacct/provider.yaml new file mode 100644 index 0000000..82791f4 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/test-infra/xacct/provider.yaml @@ -0,0 +1,151 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +AWSTemplateFormatVersion: '2010-09-09' +Description: > + PROVIDER-side resources for the cross-account DNS visibility test. Deploy this + in the provider (owner) account. These constructs are shared/associated to the + consumer account out-of-band (RAM + PHZ auth handshake), then enumerated from + the consumer to see what is actually visible. + +Parameters: + NamePrefix: + Type: String + Default: xacct-dns-test + ConsumerAccountId: + Type: String + Description: > + 12-digit AWS account ID of the CONSUMER account that the Route 53 + constructs are shared with. No default - supply this at deploy time. + AllowedPattern: '^[0-9]{12}$' + ConstraintDescription: Must be a 12-digit AWS account ID. + # A throwaway VPC in the provider account to host the resolver outbound endpoint. + ProviderVpcCidr: + Type: String + Default: 10.90.0.0/16 + +Resources: + # ---- minimal provider VPC (for the outbound resolver endpoint) ---- + ProviderVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: !Ref ProviderVpcCidr + EnableDnsSupport: true + EnableDnsHostnames: true + Tags: [{Key: Name, Value: !Sub '${NamePrefix}-provider-vpc'}] + SubnetA: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref ProviderVpc + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: !Select [0, !Cidr [!Ref ProviderVpcCidr, 4, 8]] + Tags: [{Key: Name, Value: !Sub '${NamePrefix}-sn-a'}] + SubnetB: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref ProviderVpc + AvailabilityZone: !Select [1, !GetAZs ''] + CidrBlock: !Select [1, !Cidr [!Ref ProviderVpcCidr, 4, 8]] + Tags: [{Key: Name, Value: !Sub '${NamePrefix}-sn-b'}] + ResolverEpSg: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: 'xacct dns test - resolver outbound endpoint SG' + VpcId: !Ref ProviderVpc + SecurityGroupEgress: + - {IpProtocol: udp, FromPort: 53, ToPort: 53, CidrIp: 0.0.0.0/0} + - {IpProtocol: tcp, FromPort: 53, ToPort: 53, CidrIp: 0.0.0.0/0} + Tags: [{Key: Name, Value: !Sub '${NamePrefix}-resolver-ep-sg'}] + + # ---- (1) PHZ for direct cross-account association (non-RAM handshake) ---- + DirectPhz: + Type: AWS::Route53::HostedZone + Properties: + Name: direct.xacct-test.internal. + # Associate to the PROVIDER vpc at create; consumer assoc done out-of-band. + VPCs: + - {VPCId: !Ref ProviderVpc, VPCRegion: !Ref 'AWS::Region'} + DirectPhzRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref DirectPhz + Name: app.direct.xacct-test.internal. + Type: A + TTL: '60' + ResourceRecords: ['10.90.200.10'] + + # ---- (2) Resolver outbound endpoint + FORWARD rule (shared via RAM) ---- + OutboundEndpoint: + Type: AWS::Route53Resolver::ResolverEndpoint + Properties: + Name: !Sub '${NamePrefix}-outbound' + Direction: OUTBOUND + SecurityGroupIds: [!Ref ResolverEpSg] + IpAddresses: + - {SubnetId: !Ref SubnetA} + - {SubnetId: !Ref SubnetB} + SharedForwardRule: + Type: AWS::Route53Resolver::ResolverRule + Properties: + Name: !Sub '${NamePrefix}-fwd-ram' + DomainName: ram-forward.xacct-test.internal. + RuleType: FORWARD + ResolverEndpointId: !Ref OutboundEndpoint + TargetIps: [{Ip: 10.90.0.53, Port: '53'}] + + # ---- (3) DNS Firewall domain list + rule group (shared via RAM) ---- + FirewallDomainList: + Type: AWS::Route53Resolver::FirewallDomainList + Properties: + Name: !Sub '${NamePrefix}-blocklist' + Domains: ['blocked-xacct.example.com.'] + FirewallRuleGroup: + Type: AWS::Route53Resolver::FirewallRuleGroup + Properties: + Name: !Sub '${NamePrefix}-frg' + FirewallRules: + - FirewallDomainListId: !Ref FirewallDomainList + Priority: 100 + Action: BLOCK + BlockResponse: NXDOMAIN + + # ---- (4) Route 53 Profile + resources INSIDE it (shared via RAM) ---- + Profile: + Type: AWS::Route53Profiles::Profile + Properties: + Name: !Sub '${NamePrefix}-profile' + # A resolver rule contributed via the profile (well-supported). The + # profile-PHZ association is added out-of-band via CLI (ARN form differs). + ProfileForwardRule: + Type: AWS::Route53Resolver::ResolverRule + Properties: + Name: !Sub '${NamePrefix}-profile-fwd' + DomainName: profile-forward.xacct-test.internal. + RuleType: FORWARD + ResolverEndpointId: !Ref OutboundEndpoint + TargetIps: [{Ip: 10.90.0.54, Port: '53'}] + ProfileRuleAssoc: + Type: AWS::Route53Profiles::ProfileResourceAssociation + Properties: + Name: !Sub '${NamePrefix}-profile-rule-assoc' + ProfileId: !Ref Profile + ResourceArn: !GetAtt ProfileForwardRule.Arn + # A standalone PRIVATE PHZ we associate INTO the profile via CLI post-deploy. + # NOTE: a VPCs block is REQUIRED to make this a PRIVATE zone - without it CFN + # creates a PUBLIC zone, which Route 53 Profiles reject (RSLVR-05207). + ProfilePhz: + Type: AWS::Route53::HostedZone + Properties: + Name: profile-phz.xacct-test.internal. + VPCs: + - {VPCId: !Ref ProviderVpc, VPCRegion: !Ref 'AWS::Region'} + +Outputs: + ProviderVpcId: {Value: !Ref ProviderVpc} + DirectPhzId: {Value: !Ref DirectPhz} + SharedForwardRuleArn: {Value: !GetAtt SharedForwardRule.Arn} + FirewallRuleGroupArn: {Value: !GetAtt FirewallRuleGroup.Arn} + ProfileArn: {Value: !GetAtt Profile.Arn} + ProfileId: {Value: !Ref Profile} + ProfilePhzId: {Value: !Ref ProfilePhz} + ProfileForwardRuleArn: {Value: !GetAtt ProfileForwardRule.Arn} diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_allowlist.py b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_allowlist.py new file mode 100644 index 0000000..2ac73f2 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_allowlist.py @@ -0,0 +1,256 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the DNS Diagnostic MCP Server allowlist and input-validation logic. + +These are the injection-safety tests: the analog of a SQL-safety suite for a +data-plane server. The probe family's #1 risk is shell/command injection through +the {name} and {resolver} parameters, so these assert that only well-formed DNS +names and literal-IP / allowlisted-hostname resolvers pass, and that shell +metacharacters are rejected before any command is built. +""" + +import os +import sys + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +# Allowlists must be non-wildcard so validation paths are exercised, and the +# stage must not be prod (wildcards would fail-closed at import otherwise). +os.environ.setdefault("STAGE_NAME", "dev") +os.environ.setdefault("ALLOWED_ACCOUNTS", "111122223333") +os.environ.setdefault("ALLOWED_REGIONS", "us-east-1") +os.environ.setdefault("ALLOWED_VPCS", "vpc-abc123") +os.environ.setdefault("ALLOWED_RESOLVERS", "resolver.corp.example") + + +class TestNameValidation: + """{name} must be a valid DNS name with no shell metacharacters.""" + + def test_valid_names(self): + from server import _valid_name + for n in [ + "example.com", + "oidc.eks.us-east-1.amazonaws.com", + "db.internal.corp", + "a.b.c.d.e.f", + "host", + ]: + assert _valid_name(n), n + + def test_rejects_injection(self): + from server import _valid_name + for n in [ + "example.com; rm -rf /", + "$(curl evil)", + "`id`", + "a.com | nc evil 1", + "a.com && cat /etc/passwd", + "a.com\nsecond", + "a com", + "a.com'", + ]: + assert not _valid_name(n), n + + +class TestResolverValidation: + """{resolver} must be a literal IP or an operator-allowlisted hostname.""" + + def test_literal_ipv4_and_ipv6(self): + from server import _valid_resolver + for r in ["169.254.169.253", "10.0.0.2", "fd00:ec2::253", "8.8.8.8"]: + assert _valid_resolver(r), r + + def test_allowlisted_hostname(self): + from server import _valid_resolver + assert _valid_resolver("resolver.corp.example") + + def test_rejects_non_allowlisted_hostname(self): + from server import _valid_resolver + # Well-formed hostname but NOT in ALLOWED_RESOLVERS -> rejected, so the + # comparison feature cannot become an arbitrary-egress primitive. + assert not _valid_resolver("attacker.example.net") + + def test_rejects_injection(self): + from server import _valid_resolver + for r in ["8.8.8.8; rm -rf /", "$(id)", "10.0.0.2|nc x 1", "10.0.0.2 x"]: + assert not _valid_resolver(r) + + +class TestFamilyValidation: + """{family} is an enum: A or AAAA only.""" + + def test_family_enum(self): + from server import _valid_family + assert _valid_family("A") + assert _valid_family("AAAA") + assert not _valid_family("ANY") + assert not _valid_family("TXT; drop") + + +class TestProbeBoundary: + """The probe boundary is the SSM document (structured params), not a command + string. Assert the server sends only Name/Resolver/Family and never a + 'commands' list.""" + + def test_param_names_are_structured_only(self): + from server import PROBE_PARAM_NAMES + assert set(PROBE_PARAM_NAMES) == {"Name", "Resolver", "Family"} + assert "commands" not in PROBE_PARAM_NAMES + + def test_no_command_string_builder_remains(self): + # The old free-command plumbing must be gone - the document renders the + # fixed probe set from structured params. + import server + assert not hasattr(server, "_build_probe_commands") + assert not hasattr(server, "PROBE_TEMPLATES") + + def test_ssm_run_probe_sends_structured_params(self): + import server + + captured = {} + + class _FakeSSM: + def describe_instance_information(self, **kw): + return {"InstanceInformationList": [{"InstanceId": "i-1"}]} + + def send_command(self, **kw): + captured.update(kw) + return {"Command": {"CommandId": "c-1"}} + + def get_command_invocation(self, **kw): + return {"Status": "Success", "StandardOutputContent": "ok", "StandardErrorContent": ""} + + class _FakeSession: + def client(self, name): + return _FakeSSM() + + # Avoid the 2s poll sleep. + orig_sleep = server.time.sleep + server.time.sleep = lambda *_: None + try: + out = server._ssm_run_probe(_FakeSession(), "i-1", "example.com", "169.254.169.253", "A") + finally: + server.time.sleep = orig_sleep + + assert out["status"] == "Success" + assert captured["DocumentName"] # a document is targeted + assert captured["Parameters"] == { + "Name": ["example.com"], "Resolver": ["169.254.169.253"], "Family": ["A"], + } + assert "commands" not in captured["Parameters"] + + +class TestSimulateChangeSchema: + """Mode B accepts only known, structured change types (no free text).""" + + KNOWN = { + "enable_vpce_private_dns", + "associate_phz", + "add_resolver_rule", + "associate_dns_firewall", + "associate_profile", + "set_snva_preference", + "set_dhcp_dns", + } + + def test_known_change_types_stable(self): + # Guard against silent drift of the accepted change-type set. + assert len(self.KNOWN) == 7 + + +class TestDhcpDnsRead: + """_read_dhcp_dns extracts domain-name-servers / domain-name and classifies + AmazonProvidedDNS vs a custom resolver.""" + + class _FakeEc2: + def __init__(self, dhcp_id, configs): + self._dhcp_id = dhcp_id + self._configs = configs + + def describe_vpcs(self, VpcIds): + return {"Vpcs": [{"DhcpOptionsId": self._dhcp_id}]} + + def describe_dhcp_options(self, DhcpOptionsIds): + return {"DhcpOptions": [{"DhcpConfigurations": self._configs}]} + + def test_custom_resolver(self): + import server + ec2 = self._FakeEc2("dopt-1", [ + {"Key": "domain-name-servers", "Values": [{"Value": "10.1.1.53"}, {"Value": "10.1.2.53"}]}, + {"Key": "domain-name", "Values": [{"Value": "corp.example"}]}, + ]) + out = server._read_dhcp_dns(ec2, "vpc-1") + assert out["custom_servers"] == ["10.1.1.53", "10.1.2.53"] + assert out["is_amazon_provided"] is False + assert out["domain_name"] == "corp.example" + + def test_amazon_provided(self): + import server + ec2 = self._FakeEc2("dopt-2", [ + {"Key": "domain-name-servers", "Values": [{"Value": "AmazonProvidedDNS"}]}, + ]) + out = server._read_dhcp_dns(ec2, "vpc-1") + assert out["is_amazon_provided"] is True + assert out["custom_servers"] == [] + + def test_no_servers(self): + import server + ec2 = self._FakeEc2("dopt-3", []) + out = server._read_dhcp_dns(ec2, "vpc-1") + assert out["custom_servers"] == [] + assert out["is_amazon_provided"] is False + + +class TestProfilePhzOpaque: + """_build_effective_model must emit an OPAQUE marker (not crash) when a + profile-contained PHZ's get_hosted_zone denies cross-account. Mirrors the + live provider->consumer finding (AccessDenied on the profile private PHZ).""" + + def _session(self): + import botocore.exceptions + + class _R53R: # route53resolver + def list_resolver_rule_associations(self, **kw): + return {"ResolverRuleAssociations": []} + def list_firewall_rule_group_associations(self, **kw): + return {"FirewallRuleGroupAssociations": []} + class _R53P: # route53profiles + def list_profile_associations(self, **kw): + return {"ProfileAssociations": [{"ResourceId": "vpc-abc123", "ProfileId": "rp-1"}]} + def list_profile_resource_associations(self, **kw): + return {"ProfileResourceAssociations": [ + {"ResourceType": "PrivateHostedZone", "ResourceId": "Zopaque", "Name": "n"}]} + class _R53: # route53 + def list_hosted_zones_by_vpc(self, **kw): + return {"HostedZoneSummaries": []} + def get_hosted_zone(self, **kw): + raise botocore.exceptions.ClientError( + {"Error": {"Code": "AccessDenied", "Message": "denied"}}, "GetHostedZone") + class _EC2: + def describe_vpc_attribute(self, **kw): + return {"EnableDnsSupport": {"Value": True}} + def describe_vpc_endpoints(self, **kw): + return {"VpcEndpoints": []} + class _Lattice: + def list_service_network_vpc_associations(self, **kw): + return {"items": []} + + clients = {"route53resolver": _R53R(), "route53profiles": _R53P(), + "route53": _R53(), "ec2": _EC2(), "vpc-lattice": _Lattice()} + + class _Session: + region_name = "us-east-1" + def client(self, name): + return clients[name] + return _Session() + + def test_profile_phz_denied_becomes_opaque(self): + import server + m = server._build_effective_model(self._session(), "vpc-abc123") + # Build must NOT crash, and the denied profile PHZ must surface as an + # opaque marker so its influence is not silently dropped. + opaque = [r for r in m.resolver_rules if getattr(r, "opaque", False)] + assert opaque, "expected an opaque marker for the denied profile PHZ" + assert opaque[0].source == "profile:rp-1" diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_live_regressions.py b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_live_regressions.py new file mode 100644 index 0000000..7ed747c --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_live_regressions.py @@ -0,0 +1,113 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Regression tests for issues found during live validation against real AWS +fixtures (2026-07-27). These were not caught by the pre-existing unit tests. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from dns_model import ( # noqa: E402 + EffectiveModel, + Phz, + ResolverRule, + apply_change, + resolve, +) + + +def _model(**kw): + kw.setdefault("vpc_id", "vpc-test") + return EffectiveModel(**kw) + + +class TestAddResolverRuleTargetShape: + """apply_change accepted only 'target', so a caller using the documented + 'target_ips' shape built a rule whose target rendered empty in the report.""" + + def test_target_ips_list_is_rendered(self): + m = apply_change( + _model(), + { + "type": "add_resolver_rule", + "domain": ".", + "rule_type": "FORWARD", + "target_ips": ["10.99.0.53"], + }, + ) + assert m.resolver_rules[-1].target == "10.99.0.53" + + def test_multiple_target_ips_are_joined(self): + m = apply_change( + _model(), + { + "type": "add_resolver_rule", + "domain": "onprem.corp.", + "target_ips": ["10.99.0.53", "10.99.1.53"], + }, + ) + assert m.resolver_rules[-1].target == "10.99.0.53, 10.99.1.53" + + def test_target_ips_as_bare_string_is_accepted(self): + m = apply_change( + _model(), + {"type": "add_resolver_rule", "domain": ".", "target_ips": "10.99.0.53"}, + ) + assert m.resolver_rules[-1].target == "10.99.0.53" + + def test_explicit_target_still_wins(self): + m = apply_change( + _model(), + { + "type": "add_resolver_rule", + "domain": ".", + "target": "onprem", + "target_ips": ["10.99.0.53"], + }, + ) + assert m.resolver_rules[-1].target == "onprem" + + def test_no_target_does_not_crash(self): + m = apply_change(_model(), {"type": "add_resolver_rule", "domain": "."}) + assert m.resolver_rules[-1].target == "" + + +class TestForwardTargetRendering: + """An empty target used to render as a dangling '-> ' in the impact table.""" + + def test_unspecified_target_is_labelled(self): + m = _model( + resolver_rules=(ResolverRule(".", "FORWARD", "", "direct"),), + phzs=(Phz("internal.corp.", "direct"),), + ) + r = resolve("db.internal.corp", m) + assert "(target unspecified)" in r.winner + + def test_populated_target_renders_normally(self): + m = _model( + resolver_rules=(ResolverRule(".", "FORWARD", "10.99.0.53", "direct"),), + phzs=(Phz("internal.corp.", "direct"),), + ) + r = resolve("db.internal.corp", m) + assert "10.99.0.53" in r.winner + assert "unspecified" not in r.winner + + +class TestProbeRoleReadRequirements: + """dns_probe_context's DHCP discovery calls DescribeVpcs and + DescribeDhcpOptions. The probe role originally granted neither, so Mode A + failed live with UnauthorizedOperation. Guard the IAM template.""" + + def test_probe_role_grants_dhcp_discovery_reads(self): + tpl = os.path.join( + os.path.dirname(__file__), "..", "scoped-roles.yaml" + ) + with open(tpl, encoding="utf-8") as fh: + body = fh.read() + probe = body.split("DnsDiagnosticProbeRole:", 1)[1] + for action in ("ec2:DescribeVpcs", "ec2:DescribeDhcpOptions"): + assert action in probe, f"probe role missing {action}" diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py new file mode 100644 index 0000000..b3180f5 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py @@ -0,0 +1,202 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Guards for the findings raised by the MCP security review (2026-07-28). + +These assert the IAM template stays least-privilege and the fail-closed resolver +behaviour holds. They exist so a later change cannot silently re-widen a grant +that was deliberately narrowed. +""" + +import os +import subprocess +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +SCOPED_ROLES = os.path.join(os.path.dirname(__file__), "..", "scoped-roles.yaml") +SERVER_PY = os.path.join(os.path.dirname(__file__), "..", "src", "server.py") + + +def _roles_yaml() -> str: + with open(SCOPED_ROLES, encoding="utf-8") as fh: + return fh.read() + + +def _readonly_role_block() -> str: + """The DnsDiagnosticReadOnlyRole resource, up to the next role.""" + body = _roles_yaml() + start = body.index("DnsDiagnosticReadOnlyRole:") + end = body.index("DnsDiagnosticProbeRole:") + return body[start:end] + + +def _granted_actions(block: str) -> list[str]: + """IAM actions granted in a role block. + + Only real grants: `- :` list entries and `Action: ` + scalars. Comments and ARN fields are excluded, so an explanatory comment + mentioning a wildcard, or a region wildcard inside an ARN, is not mistaken + for a permission. + """ + actions = [] + for raw in block.splitlines(): + line = raw.strip() + if line.startswith("#"): + continue + if line.startswith("- ") and ":" in line: + candidate = line[2:].strip() + # An action is service:Action -- reject ARNs and key: value pairs. + if candidate.startswith("arn:") or " " in candidate: + continue + if candidate.count(":") == 1: + actions.append(candidate) + elif line.startswith("Action: "): + candidate = line[len("Action: "):].strip() + if candidate and candidate.count(":") == 1: + actions.append(candidate) + return actions + + +class TestF4NoLatentLogsGrant: + """F-4: logs:StartQuery / GetQueryResults / DescribeLogGroups were granted but + never called. Query-log enrichment is unimplemented; the grant must stay out + until the code that uses it lands.""" + + def test_no_logs_actions_anywhere_in_template(self): + offenders = [ + a for a in _granted_actions(_roles_yaml()) if a.startswith("logs:") + ] + assert offenders == [], f"unexpected CloudWatch Logs grant: {offenders}" + + def test_server_makes_no_logs_api_calls(self): + with open(SERVER_PY, encoding="utf-8") as fh: + src = fh.read() + for call in ("start_query", "get_query_results", "describe_log_groups"): + assert call not in src, ( + f"server calls {call} but the IAM grant was removed -- " + "restore the grant in the same change that adds the call" + ) + + +class TestF5LatticeGrantsAreExplicit: + """F-5: vpc-lattice:List*/Get* wildcards would pick up any future API with a + List/Get prefix. Only the two APIs actually called may be granted.""" + + def test_no_lattice_wildcards(self): + granted = _granted_actions(_readonly_role_block()) + offenders = [ + a for a in granted if a.startswith("vpc-lattice:") and "*" in a + ] + assert offenders == [], f"lattice wildcard reintroduced: {offenders}" + + def test_the_two_used_apis_are_granted(self): + granted = _granted_actions(_readonly_role_block()) + for action in ( + "vpc-lattice:ListServiceNetworkVpcAssociations", + "vpc-lattice:GetResourceConfiguration", + ): + assert action in granted, f"missing required grant {action}" + + def test_granted_lattice_apis_match_the_code(self): + """Every lattice call in server.py must have a matching grant.""" + with open(SERVER_PY, encoding="utf-8") as fh: + src = fh.read() + granted = _granted_actions(_readonly_role_block()) + for snake, iam in ( + ("list_service_network_vpc_associations", "ListServiceNetworkVpcAssociations"), + ("get_resource_configuration", "GetResourceConfiguration"), + ): + if snake in src: + assert f"vpc-lattice:{iam}" in granted, ( + f"server calls {snake} with no vpc-lattice:{iam} grant" + ) + + +class TestProbeRoleStaysMinimal: + """The probe role's only privileged grant must remain a resource-scoped + ssm:SendCommand. Nothing mutating may creep in.""" + + def _probe_block(self) -> str: + body = _roles_yaml() + return body[body.index("DnsDiagnosticProbeRole:"):] + + def test_no_mutating_ssm_actions(self): + granted = _granted_actions(self._probe_block()) + for bad in ( + "ssm:CreateDocument", + "ssm:UpdateDocument", + "ssm:DeleteDocument", + "ssm:StartSession", + "ssm:StartAutomationExecution", + "ssm:PutParameter", + "ssm:*", + ): + assert bad not in granted, f"probe role gained {bad}" + + def test_only_sendcommand_is_privileged(self): + """Every ssm grant must be SendCommand or a read.""" + allowed = { + "ssm:SendCommand", + "ssm:GetCommandInvocation", + "ssm:ListCommandInvocations", + "ssm:DescribeInstanceInformation", + } + granted = {a for a in _granted_actions(self._probe_block()) if a.startswith("ssm:")} + assert granted <= allowed, f"unexpected ssm grants: {granted - allowed}" + + def test_sendcommand_is_document_scoped(self): + block = self._probe_block() + assert "document/${DiagnosticDocumentName}" in block, ( + "ssm:SendCommand must stay scoped to the single diagnostic document" + ) + + +class TestF1ResolverFailClosed: + """F-1: an empty resolver allowlist must permit literal IPs only and refuse + every hostname, and the wildcard case must warn at startup.""" + + def _fresh_server(self, env_extra): + """Import server.py in a subprocess with a controlled environment.""" + env = dict(os.environ) + env.update( + { + "ALLOWED_ACCOUNTS": "111122223333", + "ALLOWED_REGIONS": "us-east-1", + "STAGE_NAME": "dev", + } + ) + env.update(env_extra) + code = ( + "import server;" + "print('OK_IP', server._valid_resolver('10.0.0.2'));" + "print('OK_HOST', server._valid_resolver('resolver.example.com'))" + ) + return subprocess.run( + [sys.executable, "-c", code], + cwd=os.path.join(os.path.dirname(__file__), "..", "src"), + env=env, + capture_output=True, + text=True, + timeout=120, + ) + + def test_wildcard_resolvers_emits_warning(self): + r = self._fresh_server({"ALLOWED_RESOLVERS": "*"}) + combined = r.stdout + r.stderr + assert "WARNING" in combined and "ALLOWED_RESOLVERS" in combined, ( + f"expected a wildcard-resolver warning, got: {combined[:400]}" + ) + + def test_wildcard_still_refuses_hostnames(self): + r = self._fresh_server({"ALLOWED_RESOLVERS": "*"}) + assert "OK_IP True" in r.stdout, r.stdout + r.stderr + assert "OK_HOST False" in r.stdout, ( + "a wildcard resolver allowlist must STILL refuse hostnames " + f"(fail-closed): {r.stdout}" + ) + + def test_explicit_allowlist_emits_no_warning(self): + r = self._fresh_server({"ALLOWED_RESOLVERS": "10.0.0.2"}) + assert "WARNING" not in (r.stdout + r.stderr) diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_simulate.py b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_simulate.py new file mode 100644 index 0000000..c0e5c3f --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_simulate.py @@ -0,0 +1,313 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Mode B core: resolver engine, change application, trap detectors. + +These are pure/deterministic and need no AWS. They encode the documented DNS +traps (VPCE PHZ shadow, broad FORWARD sweep, SNVA flag AND-ing, DNS Firewall +block, Route 53 Profile union shift) as regression cases. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from dns_model import ( # noqa: E402 + EffectiveModel, FirewallRule, ResolverRule, Phz, Vpce, + resolve, apply_change, simulate, + BLOCKED, VPCE_PRIVATE, PHZ_PRIVATE, ONPREM, PUBLIC, NXDOMAIN, +) + + +def _base(**kw) -> EffectiveModel: + defaults = dict(vpc_id="vpc-abc123") + defaults.update(kw) + return EffectiveModel(**defaults) + + +class TestResolverEngine: + def test_default_public(self): + r = resolve("www.example.com", _base()) + assert r.answer_class == PUBLIC + + def test_phz_private(self): + m = _base(phzs=(Phz("internal.corp."),)) + assert resolve("db.internal.corp", m).answer_class == PHZ_PRIVATE + + def test_vpce_private_dns(self): + m = _base(vpces=(Vpce("secretsmanager.us-east-1.amazonaws.com", True),), + snva_preference="ALL_DOMAINS") + assert resolve("secretsmanager.us-east-1.amazonaws.com", m).answer_class == VPCE_PRIVATE + + def test_snva_gate_blocks_aws_override(self): + # VERIFIED_DOMAINS_ONLY should NOT override an AWS FQDN -> stays public. + m = _base(vpces=(Vpce("secretsmanager.us-east-1.amazonaws.com", True),), + snva_preference="VERIFIED_DOMAINS_ONLY") + assert resolve("secretsmanager.us-east-1.amazonaws.com", m).answer_class == PUBLIC + + def test_specific_forward_beats_root(self): + m = _base(resolver_rules=( + ResolverRule(".", "FORWARD", "onprem"), + ResolverRule("internal.corp.", "FORWARD", "onprem"), + )) + r = resolve("db.internal.corp", m) + assert r.answer_class == ONPREM + assert "internal.corp" in r.winner + + def test_onprem_zone_nxdomain_without_forward(self): + # Declared on-prem zone but no FORWARD path -> .2 NXDOMAINs (correct). + m = _base(onprem_zones=("internal.corp",)) + assert resolve("db.internal.corp", m).answer_class == NXDOMAIN + + def test_firewall_block_wins(self): + m = _base(firewall_rules=(FirewallRule(("bad.example.",), "BLOCK", "NXDOMAIN"),)) + assert resolve("bad.example", m).answer_class == NXDOMAIN + + def test_dns_support_off_darkens_resolver(self): + m = _base(phzs=(Phz("internal.corp."),), dns_support=False) + assert resolve("db.internal.corp", m).answer_class == NXDOMAIN + + def test_dns_support_off_darkens_forward_and_firewall(self): + # PY-H2: a dark VPC resolver must darken FORWARD and DNS Firewall paths + # too, not just PHZ/VPCE. Previously these returned before the check. + m = _base(dns_support=False, + resolver_rules=(ResolverRule("onprem.corp.", "FORWARD", "onprem"),), + firewall_rules=(FirewallRule(("bad.example.",), "BLOCK", "NXDOMAIN"),)) + assert resolve("host.onprem.corp", m).answer_class == NXDOMAIN + assert resolve("bad.example", m).answer_class == NXDOMAIN + assert "enableDnsSupport=false" in resolve("bad.example", m).winner + + def test_snva_specified_domains_only(self): + # M1: SPECIFIED_DOMAINS_ONLY overrides an AWS FQDN only for a specified + # domain, and blocks (public) otherwise - not allow-all. + base_kw = dict( + vpces=(Vpce("secretsmanager.us-east-1.amazonaws.com", True, "direct", + ("secretsmanager.us-east-1.amazonaws.com",)),), + snva_preference="SPECIFIED_DOMAINS_ONLY", + ) + m_in = _base(specified_domains=("secretsmanager.us-east-1.amazonaws.com",), **base_kw) + assert resolve("secretsmanager.us-east-1.amazonaws.com", m_in).answer_class == VPCE_PRIVATE + m_out = _base(specified_domains=("other.example",), **base_kw) + assert resolve("secretsmanager.us-east-1.amazonaws.com", m_out).answer_class == PUBLIC + + def test_snva_verified_and_specified(self): + # VERIFIED_DOMAINS_AND_SPECIFIED_DOMAINS behaves like SPECIFIED for an + # AWS FQDN: override only when the name is in the specified set. + base_kw = dict( + vpces=(Vpce("secretsmanager.us-east-1.amazonaws.com", True, "direct", + ("secretsmanager.us-east-1.amazonaws.com",)),), + snva_preference="VERIFIED_DOMAINS_AND_SPECIFIED_DOMAINS", + ) + m_in = _base(specified_domains=("secretsmanager.us-east-1.amazonaws.com",), **base_kw) + assert resolve("secretsmanager.us-east-1.amazonaws.com", m_in).answer_class == VPCE_PRIVATE + m_out = _base(specified_domains=("other.example",), **base_kw) + assert resolve("secretsmanager.us-east-1.amazonaws.com", m_out).answer_class == PUBLIC + + def test_recursive_rule_is_not_an_override(self): + # The default '.' RECURSIVE Internet Resolver rule means "resolve + # normally" - it must NOT be treated as a FORWARD/override. A public name + # with only a RECURSIVE '.' rule still resolves PUBLIC; a PHZ name still + # resolves via the PHZ. + m = _base( + resolver_rules=(ResolverRule(".", "RECURSIVE", ""),), + phzs=(Phz("internal.corp."),), + ) + assert resolve("www.example.com", m).answer_class == PUBLIC + assert resolve("db.internal.corp", m).answer_class == PHZ_PRIVATE + + def test_forward_beats_system_equal_specificity(self): + # Documented tie-break (L5): at equal specificity FORWARD wins over SYSTEM. + m = _base(resolver_rules=( + ResolverRule("amazonaws.com.", "SYSTEM", ""), + ResolverRule("amazonaws.com.", "FORWARD", "onprem"), + )) + assert resolve("sts.us-east-1.amazonaws.com", m).answer_class == ONPREM + + def test_opaque_firewall_rule_is_opaque(self): + # A cross-account shared firewall group whose domains are unreadable -> + # any name's effect is unpredictable -> OPAQUE (not silently inert). + from dns_model import FirewallRule, OPAQUE + m = _base(firewall_rules=( + FirewallRule(domains=(), action="BLOCK", block_response="NXDOMAIN", + source="direct", opaque=True),)) + r = resolve("anything.example.com", m) + assert r.answer_class == OPAQUE + assert "not readable" in r.winner + + def test_opaque_resolver_rule_is_opaque_when_no_concrete_match(self): + # An opaque profile-delivered rule -> OPAQUE if nothing concrete claims + # the name; a concrete match still wins over it. + from dns_model import OPAQUE + m = _base(resolver_rules=( + ResolverRule("", "FORWARD", "", source="profile:rp-x", opaque=True),)) + assert resolve("whatever.example", m).answer_class == OPAQUE + # Concrete PHZ match takes precedence over the opaque rule (firewall/rule + # steps only OPAQUE-out when no concrete resolver rule matched; PHZ is + # below resolver rules, so verify a concrete FORWARD wins): + m2 = _base(resolver_rules=( + ResolverRule("corp.example.", "FORWARD", "onprem", source="direct"), + ResolverRule("", "FORWARD", "", source="profile:rp-x", opaque=True),)) + assert resolve("db.corp.example", m2).answer_class == ONPREM + + def test_opaque_firewall_precedes_everything(self): + # Opaque firewall evaluates first (like any firewall) - even a name a PHZ + # would answer is reported OPAQUE because the hidden block list might + # cover it. + from dns_model import FirewallRule, OPAQUE + m = _base( + phzs=(Phz("internal.corp."),), + firewall_rules=(FirewallRule(domains=(), action="BLOCK", + source="direct", opaque=True),), + ) + assert resolve("db.internal.corp", m).answer_class == OPAQUE + + def test_ungated_resource_endpoint_shadow_ignores_snva_gate(self): + # A Lattice resource-endpoint shadow over a CUSTOM domain is ungated: + # the SNVA PrivateDnsPreference does not apply (it governs AWS FQDNs). + # Even with the default VERIFIED_DOMAINS_ONLY gate, a custom-domain + # shadow resolves private. + from dns_model import Shadow + m = _base( + vpces=(Shadow("app.internal.example", True, "resource-endpoint:re-1", + ("app.internal.example",), gated=False),), + snva_preference="VERIFIED_DOMAINS_ONLY", + ) + assert resolve("app.internal.example", m).answer_class == VPCE_PRIVATE + # Unserved subdomain under the resource-endpoint shadow -> NXDOMAIN. + assert resolve("x.app.internal.example", m).answer_class == NXDOMAIN + + def test_ungated_shadow_on_aws_fqdn_still_ignores_gate(self): + # Ungated endpoint shadows are not AWS FQDNs in practice, but confirm the + # gate is bypassed purely by the gated flag, not the domain check. + from dns_model import Shadow + m = _base( + vpces=(Shadow("svc.example.com", True, "resource-endpoint:re-2", + ("svc.example.com",), gated=False),), + snva_preference="VERIFIED_DOMAINS_ONLY", + ) + assert resolve("svc.example.com", m).answer_class == VPCE_PRIVATE + + def test_gated_interface_vpce_still_respects_gate(self): + # Regression: the gated interface-VPCE path still honors the SNVA gate. + m = _base( + vpces=(Vpce("secretsmanager.us-east-1.amazonaws.com", True, "direct", + ("secretsmanager.us-east-1.amazonaws.com",)),), # gated defaults True + snva_preference="VERIFIED_DOMAINS_ONLY", + ) + assert resolve("secretsmanager.us-east-1.amazonaws.com", m).answer_class == PUBLIC + + +class TestTrapDetectors: + def test_vpce_shadow_nxdomain(self): + # Real pipeline: with the private-DNS override active (ALL_DOMAINS), the + # endpoint's shadow PHZ captures the whole apex. An unserved subdomain + # -> NXDOMAIN. Driven through simulate(), NOT a hand-built model, so it + # proves the trap fires on real input. + m = _base(snva_preference="ALL_DOMAINS") + change = {"type": "enable_vpce_private_dns", + "service_apex": "oidc.eks.us-east-1.amazonaws.com", + "served_names": ["oidc.eks.us-east-1.amazonaws.com"]} + name = "abc123.oidc.eks.us-east-1.amazonaws.com" # not a served record + impacts = simulate(m, change, [name]) + assert impacts, "expected an impact for the shadowed subdomain" + assert impacts[0].after.answer_class == NXDOMAIN + assert "VPCE-shadow-NXDOMAIN" in impacts[0].traps + assert impacts[0].severity == "high" + + def test_vpce_apex_still_resolves_private(self): + # The exact apex (and served names) still resolve VPCE_PRIVATE - shadow + # only NXDOMAINs unserved subdomains. + m = _base() + change = {"type": "enable_vpce_private_dns", + "service_apex": "secretsmanager.us-east-1.amazonaws.com", + "served_names": ["secretsmanager.us-east-1.amazonaws.com"]} + after = apply_change(m, change) + r = resolve("secretsmanager.us-east-1.amazonaws.com", after) + # SNVA default VERIFIED_DOMAINS_ONLY blocks the AWS-FQDN override -> public + assert r.answer_class == PUBLIC + # With ALL_DOMAINS the apex resolves private: + from dns_model import EffectiveModel as EM + after2 = EM(**{**after.__dict__, "snva_preference": "ALL_DOMAINS"}) + assert resolve("secretsmanager.us-east-1.amazonaws.com", after2).answer_class == VPCE_PRIVATE + + def test_broad_forward_sweep(self): + m = _base() + change = {"type": "add_resolver_rule", "rule_type": "FORWARD", + "domain": ".", "target": "onprem"} + impacts = simulate(m, change, ["sts.us-east-1.amazonaws.com"]) + assert impacts + assert "broad-FORWARD-sweep" in impacts[0].traps + assert impacts[0].after.answer_class == ONPREM + assert impacts[0].severity == "high" + + def test_flag_and_mismatch(self): + # VPCE private DNS present but SNVA gate leaves the AWS FQDN public. + m = _base(vpces=(Vpce("secretsmanager.us-east-1.amazonaws.com", True),), + snva_preference="VERIFIED_DOMAINS_ONLY") + change = {"type": "set_snva_preference", "preference": "VERIFIED_DOMAINS_ONLY"} + from dns_model import detect_traps + name = "secretsmanager.us-east-1.amazonaws.com" + after_model = apply_change(m, change) + b = resolve(name, m) + a = resolve(name, after_model) + traps = detect_traps(name, b, a, m, after_model, change) + assert "flag-AND-mismatch" in traps + + def test_dns_firewall_block(self): + m = _base() + change = {"type": "associate_dns_firewall", "domains": ["evil.example."], + "action": "BLOCK", "block_response": "NXDOMAIN"} + impacts = simulate(m, change, ["evil.example"]) + assert impacts and "DNS-Firewall-block" in impacts[0].traps + + def test_profile_union_shift(self): + m = _base() + change = { + "type": "associate_profile", "profile_id": "rp-123", + "resources": {"resolver_rules": [ + {"domain": "internal.corp.", "rule_type": "FORWARD", "target": "onprem"} + ]}, + } + impacts = simulate(m, change, ["db.internal.corp"]) + assert impacts + assert "Profile-union-shift" in impacts[0].traps + assert impacts[0].after.source == "profile:rp-123" + + def test_resolver_disabled(self): + # M5: set_dhcp_dns that darkens the VPC resolver labels the break. + m = _base(phzs=(Phz("internal.corp."),)) + change = {"type": "set_dhcp_dns", "dns_support": False} + impacts = simulate(m, change, ["db.internal.corp"]) + assert impacts + assert "resolver-disabled" in impacts[0].traps + assert impacts[0].after.answer_class == NXDOMAIN + + def test_profile_source_only_shift_is_medium(self): + # L4: a Profile-union-shift trap that only changed the source (answer + # unchanged) is a benign ownership move -> medium, not high. Tested at + # the _severity level since the additive model keeps a direct construct + # winning, so a pure source-only shift is a severity-policy concern. + from dns_model import _severity, Resolution, PHZ_PRIVATE + before = Resolution("db.internal.corp", PHZ_PRIVATE, "PHZ 'internal.corp.'", "direct") + after = Resolution("db.internal.corp", PHZ_PRIVATE, "PHZ 'internal.corp.'", "profile:rp-9") + assert _severity(before, after, ["Profile-union-shift"], 0) == "medium" + # A Profile shift that also changes the answer stays high. + after_break = Resolution("db.internal.corp", NXDOMAIN, "x", "profile:rp-9") + assert _severity(before, after_break, ["Profile-union-shift"], 0) == "high" + # Any other trap remains high regardless. + assert _severity(before, after, ["broad-FORWARD-sweep"], 0) == "high" + + +class TestSimulateRanking: + def test_ranks_high_before_medium_and_omits_no_delta(self): + m = _base(phzs=(Phz("internal.corp."),)) + change = {"type": "add_resolver_rule", "rule_type": "FORWARD", + "domain": ".", "target": "onprem"} + names = ["sts.us-east-1.amazonaws.com", "www.example.com", "unchanged.internal.corp"] + # internal.corp is PHZ before; '.' FORWARD sweeps it to onprem too -> changes. + impacts = simulate(m, change, names, volumes={"sts.us-east-1.amazonaws.com": 5000}) + # All three change (root FORWARD sweeps everything), highest severity first. + assert impacts[0].severity == "high" + sev_order = [i.severity for i in impacts] + assert sev_order == sorted(sev_order, key=lambda s: {"high": 0, "medium": 1, "none": 2}[s]) diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_sops.py b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_sops.py new file mode 100644 index 0000000..e1c88b5 --- /dev/null +++ b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_sops.py @@ -0,0 +1,89 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Tests for the bundled SOP runbook tools (list_sops / get_sop). + +Covers: catalogue/file consistency both ways, retrieval, rejection of unknown +slugs, and path-traversal safety. +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +os.environ.setdefault("ALLOWED_ACCOUNTS", "111122223333") +os.environ.setdefault("ALLOWED_REGIONS", "us-east-1") + +from server import SOP_CATALOGUE, SOP_DIR, get_sop, list_sops # noqa: E402 + + +def _fn(tool): + """Unwrap a FastMCP-decorated tool to its underlying callable.""" + return getattr(tool, "fn", tool) + + +class TestSopCatalogue: + def test_every_catalogued_slug_has_a_file(self): + missing = [ + slug + for slug in SOP_CATALOGUE + if not os.path.isfile(os.path.join(SOP_DIR, f"{slug}.md")) + ] + assert missing == [], f"catalogued but no file: {missing}" + + def test_every_file_is_catalogued(self): + on_disk = {f[:-3] for f in os.listdir(SOP_DIR) if f.endswith(".md")} + uncatalogued = sorted(on_disk - set(SOP_CATALOGUE)) + assert uncatalogued == [], f"file present but not catalogued: {uncatalogued}" + + def test_triage_entry_point_exists(self): + """The vague-symptom entry point must always be present.""" + assert "Z-general-triage" in SOP_CATALOGUE + + def test_catalogue_descriptions_are_non_empty(self): + for slug, desc in SOP_CATALOGUE.items(): + assert desc.strip(), f"empty description for {slug}" + + +class TestListSops: + def test_lists_every_slug(self): + out = _fn(list_sops)() + for slug in SOP_CATALOGUE: + assert slug in out, f"{slug} missing from list_sops output" + + def test_points_at_triage_runbook(self): + assert "Z-general-triage" in _fn(list_sops)() + + +class TestGetSop: + def test_returns_content_for_each_slug(self): + for slug in SOP_CATALOGUE: + body = _fn(get_sop)(slug) + assert not body.startswith("ERROR"), f"{slug} failed to load" + assert len(body) > 200, f"{slug} content suspiciously short" + + def test_unknown_slug_is_rejected_with_valid_list(self): + out = _fn(get_sop)("no-such-runbook") + assert out.startswith("ERROR") + assert "Z-general-triage" in out, "error should list valid slugs" + + @pytest.mark.parametrize( + "evil", + [ + "../server", + "../../etc/passwd", + "/etc/passwd", + "Z-general-triage/../../server", + "..%2f..%2fetc%2fpasswd", + "Z-general-triage.md", + "", + ], + ) + def test_path_traversal_is_refused(self, evil): + """Slugs are allowlist-validated, so no traversal can reach the FS.""" + out = _fn(get_sop)(evil) + assert out.startswith("ERROR"), f"traversal not refused: {evil!r}" From edc73cf6fe9deabc63027f2e86d0f3914c498d49 Mon Sep 17 00:00:00 2001 From: ddericco Date: Wed, 29 Jul 2026 11:06:15 -0400 Subject: [PATCH 2/9] fix(mcp): Require an explicit account allowlist in every stage Security review callout: with no AllowedAccounts value the deployment defaulted to '*', which is an implicit allow-all account scope. For a DevOps Agent integration the account allowlist is the boundary that stops the server assuming a role into an arbitrary account, so it must be stated explicitly. Three separate routes reached allow-all, and the template default was only one of them: - deploying without overriding the AllowedAccounts parameter - passing '*' explicitly - the env var being absent, because _load_allowlist() fell back to '*' Changing only the template would have left the other two open, and the existing _enforce_prod_allowlists() gate covers wildcards solely when STAGE_NAME=prod, leaving a dev or staging deployment attached to a real Agent Space unprotected. template.yaml: AllowedAccounts loses its Default and gains AllowedPattern '^[0-9]{12}$' with a ConstraintDescription, so CloudFormation rejects a missing or malformed value per list item before the function boots. Confirmed via validate-template that DefaultValue is now null. src/server.py: new _enforce_account_allowlist() runs at import, unconditionally and in every stage. It refuses unset, empty, whitespace-only, any entry equal to '*', and any entry that is not a 12-digit account ID. Because it runs before the allowlists load, _load_allowlist()'s '*' fallback is unreachable for accounts and _validate()'s allow-all-on-empty branch cannot be hit for accounts either. Both docstrings now record that invariant. Regions, VPCs, and resolvers deliberately keep their permissive defaults. Accounts are known in advance; requiring ALLOWED_VPCS up front would force a redeploy to diagnose a new VPC. Those three stay guarded by the prod gate, and resolvers additionally by literal-IP-only fail-closed behaviour plus a startup warning. Docs: README parameter table, Controls table, and Safety blocked list; ARCHITECTURE fail-closed table. Tests: 88 passing, up from 77. Eleven new tests in TestAccountAllowlistRequired cover unset, empty, whitespace-only, wildcard, wildcard mixed with a real account, malformed, the prod-stage belt-and-braces case, three valid forms, and a guard asserting template.yaml never regains a Default. Verified the new tests fail when the fix is reverted. Also removes a duplicate _enforce_prod_allowlists() call left at module level by the edit that inserted the new gate. Harmless but confusing; caught in review. Verified: security review re-run returns PASS with no findings; all five earlier findings confirmed still cleared; no bypass found on the account path and no tool reaches an AWS API without passing _preflight(). Redeployed to a test account and confirmed the tools still work end to end. --- mcp/aws-vpc-dns-diagnostics-mcp/README.md | 11 ++- .../docs/ARCHITECTURE.md | 1 + mcp/aws-vpc-dns-diagnostics-mcp/src/server.py | 49 ++++++++++- mcp/aws-vpc-dns-diagnostics-mcp/template.yaml | 12 ++- .../tests/test_security_review.py | 87 +++++++++++++++++++ 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/README.md b/mcp/aws-vpc-dns-diagnostics-mcp/README.md index a6b0154..3c1a2be 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/README.md +++ b/mcp/aws-vpc-dns-diagnostics-mcp/README.md @@ -114,7 +114,7 @@ Note the `FunctionRoleArn` output. The scoped roles must trust it. | Parameter | Purpose | Default | | --- | --- | --- | | `StageName` | `dev`, `staging`, or `prod`. Wildcard allowlists are refused when `prod`. | `prod` | -| `AllowedAccounts` | Account IDs the tools may inspect | `*` (dev only) | +| `AllowedAccounts` | Account IDs the tools may inspect | **required, no default** | | `AllowedRegions` | Regions the tools may operate in | `*` (dev only) | | `AllowedVpcs` | VPC IDs the tools may target | `*` (dev only) | | `AllowedResolvers` | Extra resolver IPs/hostnames the probes may query | `*` (dev only) | @@ -122,6 +122,11 @@ Note the `FunctionRoleArn` output. The scoped roles must trust it. | `ProbeRoleArnPattern` | Per-account Mode A role ARN pattern | `arn:aws:iam::*:role/DnsDiagnosticProbeRole` | | `ReadOnlyRoleArnPattern` | Per-account Mode B role ARN pattern | `arn:aws:iam::*:role/DnsDiagnosticReadOnlyRole` | +`AllowedAccounts` has no default and does not accept `*`. It is the boundary that +stops the server assuming a role into an arbitrary account, so it must be stated +explicitly and the server refuses to start without it in **every** stage, not +only `prod`. Unset, empty, and `*` are all rejected at import. + Set every `Allowed*` parameter explicitly for anything beyond local testing. With `StageName=prod`, the server refuses to start if any allowlist is `*`. @@ -348,6 +353,7 @@ anything. - ❌ No mutating API of any kind: no create, modify, associate, or delete - ❌ Mode B holds no `ssm:SendCommand` grant at all - ❌ No account, region, VPC, or resolver outside the configured allowlists +- ❌ No startup at all without an explicit `ALLOWED_ACCOUNTS` list, in any stage - ❌ No startup at all when `StageName=prod` and any allowlist is a wildcard **Inputs are constrained before they reach anything:** @@ -400,7 +406,8 @@ probe output as untrusted input rather than as trustworthy diagnostic narration. | Credential scoping | Per tool family; Mode B's role never holds `ssm:SendCommand` | | Probe execution surface | One SSM document, fixed read-only probe set | | `ssm:SendCommand` scope | Resource-scoped to that one document ARN | -| Account / region / VPC allowlists | Enforced in one place, before any AWS call | +| Account allowlist | Required in every stage; unset, empty, and `*` all refuse startup | +| Region / VPC allowlists | Enforced in one place, before any AWS call | | Resolver allowlist | Fail-closed: literal IPs only unless a hostname is listed | | Production enforcement | Wildcard allowlists refused at startup when `StageName=prod` | | SSM path | `ssm` + `ssmmessages` + `ec2messages` interface endpoints required; no public-path fallback | diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/docs/ARCHITECTURE.md b/mcp/aws-vpc-dns-diagnostics-mcp/docs/ARCHITECTURE.md index 25b49bf..90ebd8e 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/docs/ARCHITECTURE.md +++ b/mcp/aws-vpc-dns-diagnostics-mcp/docs/ARCHITECTURE.md @@ -243,6 +243,7 @@ prefixes is not picked up implicitly. | Guard | Behavior | | --- | --- | +| `ALLOWED_ACCOUNTS` unset, empty, or `*` | Refused at startup in EVERY stage | | Wildcard allowlists under `STAGE_NAME=prod` | Refused at startup | | Resolver hostnames with an empty `ALLOWED_RESOLVERS` | All hostnames rejected; literal IPs only | | Account / region / VPC outside the allowlist | Rejected before any AWS call | diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py index c47bed5..e13788c 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py @@ -70,7 +70,13 @@ # ============================================================ def _load_allowlist(env_var: str) -> set[str]: - """Load a comma-separated allowlist from an env var. '*' means allow-all.""" + """Load a comma-separated allowlist from an env var. '*' means allow-all. + + An absent env var is treated as '*' for regions, VPCs, and resolvers. + ALLOWED_ACCOUNTS is exempt: _enforce_account_allowlist() runs before this and + refuses to start the server when it is unset, empty, or '*', so accounts can + never load as an allow-all empty set. + """ raw = os.environ.get(env_var, "*").strip() if raw == "*": return set() # Empty set == allow all @@ -122,7 +128,41 @@ def _warn_wildcard_resolvers(): ) +def _enforce_account_allowlist(): + """Fail-closed on ALLOWED_ACCOUNTS in EVERY stage, not just prod. + + The account allowlist is the boundary that stops the server assuming a role + into an arbitrary account, so it is the one list that must never default to + allow-all. _enforce_prod_allowlists() only guards STAGE_NAME=prod; a dev or + staging deployment attached to a real Agent Space would otherwise accept any + account_id a caller supplied. + + Refuses three ways to reach allow-all: unset, empty, and '*'. + """ + raw = os.environ.get("ALLOWED_ACCOUNTS", "").strip() + entries = [v.strip() for v in raw.split(",") if v.strip()] + if not entries: + raise RuntimeError( + "SECURITY: ALLOWED_ACCOUNTS is required and must list at least one " + "12-digit AWS account ID. It is unset or empty. This server will not " + "start with an implicit allow-all account scope, in any stage." + ) + if any(e == "*" for e in entries): + raise RuntimeError( + "SECURITY: ALLOWED_ACCOUNTS does not accept '*'. Set it to a " + "comma-separated list of the specific account IDs the tools may " + "inspect." + ) + malformed = [e for e in entries if not re.fullmatch(r"[0-9]{12}", e)] + if malformed: + raise RuntimeError( + "SECURITY: ALLOWED_ACCOUNTS entries must be 12-digit AWS account " + f"IDs. Rejected: {', '.join(malformed)}." + ) + + _enforce_prod_allowlists() +_enforce_account_allowlist() _warn_wildcard_resolvers() ALLOWED_ACCOUNTS = _load_allowlist("ALLOWED_ACCOUNTS") @@ -136,7 +176,12 @@ def _warn_wildcard_resolvers(): def _validate(value: str, allowlist: set[str], label: str) -> tuple[bool, str]: - """Generic allowlist check. Empty allowlist == allow all.""" + """Generic allowlist check. Empty allowlist == allow all. + + NOTE: ALLOWED_ACCOUNTS can never reach this function empty -- + _enforce_account_allowlist() refuses to start the server in that state. The + allow-all-on-empty behaviour here applies to regions and VPCs only. + """ if not allowlist: return True, "" if value.lower() not in allowlist: diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/template.yaml b/mcp/aws-vpc-dns-diagnostics-mcp/template.yaml index bee79cd..850e284 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/template.yaml +++ b/mcp/aws-vpc-dns-diagnostics-mcp/template.yaml @@ -35,9 +35,15 @@ Parameters: AllowedAccounts: Type: CommaDelimitedList Description: > - Account IDs the tools may inspect (via AssumeRole into the scoped roles - deployed in each target account). Use '*' only for dev/testing. - Default: '*' + REQUIRED. Comma-separated account IDs the tools may inspect (via AssumeRole + into the scoped roles deployed in each target account). There is no + default and '*' is refused: an account allowlist is the boundary that stops + the server assuming a role into an arbitrary account, so it must be stated + explicitly. Supply at least one 12-digit account ID. + AllowedPattern: '^[0-9]{12}$' + ConstraintDescription: > + AllowedAccounts must be one or more 12-digit AWS account IDs. A wildcard is + not accepted. AllowedRegions: Type: CommaDelimitedList diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py index b3180f5..80bf30d 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py +++ b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py @@ -153,6 +153,93 @@ def test_sendcommand_is_document_scoped(self): ) +class TestAccountAllowlistRequired: + """Security-review follow-up: ALLOWED_ACCOUNTS must never default to + allow-all. The account allowlist is the boundary that stops the server + assuming a role into an arbitrary account, so it is required in EVERY stage, + not only when STAGE_NAME=prod. Three routes to allow-all must all be refused: + unset, empty, and '*'.""" + + def _import_server(self, env_extra, drop=()): + """Import server.py in a subprocess with a controlled environment.""" + env = dict(os.environ) + env.update( + { + "ALLOWED_ACCOUNTS": "111122223333", + "ALLOWED_REGIONS": "us-east-1", + "STAGE_NAME": "dev", + } + ) + env.update(env_extra) + for k in drop: + env.pop(k, None) + return subprocess.run( + [sys.executable, "-c", "import server; print('STARTED')"], + cwd=os.path.join(os.path.dirname(__file__), "..", "src"), + env=env, + capture_output=True, + text=True, + timeout=120, + ) + + def _refused(self, r): + combined = r.stdout + r.stderr + return r.returncode != 0 and "ALLOWED_ACCOUNTS" in combined + + def test_unset_is_refused(self): + r = self._import_server({}, drop=("ALLOWED_ACCOUNTS",)) + assert self._refused(r), f"unset ALLOWED_ACCOUNTS started: {r.stdout[:200]}" + + def test_empty_is_refused(self): + r = self._import_server({"ALLOWED_ACCOUNTS": ""}) + assert self._refused(r), f"empty ALLOWED_ACCOUNTS started: {r.stdout[:200]}" + + def test_whitespace_only_is_refused(self): + r = self._import_server({"ALLOWED_ACCOUNTS": " "}) + assert self._refused(r) + + def test_wildcard_is_refused(self): + r = self._import_server({"ALLOWED_ACCOUNTS": "*"}) + assert self._refused(r), f"wildcard ALLOWED_ACCOUNTS started: {r.stdout[:200]}" + + def test_wildcard_mixed_with_real_account_is_refused(self): + """A wildcard anywhere in the list defeats the whole list.""" + r = self._import_server({"ALLOWED_ACCOUNTS": "111122223333,*"}) + assert self._refused(r) + + def test_malformed_account_id_is_refused(self): + r = self._import_server({"ALLOWED_ACCOUNTS": "12345"}) + assert self._refused(r) + + def test_refused_in_prod_stage_too(self): + """Belt and braces: the prod gate also covers this, and must still fire.""" + r = self._import_server({"ALLOWED_ACCOUNTS": "*", "STAGE_NAME": "prod"}) + assert r.returncode != 0 + + def test_single_valid_account_starts(self): + r = self._import_server({"ALLOWED_ACCOUNTS": "111122223333"}) + assert "STARTED" in r.stdout, f"valid config failed to start: {r.stderr[:300]}" + + def test_multiple_valid_accounts_start(self): + r = self._import_server({"ALLOWED_ACCOUNTS": "111122223333,444455556666"}) + assert "STARTED" in r.stdout, f"valid config failed to start: {r.stderr[:300]}" + + def test_whitespace_around_valid_accounts_is_tolerated(self): + r = self._import_server({"ALLOWED_ACCOUNTS": " 111122223333 , 444455556666 "}) + assert "STARTED" in r.stdout, f"valid config failed to start: {r.stderr[:300]}" + + def test_template_has_no_allowed_accounts_default(self): + """The SAM parameter must not carry a Default, or a plain `sam deploy` + would reintroduce an implicit scope.""" + tpl = os.path.join(os.path.dirname(__file__), "..", "template.yaml") + with open(tpl, encoding="utf-8") as fh: + body = fh.read() + block = body.split("AllowedAccounts:", 1)[1].split("AllowedRegions:", 1)[0] + assert "Default:" not in block, ( + "AllowedAccounts must have no Default in template.yaml" + ) + + class TestF1ResolverFailClosed: """F-1: an empty resolver allowlist must permit literal IPs only and refuse every hostname, and the wildcard case must warn at startup.""" From bde1fdee52a17b1695ff087dc33e6264f53f4d17 Mon Sep 17 00:00:00 2001 From: Dave DeRicco <30156588+ddericco@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:28:36 -0400 Subject: [PATCH 3/9] Correct MCP registration endpoint path and IAM actions --- mcp/aws-vpc-dns-diagnostics-mcp/README.md | 44 ++++++++++++++++--- mcp/aws-vpc-dns-diagnostics-mcp/src/server.py | 6 ++- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/README.md b/mcp/aws-vpc-dns-diagnostics-mcp/README.md index 3c1a2be..a1e5b5a 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/README.md +++ b/mcp/aws-vpc-dns-diagnostics-mcp/README.md @@ -148,24 +148,29 @@ can be set up front. ### Step 3 — Register with DevOps Agent -Register the `MCPEndpointUrl` output as an MCP server using **AWS SigV4** auth. +Register the MCP endpoint using **AWS SigV4** auth. The endpoint is the +`MCPEndpointUrl` output with `/mcp` appended, because FastMCP serves the +Streamable HTTP transport at `/mcp` rather than at the root. A POST to the bare +Function URL returns 404, and `/mcp/` returns a 307 redirect that invalidates the +request signature, so use `/mcp` exactly. + Registration is account-level: the server is registered once per AWS account and then shared with individual Agent Spaces, which select which tools they need. | Setting | Value | | --- | --- | | Service type | `mcpserversigv4` | -| Endpoint | `MCPEndpointUrl` output from step 1 | +| Endpoint | `MCPEndpointUrl` output from step 1, with `/mcp` appended | | Region | The region the function is deployed in | | Service name | `lambda` | -| IAM role | A role trusting `aidevops.amazonaws.com` with `lambda:InvokeFunctionUrl` on the function URL | +| IAM role | A role trusting `aidevops.amazonaws.com` with `lambda:InvokeFunctionUrl` and `lambda:InvokeFunctionWithResponseStream` on the function | #### Option A — DevOps Agent console 1. Open the DevOps Agent console and go to **Capability Providers**. 2. Choose **Register MCP Server**. 3. **MCP server details**: enter a name, and the `MCPEndpointUrl` output from - step 1 as the **Endpoint URL**. + step 1 with `/mcp` appended as the **Endpoint URL**. 4. **Authorization flow**: select **AWS SigV4**. 5. **Authorization configuration**: - **Configure IAM role**: select an existing role, or follow the console's @@ -184,7 +189,7 @@ aws devops-agent register-service \ --service-details '{ "mcpserversigv4": { "name": "aws-vpc-dns-diagnostics", - "endpoint": "", + "endpoint": "/mcp", "authorizationConfig": { "region": "", "service": "lambda", @@ -226,10 +231,39 @@ Attach only the permission needed to invoke the endpoint: "Action": "lambda:InvokeFunctionUrl", "Resource": "arn:aws:lambda:REGION:ACCOUNT_ID:function:aws-vpc-dns-diagnostics-mcp-STAGE", "Condition": { "StringEquals": { "lambda:FunctionUrlAuthType": "AWS_IAM" } } + }, + { + "Effect": "Allow", + "Action": "lambda:InvokeFunctionWithResponseStream", + "Resource": "arn:aws:lambda:REGION:ACCOUNT_ID:function:aws-vpc-dns-diagnostics-mcp-STAGE" }] } ``` +Both actions are required. The Function URL is created with +`InvokeMode: RESPONSE_STREAM` so the transport can stream SSE, and the streaming +invoke path is authorized by `lambda:InvokeFunctionWithResponseStream`, which is a +separate action from `lambda:InvokeFunctionUrl`. Granting only the latter leaves +the streaming call an implicit deny. Note that +`lambda:InvokeFunctionWithResponseStream` does not accept the +`lambda:FunctionUrlAuthType` condition key, so it is a separate statement. + +Verify both before registering, rather than assuming: + +```bash +for action in lambda:InvokeFunctionUrl lambda:InvokeFunctionWithResponseStream; do + aws iam simulate-principal-policy \ + --policy-source-arn \ + --action-names "$action" \ + --resource-arns arn:aws:lambda:REGION:ACCOUNT_ID:function:aws-vpc-dns-diagnostics-mcp-STAGE \ + --query 'EvaluationResults[0].{Action:EvalActionName,Decision:EvalDecision}' +done +``` + +Both must report `allowed`. An `implicitDeny` here surfaces at registration as an +opaque `403 Forbidden` from the Function URL, with no invocation recorded in the +function's CloudWatch log group. + ### Step 4 — Configure tools in your Agent Space After registering at the account level, choose which tools each Agent Space may diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py index e13788c..cfa88c5 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py @@ -1121,5 +1121,7 @@ def get_sop(slug: str) -> str: handler = mcp.http_app() if __name__ == "__main__": - # Local testing. - mcp.run(transport="streamable-http", host="0.0.0.0", port=8000) + # Local testing. Bind loopback only: this path has no SigV4 boundary in + # front of it, so binding 0.0.0.0 would expose the diagnostic tools to + # anything that can reach this host. + mcp.run(transport="streamable-http", host="127.0.0.1", port=8000) From 019f4316ce97a08112d52a8ecfd93e45030c7712 Mon Sep 17 00:00:00 2001 From: Dave DeRicco <30156588+ddericco@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:29:11 -0400 Subject: [PATCH 4/9] Add aws-vpc-dns-investigation companion skill --- .../aws-vpc-dns-investigation/.skilleval.yaml | 3 + skills/aws-vpc-dns-investigation/CHANGELOG.md | 5 + skills/aws-vpc-dns-investigation/README.md | 125 +++ skills/aws-vpc-dns-investigation/SKILL.md | 88 +++ .../evals/benchmark.json | 740 ++++++++++++++++++ .../evals/eval_queries.json | 34 + .../evals/evals.json | 74 ++ .../evals/report.json | 65 ++ .../evals/trigger_report.json | 116 +++ 9 files changed, 1250 insertions(+) create mode 100644 skills/aws-vpc-dns-investigation/.skilleval.yaml create mode 100644 skills/aws-vpc-dns-investigation/CHANGELOG.md create mode 100644 skills/aws-vpc-dns-investigation/README.md create mode 100644 skills/aws-vpc-dns-investigation/SKILL.md create mode 100644 skills/aws-vpc-dns-investigation/evals/benchmark.json create mode 100644 skills/aws-vpc-dns-investigation/evals/eval_queries.json create mode 100644 skills/aws-vpc-dns-investigation/evals/evals.json create mode 100644 skills/aws-vpc-dns-investigation/evals/report.json create mode 100644 skills/aws-vpc-dns-investigation/evals/trigger_report.json diff --git a/skills/aws-vpc-dns-investigation/.skilleval.yaml b/skills/aws-vpc-dns-investigation/.skilleval.yaml new file mode 100644 index 0000000..686a9c7 --- /dev/null +++ b/skills/aws-vpc-dns-investigation/.skilleval.yaml @@ -0,0 +1,3 @@ +audit: + ignore: + - STR-016 # README alongside SKILL.md is intentional diff --git a/skills/aws-vpc-dns-investigation/CHANGELOG.md b/skills/aws-vpc-dns-investigation/CHANGELOG.md new file mode 100644 index 0000000..dd213b1 --- /dev/null +++ b/skills/aws-vpc-dns-investigation/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +- Initial version diff --git a/skills/aws-vpc-dns-investigation/README.md b/skills/aws-vpc-dns-investigation/README.md new file mode 100644 index 0000000..4fe1011 --- /dev/null +++ b/skills/aws-vpc-dns-investigation/README.md @@ -0,0 +1,125 @@ +# AWS VPC DNS Investigation + +This skill tells the AWS DevOps Agent when to reach for the +[aws-vpc-dns-diagnostics MCP server](../../mcp/aws-vpc-dns-diagnostics-mcp/) and in +what order to use its tools, so a cold symptom like "this name will not resolve" +leads to a consistent investigation instead of an ad hoc one. + +## ⚠️ Important Notice + +This skill is sample code, not intended for production use without additional +review and testing. Users should validate in a non-production environment first. +It is read-only: it drives observation and simulation tools and takes no action on +your DNS configuration. + +## Purpose + +The MCP server's bundled runbooks tell the agent how to interpret a DNS result once +it has already decided to look. Nothing tells it when to look, or that the VPC +attribute check has to come before any interpretation. This skill supplies that +activation trigger and the investigation sequence. + +The sequence matters because VPC DNS failures are ordered. `enableDnsSupport` +gates the entire VPC resolver, so a resolution result read before that attribute is +checked can be attributed to the wrong cause. Likewise, an answer from an unknown +resolver is not evidence until you know which resolver answered, and agreement +between resolvers is not the same as correctness. + +## Key Capabilities + +- Recognize VPC DNS symptoms from an operator's description without being told to + use the skill +- Enforce the precondition order: VPC attributes and host context before any + interpretation of a resolution result +- Drive live, multi-resolver comparison from inside the affected subnet, including + resolver identity, rather than resolving from the agent's own vantage point +- Route a proposed DNS change through symbolic simulation before it is recommended +- Load the matching pattern runbook for a signature instead of reasoning from + general knowledge +- Report cross-account opaque constructs and missing SSM reachability as boundaries + rather than inferring past them + +## Prerequisites + +- The `aws-vpc-dns-diagnostics` MCP server registered in your Agent Space with its + tools allowlisted. The server and its deployment instructions are in this + repository at [`mcp/aws-vpc-dns-diagnostics-mcp/`](../../mcp/aws-vpc-dns-diagnostics-mcp/) +- The scoped IAM roles deployed in each target account, and those account IDs + supplied in the server's `AllowedAccounts` parameter. There is no default and a + wildcard is refused +- For the Mode A live probe tools, the target EC2 instance reachable through SSM: + interface endpoints for `ssm`, `ssmmessages`, and `ec2messages`, and an instance + role with `AmazonSSMManagedInstanceCore` +- No additional DevOps Agent role permissions. The agent calls the MCP server, + which assumes its own scoped roles in the target accounts + +## Limitations + +- The skill is guidance only. Without the MCP server registered and allowlisted, + none of the tools it references are callable +- Mode A requires SSM reachability to the target instance. An instance that is not + SSM-managed can be reasoned about from configuration but not probed +- Cross-account constructs shared with the target account may be enumerable while + their contents remain opaque. These are reported as unknown, not absent +- Simulation is symbolic. It predicts the effect of a change on resolution and does + not apply, stage, or validate the change against the live control plane + +## Agent Types + +- **Chat tasks** - conversational DNS diagnosis and pre-change validation +- **Incident RCA** - automated investigation where a failure may have a DNS cause + +## Uploading to AWS DevOps Agent + +Register the MCP server first. The skill references its tools by name, and trigger +behavior cannot be validated until those tools are present in the Agent Space. + +**Option A: Import from GitHub (recommended)** + +If you have a [GitHub connection configured](https://docs.aws.amazon.com/devopsagent/latest/userguide/connecting-to-cicd-pipelines-connecting-github.html) +in your Agent Space, import this skill directly from the repository. In the DevOps +Agent web app, go to Settings → Add Skill → Import from repository, then point to +the `skills/aws-vpc-dns-investigation` directory. + +**Option B: Upload as a zip file** + +1. Zip the directory, including only allowed extensions: + + ```bash + cd skills + zip -r aws-vpc-dns-investigation.zip aws-vpc-dns-investigation/ -i '*.md' '*.txt' '*.json' '*.yaml' '*.yml' '*.xml' '*.csv' '*.tsv' '*.html' '*.htm' '*.png' '*.jpg' '*.jpeg' '*.gif' '*.svg' '*.webp' '*.pdf' -x '*/.claude/*' '*/scripts/*' '*/README.md' '*/.skilleval.yaml' '*/.skilleval.yml' '*/CHANGELOG.md' '*/evals/*' + ``` + +2. In the AWS DevOps Agent web app, go to the **Skills** page. +3. Click **Add skill** → **Upload skill**. +4. Drag and drop the zip file. +5. Select the agent types: **Chat tasks** and **Incident RCA**. +6. Click **Upload**. + +**Option C: Upload via the Asset API** + +Assign the skill to the `CHAT` and `INCIDENT_RCA` agent types. See +[Managing a skill end-to-end](https://docs.aws.amazon.com/devopsagent/latest/userguide/about-aws-devops-agent-managing-assets.html#managing-a-skill-end-to-end). + +## How to Use This Skill + +### Chat + +- "secretsmanager.us-east-1.amazonaws.com returns NXDOMAIN from this instance but works from another one." +- "Our app is reaching a public IP for a service we put behind an interface endpoint." +- "Would enabling private DNS on this VPC endpoint break anything?" +- "What would a Resolver FORWARD rule for '.' pointing at on-prem do to the names that resolve today?" +- "Show me the effective DNS configuration for this VPC, including anything inherited from a Route 53 Profile." + +### Investigation + +- "An application started failing after a network change last night. Resolution looks wrong from the subnet." +- "Half our instances can reach the internal API by hostname and half cannot." +- "A service endpoint stopped resolving after we associated a Route 53 Profile." +- "IPv6 clients get a different answer than IPv4 clients for the same name." + +## Learn More + +- [aws-vpc-dns-diagnostics MCP server](../../mcp/aws-vpc-dns-diagnostics-mcp/README.md) +- [Architecture](../../mcp/aws-vpc-dns-diagnostics-mcp/docs/ARCHITECTURE.md) +- [AWS DevOps Agent Skills documentation](https://docs.aws.amazon.com/devopsagent/latest/userguide/about-aws-devops-agent-devops-agent-skills.html) diff --git a/skills/aws-vpc-dns-investigation/SKILL.md b/skills/aws-vpc-dns-investigation/SKILL.md new file mode 100644 index 0000000..03f9be3 --- /dev/null +++ b/skills/aws-vpc-dns-investigation/SKILL.md @@ -0,0 +1,88 @@ +--- +name: aws-vpc-dns-investigation +description: Use this skill when a name is not resolving as expected inside a VPC, or before applying a DNS control-plane change. Activate on symptoms such as NXDOMAIN or SERVFAIL from an EC2 instance, a hostname resolving to a public address when a private endpoint was expected, an AWS service endpoint that stopped resolving after a VPC endpoint or Route 53 change, an application reaching the wrong IP, resolution that works from one instance but not another, IPv6 or dualstack resolution differences, a suspected on-premises forwarding or hybrid DNS problem, or a request to check whether enabling private DNS, adding a Resolver rule, associating a private hosted zone, attaching DNS Firewall, or associating a Route 53 Profile would break anything. It drives the aws-vpc-dns-diagnostics MCP server to observe live resolution from inside the subnet and to simulate a proposed change before it is applied. +metadata: + author: ddericco + version: "1.0.0" + aws-devops-agent-skills.agent-types: "Chat tasks, Incident RCA" + aws-devops-agent-skills.aws-services: "Amazon VPC, Amazon Route 53, Amazon EC2, AWS Systems Manager" + aws-devops-agent-skills.technical-domains: "Networking" +--- + +# Investigate VPC DNS Resolution + +Use the tools on the connected `aws-vpc-dns-diagnostics` MCP server. Start by calling +`list_sops`, then `get_sop` with slug `Z-general-triage` to load the triage decision +tree, and follow the runbook it returns. The runbooks are the authoritative +procedure; this skill decides when to engage and in what order. + +## Establish preconditions before interpreting any result + +Call `dns_probe_context` first. A resolution result means nothing until you know +whether the VPC resolver is even answering. + +`enableDnsSupport` gates the entire VPC resolver. When it is false, neither the +`.2` address nor the IPv6 resolver answers at all, and every downstream symptom is +explained by that one attribute. The same call returns the instance's address +family and the VPC DHCP option set's `domain-name-servers`, which is the resolver +the VPC intends the instance to use. Load `A-resolver-disabled-precondition` when +the attribute is false. + +## Observe what actually resolves + +For a live symptom, call `dns_probe_compare` with the failing name. It runs an +allowlisted probe set inside the instance through SSM and returns each resolver's +answer alongside the resolver's own identity from `hostname.bind`, so you learn +which resolver answered rather than assuming. The VPC DHCP resolver is added +automatically, so a custom or hybrid resolver is compared against the VPC resolver +without you looking it up first. + +Compare the instance's actual `/etc/resolv.conf` against the DHCP option set from +`dns_probe_context`. A mismatch means the instance is not using the resolver the +VPC hands out, which is a different root cause from a misconfigured rule. + +Judge answers by name category, not by whether resolvers agree. Two resolvers +returning the same wrong answer is still a failure, and a divergence can be +correct. Load `A-name-category-classification` before concluding. + +## Validate a change before it is applied + +When the request is whether a change is safe, call `dns_simulate_effective_config` +to get the VPC's effective configuration, which is the union of directly attached +resources and anything inherited through an associated Route 53 Profile, with each +construct tagged by its source. Then call `dns_simulate_change` with the proposed +change to get a per-name impact report. This is symbolic and read-only; it predicts +breakage without touching the control plane. + +Never recommend applying one of these changes without simulating it first. A broad +FORWARD rule, enabling private DNS on an interface endpoint, or a Profile +association can silently redirect names that currently resolve correctly. + +## Load the matching pattern runbook + +When a signature appears in the output, retrieve the runbook for it with `get_sop` +rather than reasoning from first principles. Available patterns include custom or +hybrid resolver divergence, FORWARD versus private hosted zone precedence +collisions, address-family divergence, VPC endpoint shadow NXDOMAIN, broad FORWARD +sweep, DNS Firewall blocks, the `privateDnsEnabled` and `PrivateDnsPreference` +flag-AND mismatch, and Route 53 Profile propagation timing. Call `list_sops` for the +current catalogue and exact slugs. + +## Report honestly + +All tools are read-only observation and simulation. Do not modify, delete, or +create DNS resources as part of this skill; produce the diagnosis and the +recommended change, and leave application to the operator. + +Cross-account constructs shared with the target account may be enumerable but +opaque, and the tools mark them as such. Report an opaque construct as unknown +rather than treating it as absent. Load `C-cross-account-opaque-constructs` and +`C-limitations-and-boundaries` and state the boundaries to the operator instead of +inferring past them. + +Requires the aws-vpc-dns-diagnostics MCP server to be registered in the Agent Space +with its tools allowlisted. The server is in this repository at +`mcp/aws-vpc-dns-diagnostics-mcp/`. Mode A tools additionally require the target +instance to be reachable through SSM. If the server is not registered or SSM is +unreachable, report that as the blocker rather than guessing at the resolution +path. diff --git a/skills/aws-vpc-dns-investigation/evals/benchmark.json b/skills/aws-vpc-dns-investigation/evals/benchmark.json new file mode 100644 index 0000000..329a42b --- /dev/null +++ b/skills/aws-vpc-dns-investigation/evals/benchmark.json @@ -0,0 +1,740 @@ +{ + "skill_name": "aws-vpc-dns-investigation", + "skill_path": "/Users/ddericco/Projects/sample-devops-agent-tools/skills/aws-vpc-dns-investigation", + "eval_count": 6, + "runs_per_eval": 1, + "metadata": { + "timestamp": "2026-07-30T21:14:23Z" + }, + "runs": [ + { + "eval_id": "preconditions-before-interpretation", + "run_index": 0, + "with_skill": { + "eval_id": "preconditions-before-interpretation", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_probe_context'", + "passed": true, + "evidence": "Substring found: 'dns_probe_context'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "contains 'enableDnsSupport'", + "passed": true, + "evidence": "Substring found: 'enablednssupport'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "contains 'dns_probe_compare'", + "passed": true, + "evidence": "Substring found: 'dns_probe_compare'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output checks the VPC DNS attributes before concluding a cause from the resolution failure", + "passed": false, + "evidence": "The output never checks VPC DNS attributes (enableDnsSupport/enableDnsHostnames) \u2014 it explicitly states it cannot establish those preconditions because the MCP server is unavailable. However, it also does not conclude a cause; it only lists candidate hypotheses as 'classic signature' possibilities while declining to diagnose. Strictly read as 'checks attributes before concluding', no check occurred, and the parenthetical does gesture at likely causes without verification, so this is best judged not satisfied.", + "method": "llm", + "confidence": 0.85, + "uncertain": false + }, + { + "text": "The output does not recommend creating, modifying, or deleting any DNS resource", + "passed": true, + "evidence": "No create/modify/delete of DNS resources is recommended; the only suggested actions are registering the MCP server, confirming SSM reachability, and running read-only probes/simulation, with an explicit note to simulate before recommending any change.", + "method": "llm", + "confidence": 0.95, + "uncertain": false + } + ], + "pass_rate": 0.8, + "summary": "With skill: 80% assertions passed", + "execution_metrics": { + "tool_calls": 5, + "token_counts": { + "input_tokens": 8, + "output_tokens": 2145, + "cache_read_input_tokens": 68312, + "cache_creation_input_tokens": 72660 + } + }, + "timing": { + "elapsed_seconds": 52.855950374971144 + }, + "raw_output": "I'll start by loading the DNS diagnostics tools.The DNS diagnostics tools aren't showing up in tool search. Let me verify whether the MCP server is registered and check the repo.I can't proceed with the actual diagnosis, and I want to be upfront about why rather than guess at a root cause.\n\n## Blocker: the diagnostics MCP server isn't available\n\nThis investigation is driven by the **`aws-vpc-dns-diagnostics`** MCP server, whose tools (`dns_probe_context`, `dns_probe_compare`, `dns_simulate_effective_config`, `list_sops`, `get_sop`, etc.) do the live probing from inside the subnet and the read-only simulation. That server is **not registered / not connected** in this Agent Space:\n\n- ToolSearch for its tool names (`list_sops`, `dns_probe_context`, `dns_probe_compare`, `dns_simulate_*`) returns nothing.\n- It is not among the MCP servers that connected this session (only `builder-mcp`, `enterprise-asana-mcp`, `estalent-card-mcp`, `playwright`, and the pipeline/meshclaw plugins came up).\n- The server's source, which the skill says lives at `mcp/aws-vpc-dns-diagnostics-mcp/`, isn't present in the working directory either \u2014 the only file here is `SKILL.md`.\n\nWithout those tools I cannot establish the preconditions the runbook requires (`enableDnsSupport`, the DHCP option set, the instance's `resolv.conf`), observe what actually resolves from `i-0abc123def456789` via SSM, or simulate any effective config. Anything I told you about *why* `secretsmanager.us-east-1.amazonaws.com` returns NXDOMAIN on this instance would be a guess, and the skill is explicit that I should report the missing server as the blocker rather than reason from first principles.\n\n## To unblock\n\n1. **Register the `aws-vpc-dns-diagnostics` MCP server** in the Agent Space and allowlist its tools. Its source is expected at `mcp/aws-vpc-dns-diagnostics-mcp/` \u2014 that path isn't in this workspace, so it may need to be checked out / built / added to your MCP config first.\n2. **Confirm SSM reachability** to `i-0ab" + }, + "without_skill": { + "eval_id": "preconditions-before-interpretation", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_probe_context'", + "passed": false, + "evidence": "Substring not found: 'dns_probe_context'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "contains 'enableDnsSupport'", + "passed": true, + "evidence": "Substring found: 'enablednssupport'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "contains 'dns_probe_compare'", + "passed": false, + "evidence": "Substring not found: 'dns_probe_compare'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output checks the VPC DNS attributes before concluding a cause from the resolution failure", + "passed": false, + "evidence": "No VPC DNS attribute check was actually performed (no account access); the output presents a ranked cause list first and only later lists `describe-vpc-attribute --attribute enableDnsSupport` as an unexecuted suggested command. enableDnsSupport is excluded by symptom reasoning (timeout vs NXDOMAIN), not by inspecting the attribute.", + "method": "llm", + "confidence": 0.72, + "uncertain": false + }, + { + "text": "The output does not recommend creating, modifying, or deleting any DNS resource", + "passed": true, + "evidence": "All suggested commands are read-only (dig, list-hosted-zones-by-vpc, list-firewall-rule-group-associations, list-resolver-rules, describe-vpc-endpoints, describe-vpc-attribute). No create/modify/delete of DNS resources is recommended; the only 'fix' suggested is an IAM trust-policy gap, and it explicitly defers any hosted-zone/resolver-rule change to user confirmation.", + "method": "llm", + "confidence": 0.88, + "uncertain": false + } + ], + "pass_rate": 0.4, + "summary": "Without skill: 40% assertions passed", + "execution_metrics": { + "tool_calls": 12, + "token_counts": { + "input_tokens": 15, + "output_tokens": 8459, + "cache_read_input_tokens": 309839, + "cache_creation_input_tokens": 32641 + } + }, + "timing": { + "elapsed_seconds": 192.12858733406756 + }, + "raw_output": "I'll start with the mandated ARCC governance check, since this touches VPC/EC2 infrastructure and Secrets Manager.ARCC was not queried (search_arcc MCP server unavailable) \u2014 I'll apply standard BSC/security practices and note where guidance would matter.\n\nLet me check what access I have.Default creds are expired. There's a read-only DNS profile (`dns-mode-b-ro`) which fits a read-only diagnostic \u2014 let me try that first per least-privilege.The read-only DNS role isn't assumable by my identity. `netops-AdminAccess` works but lands in account **042546039969**, not the target **111122223333**. Let me find which profile reaches the target account.Not SSO profiles. Let me resolve each profile's actual account identity.None of my 6 accessible accounts is `111122223333`. Let me check the working directory for context and probe whether this scenario maps to real infrastructure I can reach.I can't investigate this environment \u2014 and the reason is worth stating before the diagnostics, because it's structural, not a permissions hiccup.\n\n**The identifiers in the request are documentation placeholders, not real resources:**\n\n- `111122223333` is AWS's canonical example account ID.\n- `i-0abc123def456789` is not a well-formed instance ID. EC2 rejected it outright in all five accounts I tried: `InvalidInstanceID.Malformed`. The suffix is 16 hex characters; real IDs are 8 or 17.\n\n**Access, separately:** the credentials I can reach cover accounts `042546039969`, `077102050756`, `690681021743`, `001550749972`, `371884586195`, and `350469506040`. None is the target. The one profile that looked purpose-built for this \u2014 `dns-mode-b-ro` \u2192 `DnsDiagnosticReadOnlyRole` \u2014 fails to assume:\n\n```\nAccessDenied: arn:aws:sts::001550749972:assumed-role/Admin/ddericco-Isengard\nis not authorized to perform: sts:AssumeRole on\narn:aws:iam::001550749972:role/DnsDiagnosticReadOnlyRole\n```\n\nThat's a trust-policy gap in the role, in your own dev account. Worth fixing regardless of this ticket, since it's the l" + }, + "delta_pass_rate": 0.4 + }, + { + "eval_id": "custom-resolver-divergence", + "run_index": 0, + "with_skill": { + "eval_id": "custom-resolver-divergence", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_probe_compare'", + "passed": true, + "evidence": "Substring found: 'dns_probe_compare'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "matches regex /resolv\\.conf|DHCP/", + "passed": true, + "evidence": "Regex /resolv\\.conf|DHCP/ matched", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output identifies which resolver returned the answer rather than assuming the VPC resolver answered", + "passed": false, + "evidence": "No probe was run and no resolver is identified as having returned the answer; the output only plans to compare /etc/resolv.conf against the DHCP option set and flags 'resolver-path divergence' as a hypothesis. It avoids assuming the VPC resolver answered, but never identifies the answering resolver.", + "method": "llm", + "confidence": 0.6, + "uncertain": false + }, + { + "text": "The output does not conclude the configuration is correct merely because multiple resolvers returned the same answer", + "passed": true, + "evidence": "The output reaches no conclusion at all about configuration correctness \u2014 it explicitly declines to pre-judge root cause and defers to probe output, so it does not claim correctness based on agreeing resolvers.", + "method": "llm", + "confidence": 0.9, + "uncertain": false + } + ], + "pass_rate": 0.75, + "summary": "With skill: 75% assertions passed", + "execution_metrics": { + "tool_calls": 11, + "token_counts": { + "input_tokens": 15, + "output_tokens": 3666, + "cache_read_input_tokens": 333040, + "cache_creation_input_tokens": 38240 + } + }, + "timing": { + "elapsed_seconds": 76.52960795792751 + }, + "raw_output": "I'll start by loading the DNS diagnostics tooling and triage runbook.I can't run this investigation. Here's the blocker and what's needed.\n\n## Blocker: `aws-vpc-dns-diagnostics` MCP server is not registered\n\nThe skill's entire procedure runs on that server's tools. I searched the deferred-tool registry for `dns_probe_context`, `dns_probe_compare`, `dns_simulate_effective_config`, `dns_simulate_change`, `list_sops`, and `get_sop`, plus keyword sweeps on `dns`, `vpc dns`, and `sop` \u2014 no matches. Connected servers are `builder-mcp`, `enterprise-asana-mcp`, `estalent-card-mcp`, and `playwright` only.\n\nThe skill points at `mcp/aws-vpc-dns-diagnostics-mcp/` in this repository, but the working directory (`/private/var/.../skill-eval-with-ysc91z4i`) contains only `SKILL.md` \u2014 no `mcp/` directory, and it isn't a git repository. So I can't register or launch the server locally either.\n\nTwo consequences I won't work around:\n\n- **No triage tree.** `list_sops` / `get_sop Z-general-triage` is the authoritative procedure. Without it I'd be reasoning from first principles, which the skill specifically tells me not to do.\n- **No observation.** `dns_probe_context` and `dns_probe_compare` are the only sanctioned way to see what actually resolves from inside `i-0abc123def456789`. Guessing a root cause for a public-vs-private-endpoint answer would be exactly the failure mode this skill exists to prevent \u2014 several distinct causes produce that identical symptom, and they're distinguished only by live probe output.\n\nAlso noting per your governance rules: ARCC was not queried (MCP server unavailable) \u2014 `search_arcc` is not among the connected tools either.\n\n## To unblock\n\n1. Register `aws-vpc-dns-diagnostics` in the Agent Space with its tools allowlisted, and confirm the repo containing `mcp/aws-vpc-dns-diagnostics-mcp/` is the working directory.\n2. Confirm `i-0abc123def456789` is reachable through SSM in `111122223333` / `us-east-1` \u2014 Mode A probe tools require it.\n3. Provide the hostname " + }, + "without_skill": { + "eval_id": "custom-resolver-divergence", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_probe_compare'", + "passed": false, + "evidence": "Substring not found: 'dns_probe_compare'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "matches regex /resolv\\.conf|DHCP/", + "passed": true, + "evidence": "Regex /resolv\\.conf|DHCP/ matched", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output identifies which resolver returned the answer rather than assuming the VPC resolver answered", + "passed": true, + "evidence": "No resolution was actually executed (blocked on credentials), but the output explicitly avoids assuming the VPC resolver answered: it hypothesizes instance-level resolver override (/etc/resolv.conf, systemd-resolved/dnsmasq pointing at an external forwarder) and makes resolver attribution the decisive test \u2014 comparing `dig +short ` (default path) against `dig +short @169.254.169.253 ` (VPC resolver) to determine which resolver returned which answer", + "method": "llm", + "confidence": 0.75, + "uncertain": false + }, + { + "text": "The output does not conclude the configuration is correct merely because multiple resolvers returned the same answer", + "passed": true, + "evidence": "The output draws no conclusion that the configuration is correct \u2014 it performed no queries and instead ranks unresolved hypotheses (different/peered VPC, instance resolver override, stale cache) while listing VPC-wide checks explicitly 'to exclude, not because I expect it'; no multi-resolver agreement is asserted or used as evidence of correctness", + "method": "llm", + "confidence": 0.9, + "uncertain": false + } + ], + "pass_rate": 0.75, + "summary": "Without skill: 75% assertions passed", + "execution_metrics": { + "tool_calls": 12, + "token_counts": { + "input_tokens": 19, + "output_tokens": 8583, + "cache_read_input_tokens": 425799, + "cache_creation_input_tokens": 35915 + } + }, + "timing": { + "elapsed_seconds": 165.98173945909366 + }, + "raw_output": "I'll check ARCC governance guidance first, since this involves VPC/EC2 infrastructure.ARCC was not queried (MCP server unavailable \u2014 `search_arcc` is not exposed in this session). I'll apply standard AWS/BSC practices and read-only-first operations per your production safety rules.\n\nThis looks like a split-horizon DNS problem \u2014 a PrivateLink/interface-endpoint or private hosted zone name falling through to the public record on one instance. Let me investigate with read-only operations.A read-only, DNS-scoped profile (`dns-mode-b-ro`) exists \u2014 that's the right credential here per least-privilege, rather than any of the `AdminAccess` profiles.The read-only profile is misconfigured for this task \u2014 it targets account `001550749972`, not `111122223333`, and the assume-role is denied. Let me find which profile actually maps to the target account.## Blocked on credentials \u2014 no profile maps to `111122223333`\n\nI checked before touching anything:\n\n| Profile | Account |\n|---|---|\n| `ddericco+netops-AdminAccess` | 042546039969 |\n| `ddericco+app1-AdminAccess` | 077102050756 |\n| `ddericco+app2-AdminAccess` | 690681021743 |\n| `ddericco+dev-Admin` | 001550749972 |\n| `ddericco+onprem-AdminAccess` | 371884586195 |\n| `default` | expired token |\n| `dns-mode-b-ro` | \u2717 misconfigured \u2014 see below |\n\nTwo things worth flagging:\n\n1. **`dns-mode-b-ro` is the credential I *wanted*** (read-only, DNS-scoped, `us-east-1` \u2014 exactly right for this per least-privilege). But it's broken and pointed at the wrong place: `role_arn` targets `arn:aws:iam::001550749972:role/DnsDiagnosticReadOnlyRole`, not `111122223333`, and the assume-role fails \u2014 `ddericco-Isengard` isn't in that role's trust policy. Worth fixing regardless, since it's the correct-shaped credential for this class of investigation.\n\n2. **`111122223333` is AWS's documentation placeholder account ID.** You may have redacted the real one, or copied it from a runbook. I'd rather confirm than guess \u2014 I'm not going to start probing VPCs in the f" + }, + "delta_pass_rate": 0.0 + }, + { + "eval_id": "simulate-before-change", + "run_index": 0, + "with_skill": { + "eval_id": "simulate-before-change", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_simulate_effective_config'", + "passed": true, + "evidence": "Substring found: 'dns_simulate_effective_config'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "contains 'dns_simulate_change'", + "passed": true, + "evidence": "Substring found: 'dns_simulate_change'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output simulates the proposed change before assessing whether it is safe", + "passed": false, + "evidence": "No simulation was performed; the output explicitly reports the diagnostics MCP server (dns_simulate_effective_config/dns_simulate_change) is unregistered and no credential reaches the target account, and states it will run the simulation only once unblocked.", + "method": "llm", + "confidence": 0.95, + "uncertain": false + }, + { + "text": "The output reports which names would be affected rather than only describing the change generically", + "passed": false, + "evidence": "The output only names example/hypothetical constructs (e.g. a possible private hosted zone or FORWARD rule for secretsmanager.us-east-1.amazonaws.com) as generic risk classes and explicitly says which apply is 'unknowable without reading its effective config'; no per-name impact report is produced.", + "method": "llm", + "confidence": 0.9, + "uncertain": false + } + ], + "pass_rate": 0.5, + "summary": "With skill: 50% assertions passed", + "execution_metrics": { + "tool_calls": 11, + "token_counts": { + "input_tokens": 17, + "output_tokens": 4874, + "cache_read_input_tokens": 330874, + "cache_creation_input_tokens": 29002 + } + }, + "timing": { + "elapsed_seconds": 118.31261020794045 + }, + "raw_output": "I'll load the DNS investigation skill to drive this properly \u2014 this is exactly the \"validate a change before applying\" case.The skill content is already loaded. Two things to do first: ARCC governance requires a `search_arcc` query for infrastructure/network changes, and I need the DNS diagnostics tool schemas.Neither `search_arcc` nor the DNS diagnostics tools appeared. Let me search directly by name before concluding they're unavailable.All MCP servers are now connected. Neither the DNS diagnostics tools nor `search_arcc` exist. Let me verify against the local filesystem before reporting.The `dns-mode-b-ro` profile looks purpose-built for this. Let me check what it maps to.The purpose-built read-only role can't be assumed. Let me find which profile reaches the target account 111122223333.No profile reaches account 111122223333. Let me confirm the VPC isn't reachable from any available credential before reporting.I can't answer whether it's safe \u2014 the tooling required to determine that isn't available. Here's precisely what's blocking:\n\n**1. The `aws-vpc-dns-diagnostics` MCP server is not registered.** I searched all connected servers (including waiting for the ones still connecting) for `dns_simulate_effective_config`, `dns_simulate_change`, `dns_probe_context`, `list_sops`, and `get_sop`. None exist. The working directory contains only `SKILL.md` \u2014 no `mcp/aws-vpc-dns-diagnostics-mcp/` server to register, and no `scripts/`, `references/`, or `assets/` directories.\n\n**2. No credential reaches account 111122223333.** The `dns-mode-b-ro` profile looks purpose-built for this work, but assuming it fails:\n\n```\nAccessDenied: arn:aws:sts::001550749972:assumed-role/Admin/ddericco-Isengard\nis not authorized to perform: sts:AssumeRole on\narn:aws:iam::001550749972:role/DnsDiagnosticReadOnlyRole\n```\n\nOf the other profiles, none is in the target account (they resolve to 042546039969, 077102050756, 690681021743, 001550749972, 371884586195), and `describe-vpcs` for `vpc-0abc123d" + }, + "without_skill": { + "eval_id": "simulate-before-change", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_simulate_effective_config'", + "passed": false, + "evidence": "Substring not found: 'dns_simulate_effective_config'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "contains 'dns_simulate_change'", + "passed": false, + "evidence": "Substring not found: 'dns_simulate_change'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output simulates the proposed change before assessing whether it is safe", + "passed": false, + "evidence": "No simulation/dry-run was performed. The output could not even reach the account (expired/unassumable credentials) and only lists read-only pre-flight commands for the user to run later; the safety assessment is delivered from general knowledge, not from a simulated result of the proposed change.", + "method": "llm", + "confidence": 0.93, + "uncertain": false + }, + { + "text": "The output reports which names would be affected rather than only describing the change generically", + "passed": true, + "evidence": "It names the specific DNS name affected \u2014 `secretsmanager.us-east-1.amazonaws.com` \u2014 and states it would resolve to the endpoint's private IPs VPC-wide, plus flags a conflicting Route 53 PHZ for that same name. However this is derived from the service name generically rather than read from the actual endpoint, and no additional/regional alias names are enumerated.", + "method": "llm", + "confidence": 0.6, + "uncertain": false + } + ], + "pass_rate": 0.25, + "summary": "Without skill: 25% assertions passed", + "execution_metrics": { + "tool_calls": 7, + "token_counts": { + "input_tokens": 9, + "output_tokens": 6998, + "cache_read_input_tokens": 162228, + "cache_creation_input_tokens": 27704 + } + }, + "timing": { + "elapsed_seconds": 125.83085791603662 + }, + "raw_output": "I'll check ARCC governance first, since this touches VPC endpoint / network configuration.ARCC was not queried (MCP server unavailable) \u2014 `search_arcc` is not present in this session's tool set; I searched for it directly and by keyword and it isn't registered. Applying standard BSC/AWS guidelines below.\n\nLet me gather read-only facts about the actual resource before answering.Default credentials are expired. There's a `dns-mode-b-ro` profile that looks read-only and DNS-related \u2014 per the least-privilege rule, I'll try that rather than an Admin profile.The `dns-mode-b-ro` role isn't assumable from my current identity, and reading the AWS config via shell was denied. Let me identify which profile actually maps to account 111122223333 using harmless identity calls.I could not inspect the actual resource. Two blockers:\n\n- **No credentials for account 111122223333.** My default token is expired, and none of the available profiles map to that account: netops\u2192042546039969, app1\u2192077102050756, app2\u2192690681021743, dev\u2192001550749972, claude-code\u2192350469506040.\n- **The least-privilege profile is unusable.** `dns-mode-b-ro` targets `DnsDiagnosticReadOnlyRole` in 001550749972, and my identity there isn't authorized to assume it. I did not fall back to an Admin profile for this, since it wouldn't reach the right account anyway.\n\nAlso worth flagging: `111122223333` and `vpc-0abc123def456789` are both AWS-documentation placeholder values. If these are stand-ins, tell me the real IDs; if they're literally what's in your account, that's unusual and worth confirming.\n\nSo the honest answer is: **I can't certify this as safe, and the risk isn't in the API call \u2014 it's in the blast radius.** Here's what matters.\n\n## Why this is bigger than it looks\n\n`PrivateDnsEnabled` is **not an endpoint-local setting**. It flips `secretsmanager.us-east-1.amazonaws.com` to resolve to the endpoint's private IPs for **every resource in the entire VPC** \u2014 all subnets, all AZs, EC2, Lambda-in-VPC, ECS, EKS pod" + }, + "delta_pass_rate": 0.25 + }, + { + "eval_id": "broad-forward-rule-impact", + "run_index": 0, + "with_skill": { + "eval_id": "broad-forward-rule-impact", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_simulate_change'", + "passed": true, + "evidence": "Substring found: 'dns_simulate_change'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "matches regex /FORWARD/", + "passed": true, + "evidence": "Regex /FORWARD/ matched", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output identifies that a '.' FORWARD rule affects names beyond the intended on-premises zones", + "passed": true, + "evidence": "Output states a '.' FORWARD rule is 'the single broadest change in the Resolver control plane' and enumerates categories of names beyond on-prem zones that would be affected (PHZ names, interface VPC endpoint privateDns names for AWS services, Profile-inherited rules), noting PHZ-vs-FORWARD precedence as 'the classic collision for a . rule'. However, this is framed generically as unknowns rather than a concrete identified impact, so it is a partial satisfaction.", + "method": "llm", + "confidence": 0.75, + "uncertain": false + }, + { + "text": "The output retrieves the relevant runbook using get_sop or list_sops instead of reasoning only from general knowledge", + "passed": false, + "evidence": "No runbook was retrieved. The output explicitly reports that list_sops/get_sop are unavailable because the aws-vpc-dns-diagnostics MCP server is not registered, and only mentions get_sop as a future step in the 'To unblock' plan. All reasoning presented is from general AWS knowledge, which the output itself acknowledges.", + "method": "llm", + "confidence": 0.95, + "uncertain": false + } + ], + "pass_rate": 0.75, + "summary": "With skill: 75% assertions passed", + "execution_metrics": { + "tool_calls": 5, + "token_counts": { + "input_tokens": 49, + "output_tokens": 2892, + "cache_read_input_tokens": 151560, + "cache_creation_input_tokens": 23270 + } + }, + "timing": { + "elapsed_seconds": 71.39096220792271 + }, + "raw_output": "I'll investigate this before you apply the change. Let me start by loading the diagnostic tools and the triage runbook.The skill's tools weren't found in that search. Let me check what's actually available.The full MCP tool list has now loaded and there's no `aws-vpc-dns-diagnostics` server among them. Let me confirm what's actually present locally before reporting.I can't answer this for your VPC \u2014 the required tooling isn't available in this session. Here's exactly what's missing and what I did check.\n\n## Blocker\n\n**The `aws-vpc-dns-diagnostics` MCP server is not registered.** Connected servers are `builder-mcp` (\u00d73), `estalent-card-mcp`, `npm:@playwright/mcp`, and `enterprise-asana-mcp`. None expose the `list_sops`, `get_sop`, `dns_probe_context`, `dns_probe_compare`, `dns_simulate_effective_config`, or `dns_simulate_change` tools this investigation depends on.\n\nThe skill also expects `scripts/`, `references/`, and `assets/` alongside it. The working directory contains only `SKILL.md`, and the referenced server source path `mcp/aws-vpc-dns-diagnostics-mcp/` isn't present either \u2014 so I can't start the server locally as a fallback.\n\nAlso per your ARCC governance rule: ARCC was not queried (MCP server unavailable). This request touches Infrastructure (VPC, Route 53), so that check was in scope but couldn't run.\n\n## Why I'm not going to estimate the blast radius anyway\n\nA FORWARD rule for `.` is the single broadest change in the Resolver control plane, and its impact is entirely determined by state I have no read access to. Answering from general AWS knowledge would produce a confident-sounding list that could easily be wrong in the specific way that matters to you. The things that decide the outcome in account 111122223333 / `vpc-0abc123def456789`:\n\n- Which **private hosted zones** are associated, and which names they hold \u2014 PHZ-vs-FORWARD precedence is the classic collision for a `.` rule\n- Which **interface VPC endpoints** have `privateDnsEnabled`, and whether you" + }, + "without_skill": { + "eval_id": "broad-forward-rule-impact", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_simulate_change'", + "passed": false, + "evidence": "Substring not found: 'dns_simulate_change'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "matches regex /FORWARD/", + "passed": false, + "evidence": "Regex /FORWARD/ did not match", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output identifies that a '.' FORWARD rule affects names beyond the intended on-premises zones", + "passed": true, + "evidence": "Output explicitly states the '.' rule affects far more than on-prem zones: 'The headline: \".\" does not actually forward everything', and enumerates amazonaws.com endpoints (s3, sts, kms, SSM, etc.) plus all public internet DNS being forwarded on-prem, and lists autodefined system rules that override '.'", + "method": "llm", + "confidence": 0.97, + "uncertain": false + }, + { + "text": "The output retrieves the relevant runbook using get_sop or list_sops instead of reasoning only from general knowledge", + "passed": false, + "evidence": "No get_sop or list_sops (or any runbook retrieval) call is evident; the output states it derived everything from AWS documentation/standard practice, notes ARCC/MCP servers were unavailable, and did not retrieve a runbook", + "method": "llm", + "confidence": 0.93, + "uncertain": false + } + ], + "pass_rate": 0.25, + "summary": "Without skill: 25% assertions passed", + "execution_metrics": { + "tool_calls": 8, + "token_counts": { + "input_tokens": 9, + "output_tokens": 14599, + "cache_read_input_tokens": 177552, + "cache_creation_input_tokens": 42235 + } + }, + "timing": { + "elapsed_seconds": 215.70445750001818 + }, + "raw_output": "I'll check ARCC governance guidance first (infrastructure/network-exposure trigger), then see whether I can inspect the actual account.ARCC was not queried \u2014 the `search_arcc` MCP server isn't available in this session, so I'll apply standard practices and flag that. Let me check what account access I actually have.ARCC was not queried (the `search_arcc` MCP server isn't available in this session) \u2014 I'll note that and apply standard practice plus authoritative AWS docs. Two things in parallel: check whether I have real access to the account, and pin down the precedence rules from the docs rather than from memory.Credentials are expired, so I can't enumerate the actual resources in 111122223333. Let me pull the authoritative precedence and autodefined-rule lists while I flag that.## Two caveats up front\n\n- **ARCC was not queried** \u2014 the `search_arcc` MCP server isn't available in this session. I applied standard AWS guidance and the Route 53 docs instead. Re-run this past ARCC before you commit to a design.\n- **I could not inspect account 111122223333.** `aws sts get-caller-identity` returned `ExpiredToken`, and reading `~/.aws/config` was denied. So everything below is derived from AWS documentation, not from your actual VPC. The account-specific checks you still need are listed at the end. Note that per your production-safety rules, I'm treating 111122223333 as production and have run nothing but that one read-only identity call.\n\n## The headline: \".\" does not actually forward everything\n\nResolver keeps a set of **autodefined system rules** that beat your `.` rule, because more specific matches win. From the docs:\n\n> VPC Resolver still doesn't forward all DNS queries to your network because using a DNS resolver outside of AWS would break some functionality.\n\nSo you get a split outcome, and the surprises live on both sides.\n\n**Survives locally (NOT forwarded), assuming `enableDnsHostnames=true`:**\n\n- `ec2.internal`, `compute-1.internal`, `compute-1.amazonaws.com` \u2014 " + }, + "delta_pass_rate": 0.5 + }, + { + "eval_id": "cross-account-opacity-honesty", + "run_index": 0, + "with_skill": { + "eval_id": "cross-account-opacity-honesty", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_simulate_effective_config'", + "passed": true, + "evidence": "Substring found: 'dns_simulate_effective_config'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "matches regex /opaque|unknown|not readable|cross-account/", + "passed": true, + "evidence": "Regex /opaque|unknown|not readable|cross-account/ matched", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output does not claim a cross-account construct is empty or absent when its contents cannot be read", + "passed": true, + "evidence": "Output explicitly warns against this failure mode: states a shared cross-account resolver rule is often 'enumerable but opaque', that correct output marks it as 'unknown', and says 'Reporting a construct as absent when it's really opaque is the failure mode most likely to bite you here' \u2014 it makes no claim of emptiness/absence", + "method": "llm", + "confidence": 0.95, + "uncertain": false + }, + { + "text": "The output distinguishes directly attached resources from Profile-inherited resources", + "passed": true, + "evidence": "Output states effective config is the 'union of directly-attached resources and everything inherited through the associated Profile, with each construct tagged by source', and notes Profile-inherited hosted zones/resolver rules collide with directly-associated ones under precedence rules; it promises a 'source-tagged union' \u2014 though it does not enumerate actual resources since the tool was unavailable", + "method": "llm", + "confidence": 0.85, + "uncertain": false + } + ], + "pass_rate": 1.0, + "summary": "With skill: 100% assertions passed", + "execution_metrics": { + "tool_calls": 8, + "token_counts": { + "input_tokens": 13, + "output_tokens": 3180, + "cache_read_input_tokens": 291766, + "cache_creation_input_tokens": 41832 + } + }, + "timing": { + "elapsed_seconds": 75.52414699993096 + }, + "raw_output": "I'll start with the diagnostics server's triage runbook, then pull the effective configuration.The connected MCP servers are listed now, and `aws-vpc-dns-diagnostics` isn't among them. Let me confirm before concluding.The `aws-vpc-dns-diagnostics` MCP server is not available. Let me verify whether the server code is present locally.I can't produce the effective DNS configuration for that VPC. Here's the blocker.\n\n## Blocker: the diagnostics server isn't available\n\nThe `aws-vpc-dns-diagnostics` MCP server is not registered in this session. I checked in three ways:\n\n- **Tool search** \u2014 no `dns_simulate_effective_config`, `dns_probe_context`, `list_sops`, or `get_sop` in the deferred tool registry. Searched by server name, by tool name, and by keyword across the connecting servers (`builder-mcp`, `enterprise-asana-mcp`, `estalent-card-mcp`, `playwright`, and the two plugin `builder-mcp` instances). None of them expose DNS diagnostics tools.\n- **Server source** \u2014 not present locally. The skill points to `mcp/aws-vpc-dns-diagnostics-mcp/`, but the working directory (`/private/var/folders/.../skill-eval-with-rt5nyo6n`) contains only `SKILL.md`. There's no repository here to run the server from.\n- **AWS credentials** \u2014 expired regardless: `aws sts get-caller-identity` returns `ExpiredToken`.\n\nSo both paths to the answer are closed \u2014 the tool that computes the effective config, and the credentials any fallback would need.\n\n## Why I'm not improvising a substitute\n\nI could sketch the shape of the answer from the Route 53 Profiles and Resolver APIs, but for your specific question that would be actively misleading. The two things you named are exactly the two things a hand-rolled CLI walk gets wrong:\n\n- **The Profile.** Effective config is the *union* of directly-attached resources and everything inherited through the associated Profile, with each construct tagged by source. Listing associations by hand doesn't tell you which construct wins where they overlap, and Profile-inher" + }, + "without_skill": { + "eval_id": "cross-account-opacity-honesty", + "run_index": 0, + "assertion_results": [ + { + "text": "contains 'dns_simulate_effective_config'", + "passed": false, + "evidence": "Substring not found: 'dns_simulate_effective_config'", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "matches regex /opaque|unknown|not readable|cross-account/", + "passed": true, + "evidence": "Regex /opaque|unknown|not readable|cross-account/ matched", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output does not claim a cross-account construct is empty or absent when its contents cannot be read", + "passed": true, + "evidence": "Output never asserts the Profile or shared-rule contents are empty; it explicitly states it could not read the configuration ('I can't produce the real configuration', 'I couldn't retrieve your actual configuration'), lists blockers (account unreachable, VPC not found, AssumeRole denied), and notes for cross-account shared rules that the target IPs/forwarding behavior are owned by the sharing account and 'you cannot see changes to them' \u2014 framed as visibility limits rather than absence", + "method": "llm", + "confidence": 0.85, + "uncertain": false + }, + { + "text": "The output distinguishes directly attached resources from Profile-inherited resources", + "passed": true, + "evidence": "Explicitly distinguishes 'locally-associated resolver rules' and 'directly-associated private hosted zones' (steps 3 and 5) from 'the Profile bound to this VPC' and its resource associations (step 2), and states the precedence interaction ('on an exact tie, local VPC config wins'; Profile rule for more specific name beats local VPC rule)", + "method": "llm", + "confidence": 0.9, + "uncertain": false + } + ], + "pass_rate": 0.75, + "summary": "Without skill: 75% assertions passed", + "execution_metrics": { + "tool_calls": 9, + "token_counts": { + "input_tokens": 17, + "output_tokens": 5205, + "cache_read_input_tokens": 418316, + "cache_creation_input_tokens": 49782 + } + }, + "timing": { + "elapsed_seconds": 140.20922175003216 + }, + "raw_output": "I'll query ARCC governance first (this involves VPC/infrastructure), then pull the actual DNS configuration.ARCC was not queried (MCP server unavailable) \u2014 no `search_arcc` tool is registered in this session, so I'll apply standard practices. All operations below are read-only describe/list calls.\n\nLet me check what credentials are available.The default credentials are expired. Let me check the profiles \u2014 there's a `dns-mode-b-ro` that looks read-only and DNS-scoped.Let me read the config with the Read tool instead.Neither working profile is the target account. Let me check all profiles for one that reaches 111122223333.No profile maps to account 111122223333. Let me confirm the VPC isn't in any account I can reach, and check where Route 53 Profiles actually exist.I can't produce the real configuration, and I want to be direct about why before giving you what I can.\n\n**These are placeholder identifiers.** `111122223333` is AWS's canonical documentation example account ID, and `vpc-0abc123def456789` is a placeholder VPC ID (`0abc123def456789` is not a real hex resource suffix pattern in use). None of your 10 configured profiles maps to account 111122223333, and the VPC ID resolves in none of the five accounts I can currently reach. Separately, `dns-mode-b-ro` \u2014 the one profile that looks purpose-built for this \u2014 is broken: its role `DnsDiagnosticReadOnlyRole` in 001550749972 doesn't trust your Isengard Admin principal, so `sts:AssumeRole` is denied.\n\nLet me pull the authoritative precedence rules so the framework I give you is accurate rather than from memory.I couldn't retrieve your actual configuration, so here is the precedence model plus the exact command sequence to produce it once you have working credentials.\n\n## Blockers\n\n| Issue | Detail |\n|---|---|\n| Account unreachable | No profile maps to `111122223333`; that's AWS's canonical docs placeholder account |\n| VPC not found | `vpc-0abc123def456789` returns `InvalidVpcID.NotFound` in all 5 reachable accounts; n" + }, + "delta_pass_rate": 0.25 + }, + { + "eval_id": "ssm-blocker-reported", + "run_index": 0, + "with_skill": { + "eval_id": "ssm-blocker-reported", + "run_index": 0, + "assertion_results": [ + { + "text": "matches regex /SSM|Systems Manager/", + "passed": true, + "evidence": "Regex /SSM|Systems Manager/ matched", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output reports that live in-instance probing is blocked rather than inventing resolution results", + "passed": true, + "evidence": "Output explicitly states 'I could not run the diagnostic', identifies SSM unavailability as a hard stop, notes MCP tools unreachable, and lists blockers instead of producing resolution results", + "method": "llm", + "confidence": 0.97, + "uncertain": false + }, + { + "text": "The output does not present fabricated resolver answers as observed data", + "passed": true, + "evidence": "No resolver answers, dig/hostname.bind outputs, or resolv.conf contents are presented; it states every Mode A finding is unavailable and only describes what probes would show hypothetically", + "method": "llm", + "confidence": 0.95, + "uncertain": false + } + ], + "pass_rate": 1.0, + "summary": "With skill: 100% assertions passed", + "execution_metrics": { + "tool_calls": 28, + "token_counts": { + "input_tokens": 40, + "output_tokens": 11945, + "cache_read_input_tokens": 1081201, + "cache_creation_input_tokens": 54699 + } + }, + "timing": { + "elapsed_seconds": 236.72465666697826 + }, + "raw_output": "I'll start by loading the skill's triage procedure and establishing preconditions.The MCP server is registered to a different project directory, so it isn't connected here. Let me verify its state and what's still possible without SSM.The MCP server tools aren't reachable from this session. Let me read the authoritative runbooks from the server source to determine what's actually possible without SSM.I could not run the diagnostic. Rather than guess at a resolution path, here is what's actually blocking and what to do about it.\n\n**ARCC was not queried (MCP server unavailable)** \u2014 no `search_arcc` tool is registered in this session.\n\n## Blocker 1 \u2014 Mode A cannot run on this instance (this is the hard stop)\n\nYou've already named it: `i-0abc123def456789` is not managed by SSM. Per the skill's own safety rules (`A-critical-safety-rules` #4) and `Z-general-triage` Step 0, that is a stop-and-report condition, not something to work around:\n\n> If SSM is unreachable, report that as the blocker. Do not route around it. ... Do not propose opening egress or attaching a public path as a workaround to make the diagnostic run.\n\n`dns_probe_context` and `dns_probe_compare` both execute inside the instance via SSM Run Command. Without SSM there is no way to observe what actually resolves, which resolver answered (`hostname.bind`), or the instance's real `/etc/resolv.conf`. Every Mode A finding is unavailable \u2014 including the intended-vs-actual resolver comparison that `Z-general-triage` calls \"often the whole answer.\"\n\nThe remediation is to make the instance SSM-reachable: SSM Agent running, `AmazonSSMManagedInstanceCore` on the instance role, and interface endpoints for **`ssm`, `ssmmessages`, and `ec2messages`**.\n\n\u26a0\ufe0f **One correction to flag, because it will misdirect you here.** The server's own error string at `mcp/aws-vpc-dns-diagnostics-mcp/src/server.py:294-299` says SSM endpoints *\"or an EC2 Instance Connect Endpoint\"* are sufficient. That contradicts the runbooks, which are e" + }, + "without_skill": { + "eval_id": "ssm-blocker-reported", + "run_index": 0, + "assertion_results": [ + { + "text": "matches regex /SSM|Systems Manager/", + "passed": true, + "evidence": "Regex /SSM|Systems Manager/ matched", + "method": "deterministic", + "confidence": 1.0, + "uncertain": false + }, + { + "text": "The output reports that live in-instance probing is blocked rather than inventing resolution results", + "passed": true, + "evidence": "Output explicitly states it cannot diagnose the DNS problem, lists blockers (malformed/placeholder instance ID, no access path to account 111122223333, denied AssumeRole on the read-only DNS role), and says it 'never got to a state where \"resolution is failing\" could be observed'; it only describes the read-only path it *would* take rather than reporting results. It also notes SSM unavailability and asks for real identifiers to proceed.", + "method": "llm", + "confidence": 0.95, + "uncertain": false + }, + { + "text": "The output does not present fabricated resolver answers as observed data", + "passed": true, + "evidence": "No resolver answers, dig/nslookup output, DHCP option set values, VPC DNS attribute states, or Route 53 rule findings are presented as observed. The only concrete data reported are actual verified facts: STS caller-identity results per profile, the ExpiredToken/AssumeRole-denied errors, and the InvalidInstanceID.Malformed responses. Prospective checks are clearly framed in conditional/future terms ('the read-only path I'd take is...').", + "method": "llm", + "confidence": 0.94, + "uncertain": false + } + ], + "pass_rate": 1.0, + "summary": "Without skill: 100% assertions passed", + "execution_metrics": { + "tool_calls": 13, + "token_counts": { + "input_tokens": 20, + "output_tokens": 4995, + "cache_read_input_tokens": 417747, + "cache_creation_input_tokens": 29462 + } + }, + "timing": { + "elapsed_seconds": 125.5548415830126 + }, + "raw_output": "I'll start by checking ARCC guidance, since this involves infrastructure (EC2, SSM, VPC/DNS).ARCC was not queried (MCP server unavailable) \u2014 no `search_arcc` tool is exposed in this session. I'll apply standard security/BSC practice instead, and keep every AWS call read-only.\n\nLet me see what access I actually have.Credentials are expired. Let me look at the profile config to find which one maps to that account \u2014 and note there's a `dns-mode-b-ro` profile that looks read-only.Config file reads are blocked. Let me try authenticating directly with the read-only profile instead.That profile targets a different account (001550749972) and the assume-role is denied. Let me find which profile actually reaches the target account 111122223333.None of my working profiles is account `111122223333`. Let me check whether that instance exists in any account I can reach.Both identifiers are placeholders, which explains the errors. Let me confirm.I can't diagnose the actual DNS problem \u2014 the identifiers in the request are placeholders, not real resources, and I have no access to the named account. Here's what I verified:\n\n**Identifiers are non-routable examples**\n- `i-0abc123def456789` has 16 hex chars after `i-`; EC2 instance IDs are 8 or 17. All five accounts I can reach rejected it with `InvalidInstanceID.Malformed` \u2014 it's malformed, not merely absent.\n- `111122223333` is the standard AWS documentation placeholder account ID.\n\n**No access path to account 111122223333**\n| Profile | Account | Status |\n|---|---|---|\n| `default` | \u2014 | ExpiredToken |\n| `ddericco+netops-AdminAccess` | 042546039969 | works |\n| `ddericco+app1-AdminAccess` | 077102050756 | works |\n| `ddericco+app2-AdminAccess` | 690681021743 | works |\n| `ddericco+dev-Admin` | 001550749972 | works |\n| `ddericco+onprem-AdminAccess` | 371884586195 | works |\n| `claude-code-DO-NOT-DELETE` | 350469506040 | works |\n| `dns-mode-b-ro` | 001550749972 | `sts:AssumeRole` denied on `DnsDiagnosticReadOnlyRole` |\n\nI tried `dns-mode-b-r" + }, + "delta_pass_rate": 0.0 + } + ], + "run_summary": { + "with_skill": { + "mean_pass_rate": 0.8, + "stddev_pass_rate": 0.1871, + "mean_tokens": 4783.7, + "mean_input_tokens": 23.7, + "mean_output_tokens": 4783.7, + "mean_total_tokens": 4807.3, + "mean_tool_calls": 11.3 + }, + "without_skill": { + "mean_pass_rate": 0.5667, + "stddev_pass_rate": 0.3109, + "mean_tokens": 8139.8, + "mean_input_tokens": 14.8, + "mean_output_tokens": 8139.8, + "mean_total_tokens": 8154.7, + "mean_tool_calls": 10.2 + }, + "delta": { + "pass_rate": 0.2333, + "tokens": -3356.2, + "total_tokens": -3347.3, + "input_tokens": 8.8, + "tool_calls": 1.2 + }, + "cost_efficiency": { + "quality_delta": 0.2333, + "cost_delta_pct": -41.0, + "classification": "PARETO_BETTER", + "emoji": "\ud83d\udfe2", + "description": "Skill improves quality while reducing cost" + }, + "estimated_cost": { + "with_skill_per_run": { + "input_cost": 7.1e-05, + "output_cost": 0.071755, + "total_cost": 0.071826, + "model": "sonnet", + "currency": "USD" + }, + "without_skill_per_run": { + "input_cost": 4.5e-05, + "output_cost": 0.122097, + "total_cost": 0.122142, + "model": "sonnet", + "currency": "USD" + }, + "per_eval_pair": 0.193968, + "total_runs": 6, + "total_cost": 1.1638, + "model": "sonnet", + "currency": "USD" + } + }, + "scores": { + "outcome": 0.8, + "process": 1.0, + "style": 0.8, + "efficiency": 1.0, + "overall": 0.9 + }, + "passed": true +} \ No newline at end of file diff --git a/skills/aws-vpc-dns-investigation/evals/eval_queries.json b/skills/aws-vpc-dns-investigation/evals/eval_queries.json new file mode 100644 index 0000000..de2c09e --- /dev/null +++ b/skills/aws-vpc-dns-investigation/evals/eval_queries.json @@ -0,0 +1,34 @@ +[ + { + "query": "Which skill would help me investigate why a name is not resolving from an EC2 instance in my VPC? Just name it; do not run it.", + "should_trigger": true + }, + { + "query": "Which skill would help me check whether enabling private DNS on an interface endpoint would break resolution? Just name it; do not run it.", + "should_trigger": true + }, + { + "query": "How do I set up an S3 bucket lifecycle policy?", + "should_trigger": false + }, + { + "query": "What are the best practices for DynamoDB table design?", + "should_trigger": false + }, + { + "query": "Write a Terraform module for an Application Load Balancer.", + "should_trigger": false + }, + { + "query": "How do I register a public domain name and create a public hosted zone in Route 53?", + "should_trigger": false + }, + { + "query": "My RDS instance is running out of storage. How do I scale it?", + "should_trigger": false + }, + { + "query": "Explain the difference between an IAM role and an IAM user.", + "should_trigger": false + } +] diff --git a/skills/aws-vpc-dns-investigation/evals/evals.json b/skills/aws-vpc-dns-investigation/evals/evals.json new file mode 100644 index 0000000..3493728 --- /dev/null +++ b/skills/aws-vpc-dns-investigation/evals/evals.json @@ -0,0 +1,74 @@ +[ + { + "id": "preconditions-before-interpretation", + "prompt": "An application on EC2 instance i-0abc123def456789 in us-east-1 account 111122223333 is getting NXDOMAIN for secretsmanager.us-east-1.amazonaws.com. Figure out why.", + "expected_output": "Establishes the VPC-attribute precondition with dns_probe_context before interpreting any resolution result, then observes live resolution with dns_probe_compare, and loads the triage runbook rather than guessing.", + "files": [], + "assertions": [ + "contains 'dns_probe_context'", + "contains 'enableDnsSupport'", + "contains 'dns_probe_compare'", + "The output checks the VPC DNS attributes before concluding a cause from the resolution failure", + "The output does not recommend creating, modifying, or deleting any DNS resource" + ] + }, + { + "id": "custom-resolver-divergence", + "prompt": "In account 111122223333 us-east-1, a hostname resolves to a public IP from instance i-0abc123def456789 but we expected the private endpoint address. Other instances resolve it correctly.", + "expected_output": "Compares the instance's actual resolver configuration against the VPC DHCP option set, uses dns_probe_compare to identify which resolver answered, and judges the answer by name category rather than by whether resolvers agree.", + "files": [], + "assertions": [ + "contains 'dns_probe_compare'", + "matches regex /resolv\\.conf|DHCP/", + "The output identifies which resolver returned the answer rather than assuming the VPC resolver answered", + "The output does not conclude the configuration is correct merely because multiple resolvers returned the same answer" + ] + }, + { + "id": "simulate-before-change", + "prompt": "We want to enable private DNS on the Secrets Manager interface endpoint in vpc-0abc123def456789, account 111122223333, us-east-1. Is that safe?", + "expected_output": "Reads the effective configuration with dns_simulate_effective_config and predicts impact with dns_simulate_change before giving an answer, and does not recommend applying the change without simulating it.", + "files": [], + "assertions": [ + "contains 'dns_simulate_effective_config'", + "contains 'dns_simulate_change'", + "The output simulates the proposed change before assessing whether it is safe", + "The output reports which names would be affected rather than only describing the change generically" + ] + }, + { + "id": "broad-forward-rule-impact", + "prompt": "Account 111122223333, us-east-1, vpc-0abc123def456789. We plan to add a Resolver FORWARD rule for '.' pointing at our on-premises resolvers. What breaks?", + "expected_output": "Simulates the change, identifies that a root FORWARD rule sweeps names that currently resolve through AWS constructs, and loads the broad FORWARD sweep runbook.", + "files": [], + "assertions": [ + "contains 'dns_simulate_change'", + "matches regex /FORWARD/", + "The output identifies that a '.' FORWARD rule affects names beyond the intended on-premises zones", + "The output retrieves the relevant runbook using get_sop or list_sops instead of reasoning only from general knowledge" + ] + }, + { + "id": "cross-account-opacity-honesty", + "prompt": "Account 111122223333 us-east-1 vpc-0abc123def456789 has a Route 53 Profile and a shared resolver rule from another account. Give me the effective DNS configuration.", + "expected_output": "Reports the effective configuration including Profile-inherited resources with their source, and reports cross-account constructs whose contents are not readable as opaque or unknown rather than as absent.", + "files": [], + "assertions": [ + "contains 'dns_simulate_effective_config'", + "matches regex /opaque|unknown|not readable|cross-account/", + "The output does not claim a cross-account construct is empty or absent when its contents cannot be read", + "The output distinguishes directly attached resources from Profile-inherited resources" + ] + }, + { + "id": "ssm-blocker-reported", + "prompt": "Resolution is failing on instance i-0abc123def456789 in account 111122223333 us-east-1, but that instance is not managed by SSM. Diagnose the DNS problem.", + "expected_output": "Reports the missing SSM path as the blocker for live probing, and does not fabricate probe output for an instance it cannot reach.", + "files": [], + "assertions": [ + "matches regex /SSM|Systems Manager/", + "The output reports that live in-instance probing is blocked rather than inventing resolution results", + "The output does not present fabricated resolver answers as observed data" + ] + } +] diff --git a/skills/aws-vpc-dns-investigation/evals/report.json b/skills/aws-vpc-dns-investigation/evals/report.json new file mode 100644 index 0000000..c16363f --- /dev/null +++ b/skills/aws-vpc-dns-investigation/evals/report.json @@ -0,0 +1,65 @@ +{ + "skill_name": "aws-vpc-dns-investigation", + "skill_path": "/Users/ddericco/Projects/sample-devops-agent-tools/skills/aws-vpc-dns-investigation", + "timestamp": "2026-07-30T21:24:18Z", + "overall_score": 0.96, + "overall_grade": "A", + "passed": true, + "sections": { + "audit": { + "score": 100, + "grade": "A", + "passed": true, + "normalized": 1.0, + "critical": 0, + "warning": 0, + "info": 0 + }, + "functional": { + "overall": 0.9, + "grade": "A", + "passed": true, + "scores": { + "outcome": 0.8, + "process": 1.0, + "style": 0.8, + "efficiency": 1.0, + "overall": 0.9 + }, + "cost_efficiency": { + "quality_delta": 0.2333, + "cost_delta_pct": -41.0, + "classification": "PARETO_BETTER", + "emoji": "\ud83d\udfe2", + "description": "Skill improves quality while reducing cost" + }, + "estimated_cost": { + "with_skill_per_run": { + "input_cost": 7.1e-05, + "output_cost": 0.071755, + "total_cost": 0.071826, + "model": "sonnet", + "currency": "USD" + }, + "without_skill_per_run": { + "input_cost": 4.5e-05, + "output_cost": 0.122097, + "total_cost": 0.122142, + "model": "sonnet", + "currency": "USD" + }, + "per_eval_pair": 0.193968, + "total_runs": 6, + "total_cost": 1.1638, + "model": "sonnet", + "currency": "USD" + } + }, + "trigger": { + "pass_rate": 1.0, + "grade": "A", + "passed": true, + "total_queries": 8 + } + } +} \ No newline at end of file diff --git a/skills/aws-vpc-dns-investigation/evals/trigger_report.json b/skills/aws-vpc-dns-investigation/evals/trigger_report.json new file mode 100644 index 0000000..420f271 --- /dev/null +++ b/skills/aws-vpc-dns-investigation/evals/trigger_report.json @@ -0,0 +1,116 @@ +{ + "skill_name": "aws-vpc-dns-investigation", + "skill_path": "/Users/ddericco/Projects/sample-devops-agent-tools/skills/aws-vpc-dns-investigation", + "query_results": [ + { + "query": "Which skill would help me investigate why a name is not resolving from an EC2 instance in my VPC? Just name it; do not run it.", + "should_trigger": true, + "trigger_count": 1, + "run_count": 1, + "trigger_rate": 1.0, + "passed": true, + "mean_input_tokens": 2.0, + "mean_output_tokens": 105.0, + "mean_total_tokens": 107.0 + }, + { + "query": "Which skill would help me check whether enabling private DNS on an interface endpoint would break resolution? Just name it; do not run it.", + "should_trigger": true, + "trigger_count": 1, + "run_count": 1, + "trigger_rate": 1.0, + "passed": true, + "mean_input_tokens": 2.0, + "mean_output_tokens": 21.0, + "mean_total_tokens": 23.0 + }, + { + "query": "How do I set up an S3 bucket lifecycle policy?", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 6.0, + "mean_output_tokens": 3545.0, + "mean_total_tokens": 3551.0 + }, + { + "query": "What are the best practices for DynamoDB table design?", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 4.0, + "mean_output_tokens": 2457.0, + "mean_total_tokens": 2461.0 + }, + { + "query": "Write a Terraform module for an Application Load Balancer.", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 23.0, + "mean_output_tokens": 24870.0, + "mean_total_tokens": 24893.0 + }, + { + "query": "How do I register a public domain name and create a public hosted zone in Route 53?", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 4.0, + "mean_output_tokens": 3365.0, + "mean_total_tokens": 3369.0 + }, + { + "query": "My RDS instance is running out of storage. How do I scale it?", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 2.0, + "mean_output_tokens": 809.0, + "mean_total_tokens": 811.0 + }, + { + "query": "Explain the difference between an IAM role and an IAM user.", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 4.0, + "mean_output_tokens": 1703.0, + "mean_total_tokens": 1707.0 + } + ], + "summary": { + "total_queries": 8, + "passed": 8, + "failed": 0, + "trigger_precision": 1.0, + "no_trigger_precision": 1.0, + "mean_total_tokens_per_run": 4615.2, + "estimated_cost": { + "per_run": { + "input_cost": 1.8e-05, + "output_cost": 0.069141, + "total_cost": 0.069158, + "model": "sonnet", + "currency": "USD" + }, + "total_runs": 8, + "total_cost": 0.5533, + "model": "sonnet", + "currency": "USD" + } + }, + "passed": true +} \ No newline at end of file From f636f56870c428b3854e3b36cb18e6a4b02bc749 Mon Sep 17 00:00:00 2001 From: Dave DeRicco <30156588+ddericco@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:38:01 -0400 Subject: [PATCH 5/9] Add VPC DNS Investigation skill to llms.txt --- llms.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/llms.txt b/llms.txt index 3b5a275..173e866 100644 --- a/llms.txt +++ b/llms.txt @@ -24,6 +24,7 @@ Skills can be used with these AWS DevOps Agent types: - [Wiz Security Context Skill](skills/wiz-security-context/SKILL.md): Queries the Wiz MCP server for a resource's security context (vulnerabilities, misconfigurations, secrets, active threats, malware, toxic combinations) to determine whether an operational anomaly is an operational issue or a security incident - [Service Quota Check Skill](skills/service-quota-check/SKILL.md): Checks AWS service quota utilization during investigations and before provisioning resources, flags quotas at 85%+ utilization, and requests increases via the Service Quotas API or recommends support cases - [DMS Operational Review Skill](skills/database-migration-service-expertise/SKILL.md): Conducts AWS Database Migration Service operational reviews with 5-category health scoring, task failure troubleshooting, migration cutover runbooks, version deprecation tracking, and cost optimization +- [VPC DNS Investigation Skill](skills/aws-vpc-dns-investigation/SKILL.md): Diagnoses VPC DNS resolution failures and validates DNS control-plane changes before they are applied, driving the aws-vpc-dns-diagnostics MCP server to observe live resolution from inside the affected subnet and to simulate a proposed change ## Key Concepts From 7def496afe23add946585c49df05f774d9bf46d6 Mon Sep 17 00:00:00 2001 From: Dave DeRicco <30156588+ddericco@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:34:25 -0400 Subject: [PATCH 6/9] Enforce resolver allowlist for caller-supplied IPs, fix VPCE apex derivation, validate change fields, remove incorrect EICE reference --- mcp/aws-vpc-dns-diagnostics-mcp/README.md | 6 +- .../src/dns_model.py | 19 +++- mcp/aws-vpc-dns-diagnostics-mcp/src/server.py | 103 ++++++++++++++---- .../tests/test_allowlist.py | 9 +- .../tests/test_security_review.py | 44 +++++++- 5 files changed, 143 insertions(+), 38 deletions(-) diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/README.md b/mcp/aws-vpc-dns-diagnostics-mcp/README.md index a1e5b5a..a918d98 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/README.md +++ b/mcp/aws-vpc-dns-diagnostics-mcp/README.md @@ -313,9 +313,9 @@ endpoint. This server talks only to AWS control-plane APIs (SSM, EC2, Route 53, Route 53 Resolver, Route 53 Profiles, VPC Lattice) and never opens a connection to a customer data resource. The private-path requirement applies to the **target instances**, not the Lambda: Mode A reaches them through SSM, which requires -`ssm`, `ssmmessages`, and `ec2messages` interface endpoints or an EC2 Instance -Connect Endpoint in the target VPC, and the server reports unreachable SSM as a -blocker rather than falling back to a public path. +`ssm`, `ssmmessages`, and `ec2messages` interface endpoints in the target VPC, +and the server reports unreachable SSM as a blocker rather than falling back to a +public path. Putting the Lambda in a VPC would add NAT or interface endpoints purely so it could keep reaching the same public AWS API endpoints, with no reduction in what diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/dns_model.py b/mcp/aws-vpc-dns-diagnostics-mcp/src/dns_model.py index 0476297..e5b6f1a 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/src/dns_model.py +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/dns_model.py @@ -307,7 +307,9 @@ def apply_change(model: EffectiveModel, change: dict) -> EffectiveModel: src = f"profile:{change['profile_id']}" if ctype == "associate_profile" and change.get("profile_id") else "direct" if ctype == "enable_vpce_private_dns": - apex = change["service_apex"] + apex = change.get("service_apex") + if not apex: + raise ValueError("enable_vpce_private_dns requires 'service_apex'") served = tuple(change.get("served_names", ())) # Flip an existing endpoint for this apex if present; else append. existing = [v for v in model.vpces if v.service_apex.rstrip(".").lower() == apex.rstrip(".").lower()] @@ -317,13 +319,19 @@ def apply_change(model: EffectiveModel, change: dict) -> EffectiveModel: return replace(model, vpces=model.vpces + (Vpce(apex, True, "direct", served),)) if ctype == "associate_phz": - return replace(model, phzs=model.phzs + (Phz(change["zone"], "direct"),)) + zone = change.get("zone") + if not zone: + raise ValueError("associate_phz requires 'zone'") + return replace(model, phzs=model.phzs + (Phz(zone, "direct"),)) if ctype == "add_resolver_rule": # Accept either "target" (a rendered label) or "target_ips" (the shape # the Route 53 Resolver API and this server's docs use). Without the # latter, a caller passing target_ips produced a rule whose target # rendered as an empty string in the impact report. + domain = change.get("domain") + if not domain: + raise ValueError("add_resolver_rule requires 'domain'") target = change.get("target") or "" if not target: ips = change.get("target_ips") or () @@ -331,7 +339,7 @@ def apply_change(model: EffectiveModel, change: dict) -> EffectiveModel: ips = (ips,) target = ", ".join(str(i) for i in ips) rule = ResolverRule( - domain=change["domain"], + domain=domain, rule_type=change.get("rule_type", "FORWARD"), target=target, source="direct", @@ -367,7 +375,10 @@ def apply_change(model: EffectiveModel, change: dict) -> EffectiveModel: ) if ctype == "set_snva_preference": - return replace(model, snva_preference=change["preference"]) + pref = change.get("preference") + if not pref: + raise ValueError("set_snva_preference requires 'preference'") + return replace(model, snva_preference=pref) if ctype == "set_dhcp_dns": # Modeled as toggling the VPC resolver on/off for this simulation scope. diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py index cfa88c5..89e91fc 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py @@ -119,11 +119,11 @@ def _warn_wildcard_resolvers(): stage = os.environ.get("STAGE_NAME", "(unset)") print( "WARNING: ALLOWED_RESOLVERS is '*' (wildcard) with STAGE_NAME=" - f"{stage}. dns_probe_compare will accept ANY literal resolver IP and " - "query it from the target instance. Hostnames are still refused " - "(fail-closed). Do NOT use a wildcard resolver allowlist in any " - "deployment reachable by AWS DevOps Agent -- set ALLOWED_RESOLVERS to a " - "comma-separated list of permitted resolver addresses.", + f"{stage}. dns_probe_compare will accept ANY caller-supplied resolver " + "IP or hostname and query it from the target instance. Do NOT use a " + "wildcard resolver allowlist in any deployment reachable by AWS DevOps " + "Agent -- set ALLOWED_RESOLVERS to a comma-separated list of permitted " + "resolver addresses.", flush=True, ) @@ -250,7 +250,7 @@ def _valid_name(name: str) -> bool: def _valid_resolver(resolver: str) -> bool: - """A resolver must be a literal IP OR an operator-allowlisted hostname.""" + """Syntax check: resolver must be a literal IP or a well-formed hostname.""" if _SHELL_META_RE.search(resolver): return False try: @@ -258,16 +258,26 @@ def _valid_resolver(resolver: str) -> bool: return True except ValueError: pass - # Not an IP - must be explicitly allowlisted and a well-formed hostname. - # NOTE (L2): the general _validate() treats an empty allowlist as allow-all, - # but resolvers are DELIBERATELY the opposite - an empty ALLOWED_RESOLVERS - # allows only literal IPs and rejects ALL hostnames (fail-closed). This - # prevents the comparison feature from becoming an arbitrary-egress primitive - # via an unvetted hostname. A hostname is permitted only when explicitly - # listed in ALLOWED_RESOLVERS. - if ALLOWED_RESOLVERS and resolver.lower() in ALLOWED_RESOLVERS: - return bool(_NAME_RE.match(resolver)) - return False + # Not an IP - must be a well-formed hostname (no shell metacharacters, DNS + # charset). Allowlist enforcement happens separately in _resolver_allowed(). + return bool(_NAME_RE.match(resolver)) + + +def _resolver_allowed(resolver: str) -> bool: + """Allowlist gate: is this resolver permitted by ALLOWED_RESOLVERS? + + When ALLOWED_RESOLVERS is non-empty, BOTH IPs and hostnames must appear in + it. When empty (wildcard), all syntactically valid resolvers pass (gated at + import time by _enforce_prod_allowlists / _warn_wildcard_resolvers). + + DHCP-discovered resolvers bypass this check because they originate from + VPC infrastructure (operator-configured DHCP option set), not from the + caller. The exemption is applied at the call site in dns_probe_compare, + not here. + """ + if not ALLOWED_RESOLVERS: + return True + return resolver.lower() in ALLOWED_RESOLVERS def _valid_family(family: str) -> bool: @@ -293,9 +303,8 @@ def _ssm_reachable(session, instance_id: str) -> bool: _SSM_UNREACHABLE_MSG = ( "SSM is not reachable for instance {iid}. Ensure SSM VPC endpoints " - "(ssm, ssmmessages, ec2messages) or an EC2 Instance Connect Endpoint are in " - "place and the instance role has AmazonSSMManagedInstanceCore. Not falling " - "back to a public path." + "(ssm, ssmmessages, ec2messages) are in place and the instance role has " + "AmazonSSMManagedInstanceCore. Not falling back to a public path." ) @@ -550,13 +559,23 @@ def dns_probe_compare( "ERROR: no resolvers to probe. Pass `resolvers` explicitly or leave " "include_dhcp_dns=true on a VPC whose DHCP option set names a resolver." ) + + # Two-pass validation: caller-supplied resolvers are gated by both syntax + # AND ALLOWED_RESOLVERS; DHCP-discovered resolvers are exempt from the + # allowlist because they originate from VPC infrastructure, not the caller. + caller_supplied = set(resolvers or []) for r in resolver_set: if not _valid_resolver(r): return ( - f"ERROR: resolver '{r}' is not a literal IP or an allowlisted " - "hostname. The comparison feature must not become an " + f"ERROR: resolver '{r}' is not a valid IP or hostname. " + "The comparison feature must not become an " "arbitrary-egress primitive." ) + if r in caller_supplied and not _resolver_allowed(r): + return ( + f"ERROR: resolver '{r}' is not in ALLOWED_RESOLVERS. " + f"Permitted: {', '.join(sorted(ALLOWED_RESOLVERS))}." + ) # L3: check SSM reachability ONCE, not per (resolver, family) triple. if not _ssm_reachable(session, instance_id): @@ -717,7 +736,21 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None private = ep.get("PrivateDnsEnabled", False) if etype == "Interface": svc = ep.get("ServiceName", "") - apex = svc.split("com.amazonaws.")[-1] if "com.amazonaws." in svc else svc + # Convert ServiceName to the private-DNS FQDN the endpoint installs. + # com.amazonaws.us-east-1.secretsmanager -> secretsmanager.us-east-1.amazonaws.com + # com.amazonaws.cn.cn-north-1.s3 -> s3.cn-north-1.amazonaws.com.cn + if "com.amazonaws." in svc: + parts = svc.split(".") # [com, amazonaws, us-east-1, secretsmanager] or [com, amazonaws, cn, cn-north-1, s3] + if len(parts) >= 4 and parts[2] == "cn": + # China partition: com.amazonaws.cn.. + apex = f"{parts[-1]}.{parts[3]}.amazonaws.com.cn" + elif len(parts) >= 4: + # Standard/GovCloud: com.amazonaws.. + apex = f"{parts[-1]}.{parts[2]}.amazonaws.com" + else: + apex = svc + else: + apex = svc vpces.append(Vpce(service_apex=apex, private_dns=private, source="direct", gated=True)) elif etype in ("Resource", "ServiceNetwork"): @@ -922,6 +955,24 @@ def dns_simulate_change( if ctype not in known: return f"ERROR: unknown change type '{ctype}'. Supported: {', '.join(sorted(known))}." + # Validate required fields per change type. Stable errors prevent KeyError + # deep in dns_model.apply_change(). + _required_fields = { + "enable_vpce_private_dns": ["service_apex"], + "associate_phz": ["zone"], + "add_resolver_rule": ["domain"], + "associate_dns_firewall": ["domains", "action"], + "associate_profile": ["profile_id"], + "set_snva_preference": ["preference"], + "set_dhcp_dns": ["servers"], + } + missing = [f for f in _required_fields.get(ctype, []) if f not in change] + if missing: + return ( + f"ERROR: change type '{ctype}' requires fields: " + f"{', '.join(_required_fields[ctype])}. Missing: {', '.join(missing)}." + ) + session = _assume(account_id, region, READONLY_ROLE_ARN_PATTERN, "dns-sim-change") model = _build_effective_model(session, vpc_id, onprem_zones) names = candidate_names or _derive_candidate_names(model) @@ -951,7 +1002,13 @@ def dns_simulate_change( f"Poll association status = COMPLETE before trusting resolution.\n" ) if not impacts: - return header + "\nNo currently-resolving names change or break. ✓" + source_label = "operator-supplied" if candidate_names else "API-derived from current config" + return ( + header + f"\nNo currently-resolving names change or break within the " + f"{len(names)}-name candidate set ({source_label}). " + f"Names not in this set were not evaluated; supply `candidate_names` " + f"or enable Resolver Query Logging for broader coverage." + ) lines = [ "\n| name | before | after | traps | severity | vol |", diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_allowlist.py b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_allowlist.py index 2ac73f2..6dd794a 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_allowlist.py +++ b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_allowlist.py @@ -67,10 +67,11 @@ def test_allowlisted_hostname(self): assert _valid_resolver("resolver.corp.example") def test_rejects_non_allowlisted_hostname(self): - from server import _valid_resolver - # Well-formed hostname but NOT in ALLOWED_RESOLVERS -> rejected, so the - # comparison feature cannot become an arbitrary-egress primitive. - assert not _valid_resolver("attacker.example.net") + from server import _resolver_allowed + # Well-formed hostname but NOT in ALLOWED_RESOLVERS -> rejected by the + # allowlist gate, so the comparison feature cannot become an + # arbitrary-egress primitive. (Syntax is fine, allowlist blocks it.) + assert not _resolver_allowed("attacker.example.net") def test_rejects_injection(self): from server import _valid_resolver diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py index 80bf30d..fa33e18 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py +++ b/mcp/aws-vpc-dns-diagnostics-mcp/tests/test_security_review.py @@ -276,14 +276,50 @@ def test_wildcard_resolvers_emits_warning(self): f"expected a wildcard-resolver warning, got: {combined[:400]}" ) - def test_wildcard_still_refuses_hostnames(self): + def test_wildcard_allows_all_with_warning(self): r = self._fresh_server({"ALLOWED_RESOLVERS": "*"}) assert "OK_IP True" in r.stdout, r.stdout + r.stderr - assert "OK_HOST False" in r.stdout, ( - "a wildcard resolver allowlist must STILL refuse hostnames " - f"(fail-closed): {r.stdout}" + # With a wildcard allowlist, all resolvers (IPs and hostnames) pass the + # allowlist gate. Security is enforced by _enforce_prod_allowlists + # refusing to start in STAGE_NAME=prod on a wildcard. + assert "OK_HOST True" in r.stdout, ( + "a wildcard resolver allowlist should accept both IPs and hostnames " + f"(enforcement is at the prod gate): {r.stdout}" ) def test_explicit_allowlist_emits_no_warning(self): r = self._fresh_server({"ALLOWED_RESOLVERS": "10.0.0.2"}) assert "WARNING" not in (r.stdout + r.stderr) + + def test_explicit_allowlist_blocks_unlisted_ip(self): + """When ALLOWED_RESOLVERS is set, caller IPs not in the list are refused.""" + env_extra = {"ALLOWED_RESOLVERS": "10.0.0.53"} + env = dict( + { + "ALLOWED_ACCOUNTS": "111122223333", + "ALLOWED_REGIONS": "us-east-1", + "STAGE_NAME": "dev", + } + ) + env.update(env_extra) + code = ( + "import server;" + "print('LISTED', server._resolver_allowed('10.0.0.53'));" + "print('UNLISTED_IP', server._resolver_allowed('8.8.8.8'));" + "print('UNLISTED_HOST', server._resolver_allowed('evil.example.com'))" + ) + r = subprocess.run( + [sys.executable, "-c", code], + cwd=os.path.join(os.path.dirname(__file__), "..", "src"), + env=env, + capture_output=True, + text=True, + timeout=120, + ) + assert "LISTED True" in r.stdout, r.stdout + r.stderr + assert "UNLISTED_IP False" in r.stdout, ( + f"an explicit allowlist must block unlisted IPs: {r.stdout}" + ) + assert "UNLISTED_HOST False" in r.stdout, ( + f"an explicit allowlist must block unlisted hostnames: {r.stdout}" + ) From b59f0d60221cf38db4b645b70c20cb6f253fc463 Mon Sep 17 00:00:00 2001 From: Dave DeRicco <30156588+ddericco@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:40:56 -0400 Subject: [PATCH 7/9] Rewrite skill entry sequence with classify-first Mode A/B routing --- skills/aws-vpc-dns-investigation/SKILL.md | 233 +++++++++++++++------- 1 file changed, 158 insertions(+), 75 deletions(-) diff --git a/skills/aws-vpc-dns-investigation/SKILL.md b/skills/aws-vpc-dns-investigation/SKILL.md index 03f9be3..c9953e8 100644 --- a/skills/aws-vpc-dns-investigation/SKILL.md +++ b/skills/aws-vpc-dns-investigation/SKILL.md @@ -11,78 +11,161 @@ metadata: # Investigate VPC DNS Resolution -Use the tools on the connected `aws-vpc-dns-diagnostics` MCP server. Start by calling -`list_sops`, then `get_sop` with slug `Z-general-triage` to load the triage decision -tree, and follow the runbook it returns. The runbooks are the authoritative -procedure; this skill decides when to engage and in what order. - -## Establish preconditions before interpreting any result - -Call `dns_probe_context` first. A resolution result means nothing until you know -whether the VPC resolver is even answering. - -`enableDnsSupport` gates the entire VPC resolver. When it is false, neither the -`.2` address nor the IPv6 resolver answers at all, and every downstream symptom is -explained by that one attribute. The same call returns the instance's address -family and the VPC DHCP option set's `domain-name-servers`, which is the resolver -the VPC intends the instance to use. Load `A-resolver-disabled-precondition` when -the attribute is false. - -## Observe what actually resolves - -For a live symptom, call `dns_probe_compare` with the failing name. It runs an -allowlisted probe set inside the instance through SSM and returns each resolver's -answer alongside the resolver's own identity from `hostname.bind`, so you learn -which resolver answered rather than assuming. The VPC DHCP resolver is added -automatically, so a custom or hybrid resolver is compared against the VPC resolver -without you looking it up first. - -Compare the instance's actual `/etc/resolv.conf` against the DHCP option set from -`dns_probe_context`. A mismatch means the instance is not using the resolver the -VPC hands out, which is a different root cause from a misconfigured rule. - -Judge answers by name category, not by whether resolvers agree. Two resolvers -returning the same wrong answer is still a failure, and a divergence can be -correct. Load `A-name-category-classification` before concluding. - -## Validate a change before it is applied - -When the request is whether a change is safe, call `dns_simulate_effective_config` -to get the VPC's effective configuration, which is the union of directly attached -resources and anything inherited through an associated Route 53 Profile, with each -construct tagged by its source. Then call `dns_simulate_change` with the proposed -change to get a per-name impact report. This is symbolic and read-only; it predicts -breakage without touching the control plane. - -Never recommend applying one of these changes without simulating it first. A broad -FORWARD rule, enabling private DNS on an interface endpoint, or a Profile -association can silently redirect names that currently resolve correctly. - -## Load the matching pattern runbook - -When a signature appears in the output, retrieve the runbook for it with `get_sop` -rather than reasoning from first principles. Available patterns include custom or -hybrid resolver divergence, FORWARD versus private hosted zone precedence -collisions, address-family divergence, VPC endpoint shadow NXDOMAIN, broad FORWARD -sweep, DNS Firewall blocks, the `privateDnsEnabled` and `PrivateDnsPreference` -flag-AND mismatch, and Route 53 Profile propagation timing. Call `list_sops` for the -current catalogue and exact slugs. - -## Report honestly - -All tools are read-only observation and simulation. Do not modify, delete, or -create DNS resources as part of this skill; produce the diagnosis and the -recommended change, and leave application to the operator. - -Cross-account constructs shared with the target account may be enumerable but -opaque, and the tools mark them as such. Report an opaque construct as unknown -rather than treating it as absent. Load `C-cross-account-opaque-constructs` and -`C-limitations-and-boundaries` and state the boundaries to the operator instead of -inferring past them. - -Requires the aws-vpc-dns-diagnostics MCP server to be registered in the Agent Space -with its tools allowlisted. The server is in this repository at -`mcp/aws-vpc-dns-diagnostics-mcp/`. Mode A tools additionally require the target -instance to be reachable through SSM. If the server is not registered or SSM is -unreachable, report that as the blocker rather than guessing at the resolution -path. +Use the tools on the connected `aws-vpc-dns-diagnostics` MCP server. + +## Step 1: Classify the request as Mode A or Mode B + +Before calling any tool, determine which mode applies: + +- **Mode A (live diagnosis):** The operator reports a resolution symptom from a + running instance. They provide an instance ID (or you can identify one). The + goal is to observe what actually resolves and compare resolvers. +- **Mode B (pre-change validation):** The operator asks whether a proposed DNS + change is safe. They provide account, region, VPC, and a change descriptor. + No instance is required. + +If the request is ambiguous, ask the operator to clarify. Do not default to +Mode A when the input lacks an instance ID, and do not default to Mode B when +the operator describes a live symptom. + +## Step 2: Load safety rules + +Regardless of mode, call `get_sop` with slug `A-critical-safety-rules` and +follow every rule it contains. These are non-negotiable constraints on how you +interpret results, handle opaque constructs, and report findings. + +--- + +## Mode A route: live diagnosis + +### Required inputs + +account_id, region, instance_id, and the failing DNS name. + +### Tool sequence (in order) + +1. `dns_probe_context` — establishes VPC-attribute preconditions: enableDnsSupport, + enableDnsHostnames, address family, DHCP option set. A resolution result means + nothing until you know whether the VPC resolver is answering. +2. `dns_probe_compare` — runs the allowlisted probe set inside the instance via + SSM. Returns each resolver's answer and the resolver's own identity from + `hostname.bind`. The VPC DHCP resolver is auto-added for comparison. +3. `get_sop` — load the pattern runbook matching the observed signature + (see trap-to-SOP mapping below). + +### Interpretation rules + +- If `enableDnsSupport` is false: load `A-resolver-disabled-precondition`. The + VPC resolver is intentionally dark and every probe failure follows from that. +- Compare the instance's `/etc/resolv.conf` (from the probe output) against the + DHCP option set. A mismatch means the instance is not using the VPC-intended + resolver. +- Judge answers by name category (load `A-name-category-classification`), not by + whether resolvers agree. Two resolvers returning the same wrong answer is still + a failure. + +### Mode A trap-to-SOP mapping + +| Observed signature | SOP slug | +| --- | --- | +| Custom resolver answers differently from VPC .2 | `A-custom-resolver-divergence` | +| FORWARD rule and PHZ both match the name | `A-forward-vs-phz-precedence-collision` | +| A record works, AAAA fails (or vice versa) | `A-address-family-divergence` | +| enableDnsSupport is false | `A-resolver-disabled-precondition` | +| General live comparison procedure | `A-mode-a-live-resolver-comparison` | + +### Reporting format for Mode A + +Label every finding as **Observed** (ground truth from the probe). State which +resolver answered and what it returned. When Mode A and Mode B produce different +conclusions for the same name, **Mode A wins** because it is ground truth from +inside the subnet. + +--- + +## Mode B route: pre-change validation + +### Required inputs + +account_id, region, vpc_id, and a change descriptor (structured dict with `type` +and type-specific fields). No instance required. + +### Tool sequence (in order) + +1. `dns_simulate_effective_config` — returns the VPC's effective DNS config: the + union of directly attached resources and anything inherited through an + associated Route 53 Profile, each construct tagged by source. +2. `dns_simulate_change` — applies the proposed change symbolically and returns a + per-name impact report (before/after, delta, traps, severity, volume). +3. `get_sop` — load runbooks for any traps reported in the impact table + (see trap-to-SOP mapping below). + +### Interpretation rules + +- Never recommend applying a change without simulating it first. A broad FORWARD + rule, enabling private DNS on an interface endpoint, or a Profile association + can silently redirect names that currently resolve correctly. +- The candidate set is limited to API-derived names (PHZ records, rule domains, + VPCE apexes, Firewall domain lists) or operator-supplied names. It is not + exhaustive. State the coverage boundary. +- If the operator supplies `volumes` (from Resolver Query Logs), names are ranked + by traffic. This is enrichment; absence does not invalidate the simulation. + +### Mode B trap-to-SOP mapping + +| Trap label in impact report | SOP slug | +| --- | --- | +| VPCE-shadow-NXDOMAIN | `B-vpce-shadow-nxdomain` | +| broad-FORWARD-sweep | `B-broad-forward-sweep` | +| flag-AND-mismatch | `B-flag-and-mismatch` | +| DNS-Firewall-block | `B-dns-firewall-block` | +| profile-union-shift | `B-profile-propagation-timing` | +| General pre-change procedure | `B-mode-b-pre-change-validation` | + +### Reporting format for Mode B + +Label every finding as **Predicted** (symbolic, not ground truth). State the +candidate-set size, its source (API-derived or operator-supplied), and that names +outside this set were not evaluated. Include the propagation timing caveat for +Profile changes. + +--- + +## Cross-account opacity + +Call `get_sop` with slug `C-cross-account-opaque-constructs` when the effective +config or impact report contains opaque markers. Cross-account constructs shared +via RAM or a Route 53 Profile may be enumerable but their contents are not +readable from the consumer account. Report them as "present but unknown content" +rather than treating them as absent or inferring past them. + +## Limitations + +Call `get_sop` with slug `C-limitations-and-boundaries` and state the relevant +boundaries to the operator. Key constraints: + +- All tools are read-only. Do not modify, delete, or create DNS resources. +- Mode A requires SSM reachability (ssm, ssmmessages, ec2messages VPC endpoints + and an instance role with AmazonSSMManagedInstanceCore). +- Mode B candidate sets are not exhaustive. The "no impacts" conclusion applies + only within the tested set. +- Opaque constructs cannot be resolved from this account. +- Resolver Query Log ingestion is not implemented; volumes must be supplied by + the operator. + +## Final response requirements + +Every response produced by this skill must include: + +1. Each finding labelled **Observed** (Mode A) or **Predicted** (Mode B). +2. When both modes were used, state "Mode A wins" for any conflict. +3. The candidate-set coverage: how many names, what source, what was not tested. +4. Any opaque constructs and their impact on the conclusion. +5. Recommended next steps or the specific change to apply (never apply it). + +## Prerequisites + +Requires the aws-vpc-dns-diagnostics MCP server registered in the Agent Space +with its tools allowlisted. The server is at `mcp/aws-vpc-dns-diagnostics-mcp/`. +If the server is not registered or SSM is unreachable, report that as the blocker +rather than guessing at the resolution path. From 269315bf671ad07d235329398c014a3c0290c71c Mon Sep 17 00:00:00 2001 From: Dave DeRicco <30156588+ddericco@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:38:44 -0400 Subject: [PATCH 8/9] Paginate all API calls in _build_effective_model and render opaque markers explicitly --- mcp/aws-vpc-dns-diagnostics-mcp/src/server.py | 103 +++++++++++++----- 1 file changed, 76 insertions(+), 27 deletions(-) diff --git a/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py index 89e91fc..574ed2c 100644 --- a/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py +++ b/mcp/aws-vpc-dns-diagnostics-mcp/src/server.py @@ -620,6 +620,25 @@ def dns_probe_compare( ) +def _paginate(method, result_key: str, token_key: str = "NextToken", **kwargs) -> list: + """Exhaust a paginated AWS API call and return the full list of items. + + Most AWS APIs use 'NextToken' in both request and response. VPC Lattice + uses lowercase 'nextToken'. The caller specifies which via token_key. + """ + items: list = [] + token = None + while True: + if token: + kwargs[token_key] = token + resp = method(**kwargs) + items.extend(resp.get(result_key, [])) + token = resp.get(token_key) + if not token: + break + return items + + def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None = None) -> EffectiveModel: """ Build the VPC's effective DNS model from live control-plane reads: the union @@ -645,9 +664,10 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None snva_preference = "VERIFIED_DOMAINS_ONLY" specified_domains: tuple[str, ...] = () try: - assocs = lattice.list_service_network_vpc_associations( - vpcIdentifier=vpc_id - ).get("items", []) + assocs = _paginate( + lattice.list_service_network_vpc_associations, + "items", token_key="nextToken", vpcIdentifier=vpc_id, + ) # Prefer an association that actually enables private DNS; else the first. chosen = next((a for a in assocs if a.get("privateDnsEnabled")), assocs[0] if assocs else None) if chosen: @@ -664,9 +684,11 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None vpces: list[Vpce] = [] # --- directly-attached Resolver rules --- - for assoc in r53r.list_resolver_rule_associations( - Filters=[{"Name": "VPCId", "Values": [vpc_id]}] - ).get("ResolverRuleAssociations", []): + for assoc in _paginate( + r53r.list_resolver_rule_associations, + "ResolverRuleAssociations", + Filters=[{"Name": "VPCId", "Values": [vpc_id]}], + ): rid = assoc["ResolverRuleId"] try: rule = r53r.get_resolver_rule(ResolverRuleId=rid)["ResolverRule"] @@ -682,12 +704,17 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None domain="", rule_type="FORWARD", target="", source="direct", opaque=True)) # --- directly-attached DNS Firewall rule groups --- - for fga in r53r.list_firewall_rule_group_associations( - VpcId=vpc_id - ).get("FirewallRuleGroupAssociations", []): + for fga in _paginate( + r53r.list_firewall_rule_group_associations, + "FirewallRuleGroupAssociations", + VpcId=vpc_id, + ): fgid = fga["FirewallRuleGroupId"] try: - frules = r53r.list_firewall_rules(FirewallRuleGroupId=fgid).get("FirewallRules", []) + frules = _paginate( + r53r.list_firewall_rules, "FirewallRules", + FirewallRuleGroupId=fgid, + ) except Exception: # Rule group associated but not readable (cross-account share) -> # record an opaque BLOCK marker so it is not silently dropped. @@ -697,9 +724,10 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None continue for fr in frules: try: - dl = r53r.list_firewall_domains( - FirewallDomainListId=fr["FirewallDomainListId"] - ).get("Domains", []) + dl = _paginate( + r53r.list_firewall_domains, "Domains", + FirewallDomainListId=fr["FirewallDomainListId"], + ) opaque = False except Exception: # Domain list not readable from this account (RAM-shared group / @@ -715,9 +743,10 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None )) # --- associated PHZs --- - for hz in r53.list_hosted_zones_by_vpc( - VPCId=vpc_id, VPCRegion=session.region_name - ).get("HostedZoneSummaries", []): + for hz in _paginate( + r53.list_hosted_zones_by_vpc, "HostedZoneSummaries", + VPCId=vpc_id, VPCRegion=session.region_name, + ): phzs.append(Phz(zone=hz["Name"], source="direct")) # --- VPC endpoints: every DNS shadow the CONSUMER VPC sees, derived purely @@ -729,9 +758,10 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None # This does NOT read resource configurations or gateways - those are # provider-only constructs a consumer account cannot enumerate (a # resource config may be RAM-shared and its gateway invisible here). - for ep in ec2.describe_vpc_endpoints( - Filters=[{"Name": "vpc-id", "Values": [vpc_id]}] - ).get("VpcEndpoints", []): + for ep in _paginate( + ec2.describe_vpc_endpoints, "VpcEndpoints", + Filters=[{"Name": "vpc-id", "Values": [vpc_id]}], + ): etype = ep.get("VpcEndpointType", "") private = ep.get("PrivateDnsEnabled", False) if etype == "Interface": @@ -780,7 +810,9 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None # --- Route 53 Profiles inherited resources (the union) --- try: - profile_assocs = r53p.list_profile_associations().get("ProfileAssociations", []) + profile_assocs = _paginate( + r53p.list_profile_associations, "ProfileAssociations", + ) except Exception: profile_assocs = [] for pa in profile_assocs: @@ -789,7 +821,10 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None pid = pa["ProfileId"] src = f"profile:{pid}" try: - pras = r53p.list_profile_resource_associations(ProfileId=pid).get("ProfileResourceAssociations", []) + pras = _paginate( + r53p.list_profile_resource_associations, "ProfileResourceAssociations", + ProfileId=pid, + ) except Exception: pras = [] for pr in pras: @@ -819,7 +854,10 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None domain="", rule_type="FORWARD", target="", source=src, opaque=True)) elif rtype == "FirewallRuleGroup": try: - frules = r53r.list_firewall_rules(FirewallRuleGroupId=pr["ResourceId"]).get("FirewallRules", []) + frules = _paginate( + r53r.list_firewall_rules, "FirewallRules", + FirewallRuleGroupId=pr["ResourceId"], + ) except Exception: firewall_rules.append(FirewallRule( domains=(), action="BLOCK", block_response="NXDOMAIN", @@ -827,9 +865,10 @@ def _build_effective_model(session, vpc_id: str, onprem_zones: list[str] | None continue for fr in frules: try: - dl = r53r.list_firewall_domains( - FirewallDomainListId=fr["FirewallDomainListId"] - ).get("Domains", []) + dl = _paginate( + r53r.list_firewall_domains, "Domains", + FirewallDomainListId=fr["FirewallDomainListId"], + ) opaque = False except Exception: dl, opaque = [], True @@ -898,14 +937,24 @@ def dns_simulate_effective_config( def _rows(items, fmt): return "\n".join(fmt(i) for i in items) if items else "_(none)_" + def _rule_row(r): + if r.opaque: + return f"- [OPAQUE] {r.rule_type} rule, domain/target unknown [{r.source}]" + return f"- `{r.domain}` {r.rule_type} -> {r.target} [{r.source}]" + + def _fw_row(f): + if f.opaque: + return f"- [OPAQUE] {f.action}/{f.block_response} p{f.priority}, domain list unknown [{f.source}]" + return f"- {f.action}/{f.block_response} p{f.priority} on {len(f.domains)} domains [{f.source}]" + return ( f"**Effective DNS config for {vpc_id}** ({account_id}/{region})\n\n" f"enableDnsSupport: {m.dns_support} | SNVA preference: {m.snva_preference}" f"{(' | specified domains: ' + ', '.join(m.specified_domains)) if m.specified_domains else ''}\n\n" f"**Resolver rules** ({len(m.resolver_rules)}):\n" - f"{_rows(m.resolver_rules, lambda r: f'- `{r.domain}` {r.rule_type} -> {r.target} [{r.source}]')}\n\n" + f"{_rows(m.resolver_rules, _rule_row)}\n\n" f"**DNS Firewall rules** ({len(m.firewall_rules)}):\n" - f"{_rows(m.firewall_rules, lambda f: f'- {f.action}/{f.block_response} p{f.priority} on {len(f.domains)} domains [{f.source}]')}\n\n" + f"{_rows(m.firewall_rules, _fw_row)}\n\n" f"**PHZ associations** ({len(m.phzs)}):\n" f"{_rows(m.phzs, lambda p: f'- `{p.zone}` [{p.source}]')}\n\n" f"**Interface VPCEs** ({len(m.vpces)}):\n" From c3da022600ee01fbc432da471568abf3190de201 Mon Sep 17 00:00:00 2001 From: Dave DeRicco <30156588+ddericco@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:15:29 -0400 Subject: [PATCH 9/9] Add negative eval triggers and update trigger report (14/14 passed) --- .../evals/eval_queries.json | 24 ++++ .../evals/trigger_report.json | 120 ++++++++++++++---- 2 files changed, 117 insertions(+), 27 deletions(-) diff --git a/skills/aws-vpc-dns-investigation/evals/eval_queries.json b/skills/aws-vpc-dns-investigation/evals/eval_queries.json index de2c09e..5c9067a 100644 --- a/skills/aws-vpc-dns-investigation/evals/eval_queries.json +++ b/skills/aws-vpc-dns-investigation/evals/eval_queries.json @@ -30,5 +30,29 @@ { "query": "Explain the difference between an IAM role and an IAM user.", "should_trigger": false + }, + { + "query": "My EC2 instance in a private subnet cannot reach the internet. It has no public IP and no NAT gateway. How do I fix this?", + "should_trigger": false + }, + { + "query": "Check the NS and SOA records for example.com in my Route 53 public hosted zone to verify delegation is correct.", + "should_trigger": false + }, + { + "query": "I want to transfer my domain registration from GoDaddy to Route 53. What are the steps?", + "should_trigger": false + }, + { + "query": "Configure DNS failover routing for my CloudFront distribution using Route 53 health checks.", + "should_trigger": false + }, + { + "query": "My Lambda function is timing out when calling an external API. How do I troubleshoot the network path?", + "should_trigger": false + }, + { + "query": "Set up GeoDNS with Route 53 geolocation routing policies to serve different content by region.", + "should_trigger": false } ] diff --git a/skills/aws-vpc-dns-investigation/evals/trigger_report.json b/skills/aws-vpc-dns-investigation/evals/trigger_report.json index 420f271..bc9868c 100644 --- a/skills/aws-vpc-dns-investigation/evals/trigger_report.json +++ b/skills/aws-vpc-dns-investigation/evals/trigger_report.json @@ -10,8 +10,8 @@ "trigger_rate": 1.0, "passed": true, "mean_input_tokens": 2.0, - "mean_output_tokens": 105.0, - "mean_total_tokens": 107.0 + "mean_output_tokens": 65.0, + "mean_total_tokens": 67.0 }, { "query": "Which skill would help me check whether enabling private DNS on an interface endpoint would break resolution? Just name it; do not run it.", @@ -21,8 +21,8 @@ "trigger_rate": 1.0, "passed": true, "mean_input_tokens": 2.0, - "mean_output_tokens": 21.0, - "mean_total_tokens": 23.0 + "mean_output_tokens": 82.0, + "mean_total_tokens": 84.0 }, { "query": "How do I set up an S3 bucket lifecycle policy?", @@ -32,8 +32,8 @@ "trigger_rate": 0.0, "passed": true, "mean_input_tokens": 6.0, - "mean_output_tokens": 3545.0, - "mean_total_tokens": 3551.0 + "mean_output_tokens": 3825.0, + "mean_total_tokens": 3831.0 }, { "query": "What are the best practices for DynamoDB table design?", @@ -42,9 +42,9 @@ "run_count": 1, "trigger_rate": 0.0, "passed": true, - "mean_input_tokens": 4.0, - "mean_output_tokens": 2457.0, - "mean_total_tokens": 2461.0 + "mean_input_tokens": 6.0, + "mean_output_tokens": 2941.0, + "mean_total_tokens": 2947.0 }, { "query": "Write a Terraform module for an Application Load Balancer.", @@ -53,9 +53,9 @@ "run_count": 1, "trigger_rate": 0.0, "passed": true, - "mean_input_tokens": 23.0, - "mean_output_tokens": 24870.0, - "mean_total_tokens": 24893.0 + "mean_input_tokens": 0.0, + "mean_output_tokens": 0.0, + "mean_total_tokens": 0.0 }, { "query": "How do I register a public domain name and create a public hosted zone in Route 53?", @@ -65,8 +65,8 @@ "trigger_rate": 0.0, "passed": true, "mean_input_tokens": 4.0, - "mean_output_tokens": 3365.0, - "mean_total_tokens": 3369.0 + "mean_output_tokens": 4342.0, + "mean_total_tokens": 4346.0 }, { "query": "My RDS instance is running out of storage. How do I scale it?", @@ -75,9 +75,9 @@ "run_count": 1, "trigger_rate": 0.0, "passed": true, - "mean_input_tokens": 2.0, - "mean_output_tokens": 809.0, - "mean_total_tokens": 811.0 + "mean_input_tokens": 4.0, + "mean_output_tokens": 2717.0, + "mean_total_tokens": 2721.0 }, { "query": "Explain the difference between an IAM role and an IAM user.", @@ -86,28 +86,94 @@ "run_count": 1, "trigger_rate": 0.0, "passed": true, + "mean_input_tokens": 2.0, + "mean_output_tokens": 904.0, + "mean_total_tokens": 906.0 + }, + { + "query": "My EC2 instance in a private subnet cannot reach the internet. It has no public IP and no NAT gateway. How do I fix this?", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 4.0, + "mean_output_tokens": 2539.0, + "mean_total_tokens": 2543.0 + }, + { + "query": "Check the NS and SOA records for example.com in my Route 53 public hosted zone to verify delegation is correct.", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 0.0, + "mean_output_tokens": 0.0, + "mean_total_tokens": 0.0 + }, + { + "query": "I want to transfer my domain registration from GoDaddy to Route 53. What are the steps?", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 2.0, + "mean_output_tokens": 2326.0, + "mean_total_tokens": 2328.0 + }, + { + "query": "Configure DNS failover routing for my CloudFront distribution using Route 53 health checks.", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 0.0, + "mean_output_tokens": 0.0, + "mean_total_tokens": 0.0 + }, + { + "query": "My Lambda function is timing out when calling an external API. How do I troubleshoot the network path?", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, "mean_input_tokens": 4.0, - "mean_output_tokens": 1703.0, - "mean_total_tokens": 1707.0 + "mean_output_tokens": 4401.0, + "mean_total_tokens": 4405.0 + }, + { + "query": "Set up GeoDNS with Route 53 geolocation routing policies to serve different content by region.", + "should_trigger": false, + "trigger_count": 0, + "run_count": 1, + "trigger_rate": 0.0, + "passed": true, + "mean_input_tokens": 2.0, + "mean_output_tokens": 4232.0, + "mean_total_tokens": 4234.0 } ], "summary": { - "total_queries": 8, - "passed": 8, + "total_queries": 14, + "passed": 14, "failed": 0, "trigger_precision": 1.0, "no_trigger_precision": 1.0, - "mean_total_tokens_per_run": 4615.2, + "mean_total_tokens_per_run": 2029.4, "estimated_cost": { "per_run": { - "input_cost": 1.8e-05, - "output_cost": 0.069141, - "total_cost": 0.069158, + "input_cost": 8e-06, + "output_cost": 0.030401, + "total_cost": 0.030409, "model": "sonnet", "currency": "USD" }, - "total_runs": 8, - "total_cost": 0.5533, + "total_runs": 14, + "total_cost": 0.4257, "model": "sonnet", "currency": "USD" }