{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Provably safe ACL and firewall rule changes" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Changing ACLs or firewall rules is one of the riskiest updates to a network. Even a small error can block connectivity for a large set of critical services or open up sensitive resources to the world at large. \n", "\n", "This notebook shows a 3-step process that uses Batfish to make provably safe and correct changes to ACLs and firewall rules, which we generally call filters. For a broader view of Batfish's support for analyzing filters, check out the [\"Analyzing ACLs and Firewall Rules\" notebook](https://github.com/batfish/pybatfish/blob/master/jupyter_notebooks/Analyzing%20ACLs%20and%20Firewall%20Rules.ipynb).\n", "\n", "Check out a video demo of this notebook [here](https://www.youtube.com/watch?v=MJYLVL9UOWk).\n", "\n", "We will primarily use the `searchFilters` question of Batfish in this process. This question searches within large spaces of flows (specified using packet headers) for flows that match the specified action ('permit' or 'deny'). See [here](https://pybatfish.readthedocs.io/en/latest/notebooks/filters.html#Search-Filters) for its documentation." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Change scenario" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Our goal is to update an ACL on one of our routers to permit HTTP traffic (ports 80 and 8080) from one subnet (10.10.10.0/24) to another (18.18.18.0/27). We will implement this by adding rules to permit this traffic to our ACLs, and we will then use Batfish to check if the implementation was correct." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Initialization" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We start by initializing the pre-change snapshot and variables that describe the change. Our example snapshot contains two devices, and we'll change the ACL **acl_in** on [**rtr-with-acl**](https://github.com/batfish/pybatfish/blob/master/jupyter_notebooks/networks/example-filters/current/configs/rtr-with-acl.cfg). " ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "'current'" ] }, "execution_count": 1, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Import packages\n", "%run startup.py \n", "bf = Session(host=\"localhost\")\n", "\n", "# Initialize a network and snapshot\n", "CURRENT_SNAPSHOT_NAME = \"current\"\n", "CURRENT_SNAPSHOT_PATH = \"networks/example-filters/current\"\n", "bf.set_network(\"network-example-filters\")\n", "bf.init_snapshot(CURRENT_SNAPSHOT_PATH, name=CURRENT_SNAPSHOT_NAME, overwrite=True)" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "node_name = \"rtr-with-acl\" # The router to change\n", "filter_name = \"acl_in\" # Name of the ACL to change\n", "\n", "# The traffic to allow\n", "change_traffic = HeaderConstraints(srcIps=\"10.10.10.0/24\",\n", " dstIps=\"18.18.18.0/27\",\n", " ipProtocols=[\"tcp\"],\n", " dstPorts=\"80, 8080\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Step 1: Ensure that the intended traffic is not already permitted" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Before we make the change to allow the intended traffic, we verify that that traffic is not already permitted — because if it is, we do not need to change anything. We accomplish this using the `searchFilters` question. Given a space of flows, specified using header fields such as source and destination addresses and ports, and a matching condition (e.g., permit, deny) as input, this question finds flows that satisfy the condition. If it reports no flows, then it is guaranteed that no flow within the space satisfies the condition. " ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
NodeFilter_NameFlowActionLine_ContentTrace
\n", "
" ], "text/plain": [ "Empty DataFrame\n", "Columns: [Node, Filter_Name, Flow, Action, Line_Content, Trace]\n", "Index: []" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Check if the intended traffic is already permitted in the current snapshot\n", "answer = bf.q.searchFilters(headers=change_traffic,\n", " filters=filter_name,\n", " nodes=node_name,\n", " action=\"permit\").answer(\n", " snapshot=CURRENT_SNAPSHOT_NAME)\n", "show(answer.frame())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Since the query above did not find any results, we know with certainty that no flow within the specified space is already permitted. We can now proceed. If some flow is returned as part of the query, we may want to delete the filter line(s) that permits that flow before we update the filter." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Step 2: Ensure that the intended traffic is permitted in the candidate change" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Assume that we implemented a candidate change, shown as the diff below.\n", "```\n", "diff -r networks/example-filters/current/configs/rtr-with-acl.cfg \\ \n", " networks/example-filters/candidate1/configs/rtr-with-acl.cfg\n", "39a40,41\n", "> 462 permit tcp 10.10.10.0/24 18.18.18.0/26 eq 80 \n", "> 463 permit tcp 10.10.10.0/24 18.18.18.0/26 eq 8080 \n", "```\n", "\n", "We can load the snapshot with this change into Batfish and ensure that all flows within the intended traffic are permitted. We will do that by asking the same `searchFilters` question as before, except now searching for flows that are denied instead of permitted. If it produces no results, then we have the guarantee that all possible flows in the intended space are allowed." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
NodeFilter_NameFlowActionLine_ContentTrace
\n", "
" ], "text/plain": [ "Empty DataFrame\n", "Columns: [Node, Filter_Name, Flow, Action, Line_Content, Trace]\n", "Index: []" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Load the candidate1 change\n", "CANDIDATE1_SNAPSHOT_NAME = \"candidate1\"\n", "CANDIDATE1_SNAPSHOT_PATH = \"networks/example-filters/candidate1\"\n", "bf.init_snapshot(CANDIDATE1_SNAPSHOT_PATH, name=CANDIDATE1_SNAPSHOT_NAME, overwrite=True)\n", "\n", "# Check if any flow in the intended traffic is denied in candidate1\n", "answer = bf.q.searchFilters(headers=change_traffic,\n", " filters=filter_name,\n", " nodes=node_name,\n", " action=\"deny\").answer(\n", " snapshot=CANDIDATE1_SNAPSHOT_NAME)\n", "show(answer.frame())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Since we got no results, we can be confident that our candidate change permits *all* traffic that we intended to permit. If there were any flow in the desired space that was not permitted by the change, the query above would have found it." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Step 3: Ensure that no collateral damage has occurred" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Typically, engineers will stop change validation after checking that the intended traffic has been successfully permitted by the change. However, for safety and correctness, we must also check that no traffic outside of the intended space has been impacted — that is, our change has not caused collateral damage.\n", "\n", "We can verify this using a \"differential\" version of the `searchFilters` question that compares two snapshots. The query below compares the candidate1 and initial snapshots, and is asking Batfish if there is *any* flow outside of the intended traffic that the two snapshots treat differently (i.e., one of them permits and the other rejects, or vice versa). To search traffic outside the specified flow space, we use the `invertSearch` flag. If this query returns no result, then combined with the result above, we have ensured that the change is completely correct." ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "data": { "text/html": [ "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
 NodeFilter_NameFlowKeyPresenceSnapshot_ActionReference_ActionSnapshot_Line_ContentReference_Line_ContentSnapshot_TraceReference_Trace
0rtr-with-aclacl_inStart Location: rtr-with-acl
Src IP: 10.10.10.0
Src Port: 49152
Dst IP: 18.18.18.32
Dst Port: 80
IP Protocol: TCP (SYN)
In bothPERMITDENY462 permit tcp 10.10.10.0/24 18.18.18.0/26 eq 802020 deny tcp any any
  • Matched line 462 permit tcp 10.10.10.0/24 18.18.18.0/26 eq 80
  • Matched line 2020 deny tcp any any
\n" ], "text/plain": [ " Node Filter_Name \\\n", "0 rtr-with-acl acl_in \n", "\n", " Flow \\\n", "0 start=rtr-with-acl [10.10.10.0:49152->18.18.18.32:80 TCP (SYN)] \n", "\n", " KeyPresence Snapshot_Action Reference_Action \\\n", "0 In both PERMIT DENY \n", "\n", " Snapshot_Line_Content Reference_Line_Content \\\n", "0 462 permit tcp 10.10.10.0/24 18.18.18.0/26 eq 80 2020 deny tcp any any \n", "\n", " Snapshot_Trace \\\n", "0 - Matched line 462 permit tcp 10.10.10.0/24 18.18.18.0/26 eq 80 \n", "\n", " Reference_Trace \n", "0 - Matched line 2020 deny tcp any any " ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Check if traffic other than the intended traffic has been impacted\n", "answer = bf.q.searchFilters(headers=change_traffic,\n", " invertSearch=True,\n", " filters=filter_name,\n", " nodes=node_name).answer(snapshot=CANDIDATE1_SNAPSHOT_NAME,\n", " reference_snapshot=CURRENT_SNAPSHOT_NAME)\n", "show(answer.frame())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Unfortunately, we do get a result, indicating that at least one flow outside of the intended space will be treated differently than before. The column `Flow` shows a flow that the two snapshots treat differently. In particular, this flow has destination IP address 18.18.18.32, which is *outside* of the address range 18.18.18.0/27 that we wanted to permit. The columns that start with `Base_` show how `CANDIDATE1_SNAPSHOT` treats that flow, and those that start with `Delta_` show how `CURRENT_SNAPSHOT` treats the flow. As shown, the candidate snapshot permits the flow while the current snapshot denies it. That means we've accidentally opened up more space than we intended.\n", "\n", "The root cause of the problem is apparent if we look at the diff above more carefully. The updated ACL permits destination prefix 18.18.18.0/26 rather than the intended 18.18.18.0/27. We need to fix this." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Step 2 (again): Ensure that the intended traffic is permitted in the candidate change" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Assume that we implemented another candidate change, shown by the diff below.\n", "```\n", "diff -r networks/example-filters/current/configs/rtr-with-acl.cfg \\ \n", " networks/example-filters/candidate2/configs/rtr-with-acl.cfg\n", "39a40,41\n", "> 462 permit tcp 10.10.10.0/24 18.18.18.0/27 eq 80 \n", "> 463 permit tcp 10.10.10.0/24 18.18.18.0/27 eq 8080 \n", "```\n", "\n", "We will now load this change and repeat the same validation steps that we ran on the prior candidate change." ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
NodeFilter_NameFlowActionLine_ContentTrace
\n", "
" ], "text/plain": [ "Empty DataFrame\n", "Columns: [Node, Filter_Name, Flow, Action, Line_Content, Trace]\n", "Index: []" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Load (another) candidate change\n", "CANDIDATE2_SNAPSHOT_NAME = \"candidate2\"\n", "CANDIDATE2_SNAPSHOT_PATH = \"networks/example-filters/candidate2\"\n", "bf.init_snapshot(CANDIDATE2_SNAPSHOT_PATH, name=CANDIDATE2_SNAPSHOT_NAME, overwrite=True)\n", "\n", "# Check if any part of the intended traffic is denied in candidate2\n", "answer = bf.q.searchFilters(headers=change_traffic,\n", " filters=filter_name,\n", " nodes=node_name,\n", " action=\"deny\").answer(snapshot=CANDIDATE2_SNAPSHOT_NAME)\n", "show(answer.frame())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "As before, we get no results, which means that no flow in the intended space is being denied; we correctly permitted all intended traffic. " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Step 3 (again): Ensure that no collateral damage has occurred" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now, let's also check again that no other traffic is impacted." ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
NodeFilter_NameFlowKeyPresenceSnapshot_ActionReference_ActionSnapshot_Line_ContentReference_Line_ContentSnapshot_TraceReference_Trace
\n", "
" ], "text/plain": [ "Empty DataFrame\n", "Columns: [Node, Filter_Name, Flow, KeyPresence, Snapshot_Action, Reference_Action, Snapshot_Line_Content, Reference_Line_Content, Snapshot_Trace, Reference_Trace]\n", "Index: []" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# Check if traffic other than the intended traffic has been impacted\n", "answer = bf.q.searchFilters(headers=change_traffic,\n", " filters=filter_name,\n", " nodes=node_name,\n", " invertSearch=True).answer(snapshot=CANDIDATE2_SNAPSHOT_NAME,\n", " reference_snapshot=CURRENT_SNAPSHOT_NAME)\n", "show(answer.frame())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "This time, we got no collateral damage results! That implies this change is completely correct: It allows all traffic that we meant to allow and has no impact on other traffic. Therefore we can apply it with full confidence that it will have the exact desired behavior." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Summary" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "In this notebook, we showed how you can use Batfish to ensure that changes to filters are correct and permit or deny only the intended traffic. " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The steps for provably safe ACL and firewall changes are:\n", "1. Check that the intended traffic does not already match the desired action (permit or deny)\n", "2. Check that the intended traffic is treated correctly in the candidate change\n", "3. Check that nothing but the intended traffic is impacted by the candidate change" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "For additional ways to analyze filter using Batfish, see the [\"Analyzing ACLs and Firewall Rules\" notebook](https://github.com/batfish/pybatfish/blob/master/jupyter_notebooks/Analyzing%20ACLs%20and%20Firewall%20Rules.ipynb)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "***\n", "### Get involved with the Batfish community\n", "\n", "Join our community on [Slack](https://join.slack.com/t/batfish-org/shared_invite/enQtMzA0Nzg2OTAzNzQ1LTcyYzY3M2Q0NWUyYTRhYjdlM2IzYzRhZGU1NWFlNGU2MzlhNDY3OTJmMDIyMjQzYmRlNjhkMTRjNWIwNTUwNTQ) and [GitHub](https://github.com/batfish/batfish). " ] } ], "metadata": { "hide_input": false, "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.9.15" } }, "nbformat": 4, "nbformat_minor": 1 }