-
Notifications
You must be signed in to change notification settings - Fork 39
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2613 from Slenderman00/masteruninett
Palo Alto ARP table plugin
- Loading branch information
Showing
7 changed files
with
335 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
New ipdevpoll plugin to fetch ARP cache data from Palo Alto firewall APIs |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
# | ||
# Copyright (C) 2023, 2024 University of Tromsø | ||
# Copyright (C) 2024 Sikt | ||
# | ||
# This file is part of Network Administration Visualized (NAV). | ||
# | ||
# NAV is free software: you can redistribute it and/or modify it under the | ||
# terms of the GNU General Public License version 3 as published by the Free | ||
# Software Foundation. | ||
# | ||
# This program is distributed in the hope that it will be useful, but WITHOUT | ||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or | ||
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for | ||
# more details. You should have received a copy of the GNU General Public | ||
# License along with NAV. If not, see <http://www.gnu.org/licenses/>. | ||
# | ||
|
||
"""ipdevpoll plugin for fetching arp mappings from Palo Alto firewalls | ||
Add [paloaltoarp] section to ipdevpoll.conf | ||
add hostname = key to [paloaltoarp] section | ||
for example: | ||
[paloaltoarp] | ||
10.0.0.0 = abcdefghijklmnopqrstuvwxyz1234567890 | ||
""" | ||
|
||
import xml.etree.ElementTree as ET | ||
from typing import Dict | ||
|
||
from IPy import IP | ||
from twisted.internet import defer, reactor, ssl | ||
from twisted.internet.defer import returnValue | ||
from twisted.web import client | ||
from twisted.web.client import Agent | ||
from twisted.web.http_headers import Headers | ||
|
||
from nav import buildconf | ||
from nav.ipdevpoll.plugins.arp import Arp | ||
|
||
|
||
class PaloaltoArp(Arp): | ||
configured_devices: Dict[str, str] = {} | ||
|
||
@classmethod | ||
def on_plugin_load(cls): | ||
"""Loads the list of PaloAlto access keys from ipdevpoll.conf into the plugin | ||
class instance, so that `can_handle` will be able to answer which devices | ||
this plugin can run for. | ||
""" | ||
from nav.ipdevpoll.config import ipdevpoll_conf | ||
|
||
cls._logger.debug("loading paloaltoarp configuration") | ||
if 'paloaltoarp' not in ipdevpoll_conf: | ||
cls._logger.debug("PaloaltoArp config section NOT found") | ||
return | ||
cls._logger.debug("PaloaltoArp config section found") | ||
cls.configured_devices = dict(ipdevpoll_conf['paloaltoarp']) | ||
|
||
@classmethod | ||
def can_handle(cls, netbox): | ||
"""Return True if this plugin can handle the given netbox.""" | ||
return ( | ||
netbox.sysname in cls.configured_devices | ||
or str(netbox.ip) in cls.configured_devices | ||
) | ||
|
||
@defer.inlineCallbacks | ||
def handle(self): | ||
"""Handle plugin business, return a deferred.""" | ||
|
||
api_key = self.configured_devices.get( | ||
str(self.netbox.ip), self.configured_devices.get(self.netbox.sysname, "") | ||
) | ||
self._logger.debug("Collecting IP/MAC mappings for Paloalto device") | ||
|
||
mappings = yield self._get_paloalto_arp_mappings(self.netbox.ip, api_key) | ||
if mappings is None: | ||
self._logger.info("No mappings found for Paloalto device") | ||
returnValue(None) | ||
|
||
yield self._process_data(mappings) | ||
|
||
returnValue(None) | ||
|
||
@defer.inlineCallbacks | ||
def _get_paloalto_arp_mappings(self, address: str, key: str): | ||
"""Get mappings from Paloalto device""" | ||
|
||
arptable = yield self._do_request(address, key) | ||
if arptable is None: | ||
returnValue(None) | ||
|
||
# process arpdata into an array of mappings | ||
mappings = parse_arp(arptable.decode('utf-8')) | ||
returnValue(mappings) | ||
|
||
@defer.inlineCallbacks | ||
def _do_request(self, address: str, key: str): | ||
"""Make request to Paloalto device""" | ||
|
||
class SslPolicy(client.BrowserLikePolicyForHTTPS): | ||
def creatorForNetloc(self, hostname, port): | ||
return ssl.CertificateOptions(verify=False) | ||
|
||
url = f"https://{address}/api/?type=op&cmd=<show><arp><entry+name+=+'all'/></arp></show>&key={key}" | ||
self._logger.debug("making request: %s", url) | ||
|
||
agent = Agent(reactor, contextFactory=SslPolicy()) | ||
|
||
try: | ||
response = yield agent.request( | ||
b'GET', | ||
url.encode('utf-8'), | ||
Headers( | ||
{'User-Agent': [f'NAV/PaloaltoArp; version {buildconf.VERSION}']} | ||
), | ||
None, | ||
) | ||
except Exception: # noqa | ||
self._logger.exception( | ||
"Error when talking to PaloAlto API. " | ||
"Make sure the device is reachable and the API key is correct." | ||
) | ||
returnValue(None) | ||
|
||
response = yield client.readBody(response) | ||
returnValue(response) | ||
|
||
|
||
def parse_arp(arp): | ||
""" | ||
Create mappings from arp table | ||
xml.etree.ElementTree is considered insecure: https://docs.python.org/3/library/xml.html#xml-vulnerabilities | ||
However, since we are not parsing untrusted data, this should not be a problem. | ||
""" | ||
|
||
arps = [] | ||
|
||
root = ET.fromstring(arp) | ||
entries = root[0][4] | ||
for entry in entries: | ||
status = entry[0].text | ||
ip = entry[1].text | ||
mac = entry[2].text | ||
if status.strip() != "i": | ||
if mac != "(incomplete)": | ||
arps.append(('ifindex', IP(ip), mac)) | ||
|
||
return arps |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -39,3 +39,12 @@ backports.zoneinfo ; python_version < '3.9' | |
importlib_metadata ; python_version < '3.8' | ||
importlib_resources ; python_version < '3.9' | ||
git+https://github.com/Uninett/[email protected]#egg=drf-oidc-auth | ||
|
||
# The following modules are really sub-requirements of Twisted, not of | ||
# NAV directly. They may be optional from Twisted's point of view, | ||
# but they are required for parts of the Twisted library that NAV uses: | ||
# | ||
# PyOpenSSL is required for TLS verification during PaloAlto API GET operations | ||
PyOpenSSL==23.3.0 | ||
# service-identity is required to make TLS communication libraries shut up about potential MITM attacks | ||
service-identity==21.1.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
from unittest.mock import patch, Mock | ||
|
||
from IPy import IP | ||
from nav.ipdevpoll.plugins.paloaltoarp import PaloaltoArp, parse_arp | ||
from twisted.internet import defer | ||
from twisted.internet.defer import inlineCallbacks, succeed | ||
from twisted.web.client import Agent, Response | ||
|
||
mock_data = b''' | ||
<response status="success"> | ||
<result> | ||
<max>132000</max> | ||
<total>3</total> | ||
<timeout>1800</timeout> | ||
<dp>s3dp1</dp> | ||
<entries> | ||
<entry> | ||
<status> s </status> | ||
<ip>192.168.0.1</ip> | ||
<mac>00:00:00:00:00:01</mac> | ||
<ttl>100</ttl> | ||
<interface>ae2</interface> | ||
<port>ae2</port> | ||
</entry> | ||
<entry> | ||
<status> e </status> | ||
<ip>192.168.0.2</ip> | ||
<mac>00:00:00:00:00:02</mac> | ||
<ttl>200</ttl> | ||
<interface>ae2</interface> | ||
<port>ae2</port> | ||
</entry> | ||
<entry> | ||
<status> c </status> | ||
<ip>192.168.0.3</ip> | ||
<mac>00:00:00:00:00:03</mac> | ||
<ttl>300</ttl> | ||
<interface>ae3.61</interface> | ||
<port>ae3</port> | ||
</entry> | ||
<entry> | ||
<status> i </status> | ||
<ip>192.168.0.4</ip> | ||
<mac>00:00:00:00:00:04</mac> | ||
<ttl>400</ttl> | ||
<interface>ae3.61</interface> | ||
<port>ae3</port> | ||
</entry> | ||
</entries> | ||
</result> | ||
</response> | ||
''' | ||
|
||
|
||
def test_parse_mappings(): | ||
assert parse_arp(mock_data) == [ | ||
('ifindex', IP('192.168.0.1'), '00:00:00:00:00:01'), | ||
('ifindex', IP('192.168.0.2'), '00:00:00:00:00:02'), | ||
('ifindex', IP('192.168.0.3'), '00:00:00:00:00:03'), | ||
] | ||
|
||
|
||
@inlineCallbacks | ||
def test_get_mappings(): | ||
# Mocking the __init__ method | ||
with patch.object(PaloaltoArp, "__init__", lambda x: None): | ||
instance = PaloaltoArp() | ||
instance.config = {'paloaltoarp': {'abcdefghijklmnop': '0.0.0.0'}} | ||
|
||
# Mocking _do_request to return the mock_data when called | ||
with patch.object( | ||
PaloaltoArp, "_do_request", return_value=defer.succeed(mock_data) | ||
): | ||
mappings = yield instance._get_paloalto_arp_mappings( | ||
"0.0.0.0", "abcdefghijklmnop" | ||
) | ||
|
||
assert mappings == [ | ||
('ifindex', IP('192.168.0.1'), '00:00:00:00:00:01'), | ||
('ifindex', IP('192.168.0.2'), '00:00:00:00:00:02'), | ||
('ifindex', IP('192.168.0.3'), '00:00:00:00:00:03'), | ||
] | ||
|
||
|
||
@inlineCallbacks | ||
def test_do_request(): | ||
mock_response = Mock(spec=Response) | ||
mock_agent = Mock(spec=Agent) | ||
mock_agent.request.return_value = succeed(mock_response) | ||
|
||
with patch( | ||
'nav.ipdevpoll.plugins.paloaltoarp.Agent', return_value=mock_agent | ||
), patch('twisted.web.client.readBody', return_value="test content"): | ||
mock_address = "paloalto.example.org" | ||
mock_key = "secret" | ||
|
||
mock_netbox = Mock(sysname=mock_address, ip="127.0.0.1") | ||
|
||
plugin = PaloaltoArp(netbox=mock_netbox, agent=Mock(), containers=Mock()) | ||
result = yield plugin._do_request(mock_address, mock_key) | ||
|
||
expected_url = f"https://{mock_address}/api/?type=op&cmd=<show><arp><entry+name+=+'all'/></arp></show>&key={mock_key}".encode( | ||
"utf-8" | ||
) | ||
mock_agent.request.assert_called() | ||
args, kwargs = mock_agent.request.call_args | ||
assert expected_url in args | ||
|
||
assert result == "test content" |