Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Palo Alto ARP table plugin #2613

Merged
merged 30 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
769e96d
Implemented paloaltoarp plugin
Slenderman00 Apr 27, 2023
a779f85
Added utf-8 encoding tag
Slenderman00 Apr 27, 2023
4c09030
Removed unused variables from the parse_arp function
Slenderman00 Apr 27, 2023
857efb9
Removed unnecessary logging
Slenderman00 Apr 27, 2023
a56f641
can_handle only returns true on netboxes in config
Slenderman00 May 2, 2023
4b46355
Updated xml disclaimer
Slenderman00 May 2, 2023
a8ddd84
Updated user-agent
Slenderman00 May 2, 2023
5d05e27
Changed url to use f string
Slenderman00 May 4, 2023
671e13a
minor reformat
Slenderman00 May 4, 2023
ee7f680
replaced ipdevpoll_config with self.config
Slenderman00 Sep 11, 2023
3af3a05
whitespace must be removed from the mapping status
Slenderman00 Oct 2, 2023
118ac26
parse mappings test
Slenderman00 Oct 2, 2023
adeb592
implemented test for get mappings
Slenderman00 Oct 10, 2023
877a483
Stopped mocking the logger
Slenderman00 Oct 10, 2023
77852a0
reformatted with python black
Slenderman00 Oct 10, 2023
0689720
Added PyOpenSSL
Slenderman00 Nov 27, 2023
1da5e40
Revert "Added PyOpenSSL"
Slenderman00 Nov 27, 2023
677709a
Revert back to "Added PyOpenSSL"
Slenderman00 Nov 27, 2023
cac72d4
fix: Deleted accidentally included legacy test
Slenderman00 Mar 7, 2024
f103424
Sort imports and clean up strings
lunkwill42 Apr 29, 2024
d716984
Rework PaloAltoArp configuration build/usage
lunkwill42 Apr 29, 2024
772aea9
Clean up requirements
lunkwill42 Apr 29, 2024
ba9ce3d
Clean up variable names and annotate
lunkwill42 Apr 29, 2024
d246665
Rename policy class.
lunkwill42 Apr 29, 2024
82aae58
Improve PaloAltoArp API error handling
lunkwill42 Apr 29, 2024
acaceb5
Add paloaltoarp plugin to default ipdevpoll conf
lunkwill42 Apr 30, 2024
cd390f5
Add paloaltoarp plugin documentation
lunkwill42 Apr 30, 2024
a00e9af
Add news fragment
lunkwill42 Apr 30, 2024
303197f
Fix broken api key lookup
lunkwill42 May 2, 2024
7ff53ee
Unit test `PaloaltoArp._do_request()`
lunkwill42 May 2, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions NOTES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ Dependency changes

.. IMPORTANT:: NAV 5.10 requires PostgreSQL to be at least version *11*.

New dependencies
~~~~~~~~~~~~~~~~

Dependencies to these Python modules have been added in order to support
communicating with Palo Alto firewall APIs:

* :mod:`PyOpenSSL` (``==23.3.0``)
* :mod:`service-identity` (``==21.1.0``)

Support for fetching ARP cache data from Palo Alto firewalls
------------------------------------------------------------

Palo Alto firewalls do support SNMP. They do not, however, support fetching
ARP cache data using SNMP. A new ipdevpoll plugin, ``paloaltoarp``, has been
added to fetch ARP cache data using the REST API built in to these firewall
products.

Access credentials for Palo Alto firewalls need to be configured in
:file:`ipdevpoll.conf`, but a later NAV release should move to providing
management profiles also for this.

Please read more in :doc:`the ipdevpoll reference documentation
</reference/ipdevpoll>` for configuration details.


NAV 5.9
=======
Expand Down
1 change: 1 addition & 0 deletions changelog.d/2613.added.md
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
30 changes: 30 additions & 0 deletions doc/reference/ipdevpoll.rst
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,36 @@ Section [linkstate]
The value ``any`` will generate alerts for all link state changes, but
**this is not recommended** for performance reasons.

Section [paloaltoarp]
---------------------

This section configures the Palo Alto ARP plugin. Palo Alto firewalls do
support SNMP. They do not, however, support fetching ARP cache data using
SNMP. This plugin enables fetching ARP records from Palo Alto firewalls using
their built-in REST API.

Currently, there is no management profile type for this type of REST APIs, so
credentials to access a Palo Alto firewall's API must be configured in this
section.

If you have a Palo Alto firewall named ``example-fw.example.org``, with an IP
address of ``10.0.42.42`` and a secret API token of
``762e87e0ec051a1c5211a08dd48e7a93720eee63``, you can configure this in this
section by adding::

example-fw.example.org = 762e87e0ec051a1c5211a08dd48e7a93720eee63

Or, alternatively::

10.0.42.42 = 762e87e0ec051a1c5211a08dd48e7a93720eee63


.. warning:: The Palo Alto ARP plugin does not currently verify TLS
certificates when accessing a Palo Alto API. This will be changed
at a later date, but if it worries you, you should not use the
plugin yet.


Job sections
------------

Expand Down
14 changes: 12 additions & 2 deletions python/nav/etc/ipdevpoll.conf
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ linkaggregate=
bgp=
poe=
juniperalarm=
paloaltoarp=

[job_inventory]
#
Expand Down Expand Up @@ -132,9 +133,9 @@ description: Checks for changes in the reverse DNS records of devices
interval: 30m
intensity: 0
plugins:
arp
arp paloaltoarp
description:
The ip2mac job logs IP to MAC address mappings from routers
The ip2mac job logs IP to MAC address mappings from routers and firewalls
(i.e. from IPv4 ARP and IPv6 Neighbor caches)


Expand Down Expand Up @@ -225,6 +226,15 @@ filter = topology
# using the staticroutes plugins. Value is a number of seconds between requests.
#throttle-delay=0.0

[paloaltoarp]
# Until a management profile type for (Palo Alto) REST API credentials
# exist in NAV, this section is used to configure API tokens/keys per
# Palo Alto firewall. Identify each Palo Alto firewall with either its
# NAV sysname or management IP address:

#hostname = secret-API-key
#ip = another-secret-API-key

[sensors]
# A space-separated list of Python modules to load into ipdevpoll as the
# sensors plugin is loaded. An asterisk suffix will cause all modules in that
Expand Down
150 changes: 150 additions & 0 deletions python/nav/ipdevpoll/plugins/paloaltoarp.py
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

Check warning on line 56 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L55-L56

Added lines #L55 - L56 were not covered by tests
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 (

Check warning on line 63 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L63

Added line #L63 was not covered by tests
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(

Check warning on line 72 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L72

Added line #L72 was not covered by tests
str(self.netbox.ip), self.configured_devices.get(self.netbox.sysname, "")
)
self._logger.debug("Collecting IP/MAC mappings for Paloalto device")

Check warning on line 75 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L75

Added line #L75 was not covered by tests
johannaengland marked this conversation as resolved.
Show resolved Hide resolved

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")
johannaengland marked this conversation as resolved.
Show resolved Hide resolved
returnValue(None)

Check warning on line 80 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L77-L80

Added lines #L77 - L80 were not covered by tests

yield self._process_data(mappings)

Check warning on line 82 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L82

Added line #L82 was not covered by tests

returnValue(None)

Check warning on line 84 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L84

Added line #L84 was not covered by tests

@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:
johannaengland marked this conversation as resolved.
Show resolved Hide resolved
returnValue(None)

Check warning on line 92 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L92

Added line #L92 was not covered by tests

# 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)

Check warning on line 104 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L104

Added line #L104 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not accustomed to using the Twisted client libraries for this sort of thing. Does this in fact mean that we explicitly turn off TLS certificate verification? If so, is that really what we want when talking to a security-specific product? :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed this at UiT last year. Since we are only extracting ARP data this should be fine as long as the keys and permissions on the Palo Alto device are configured correctly (To only allow for fetching ARP data). But yes we are explicitly turning of TLS certificate verification on all requests made by the plugin.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to implement certificate pinning instead of this solution just in case someone manages to misconfigure their permissions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A MITM attack might also allow for data infiltration, certificate pinning is starting to seem like the best option.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To get this PR done with, I would just make note of this fact in the docs. That way, you could submit a new PR with changes for certificate config options (i.e. switch verification on/off or point to a pinned certificate)


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(

Check warning on line 121 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L120-L121

Added lines #L120 - L121 were not covered by tests
"Error when talking to PaloAlto API. "
"Make sure the device is reachable and the API key is correct."
)
returnValue(None)

Check warning on line 125 in python/nav/ipdevpoll/plugins/paloaltoarp.py

View check run for this annotation

Codecov / codecov/patch

python/nav/ipdevpoll/plugins/paloaltoarp.py#L125

Added line #L125 was not covered by tests

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
9 changes: 9 additions & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:
lunkwill42 marked this conversation as resolved.
Show resolved Hide resolved
#
# 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
109 changes: 109 additions & 0 deletions tests/unittests/ipdevpoll/plugins_paloaltoarp_test.py
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"
Loading