Like programming best practices, growing your organization’s use of Application Programming Interfaces (APIs) comes with its own complexities. This blog post will highlight three best practices for ensuring an effective and efficient codebase. Additionally, a few notable examples from Cisco DevNet are provided to whet your appetite.
API best practices
1. Choose an organization-wide coding standard (style guide) – this cannot be stressed enough.
With a codified style guide, you can be sure that whatever scripts or programs are written will be easily followed by any employee at Cisco. The style guide utilized by the Python community at Cisco is Python’s PEP8 standard.
An example of bad vs good coding format can be seen in the following example of dictionary element access. I am using this example because here at Cisco many of our APIs use the JSON (
JavaScript Object Notation) format, which, at its most basic, is a multi-level dictionary. Both the “bad” and “good” examples will work; however, the style guide dictates that the get() method should be used instead of the has_key() method for accessing dictionary elements:
Bad:
d = {'hello': 'world'}
if d.has_key('hello'):
print d['hello'] # prints 'world
else:
print 'default_value'
Good:
d = {'hello': 'world'}
print d.get('hello', 'default_value') # prints 'world'
print d.get('thingy', 'default_value') # prints 'default_value'
# Or:
if 'hello' in d:
print d['hello']
2. Implement incremental tests – start testing early and test early. For Python, incremental testing takes the form of the “unittest” unit testing framework. Utilizing the framework, code can be broken down into small chunks and tested piece by piece. For the examples below, a potential breakdown of the code into modules could be similar to the following: test network access by pinging the appliance’s management interface, run the API GET method to determine which API versions are accepted by the appliance, and finally parse the output of the API GET method.
In this example I will show the code which implements the unit test framework. The unit test code is written in a separate python script and imports our python API script. For simplicity we will assume that the successful API call (curl -k -X GET –header ‘Accept: application/json’ ‘https://192.168.10.195/api/versions’) always returns the following json response:
{
"supportedVersions":["v3", "latest"]
}
Please review the full example code at the end of this blog post.
3. Document your code – commenting is not documenting. While Python comments can be fairly straight forward in some cases, most, if not all style guides will request the use of a documentation framework. Cisco, for example, uses PubHub. Per the Cisco DevNet team, “PubHub is an online document-creation system that the Cisco DevNet group created” (DevNet, 2019).
DevNet Security
A great introduction to API development with Cisco Security products can be had by attending a DevNet Express Security session. (The next one takes place Oct 8-9 in Madrid.) During the session the APIs for AMP for Endpoints (A4E), ISE, ThreatGrid, Umbrella, and Firepower are explored. A simple way to test your needed API call is to utilize a program called curl prior to entering the command into a python script. For the examples below, we will provide the documentation as well as the curl command needed. Any text within ‘<‘ and ‘>’ indicates that user-specific input is required.
For A4E, one often-used API call lists and provides additional information about each computer. For A4E, a curl-instantiated API call follows this format:
“curl -o output_file.json https://clientID:APIKey@api.amp.cisco.com/v1/function“.
To pull the list of computers, the API call looks like this:
curl -o computers.json https://<client_id>:<api_key>@api.amp.cisco.com/v1/computers.
In ISE a great API call to use queries the monitoring node for all reasons for authentication failure on that monitoring node. To test this API call, we can implement the following curl command:
curl -o output_file.json https://acme123/admin/API/mnt/FailureReasons. The API call’s generic form is as follows: https://ISEmntNode.domain.com/admin/API/mnt/<specific-api-call>.
Please note that you will need to log in to the monitoring node prior to performing the API call.
A common ThreatGrid API call performed is one where a group of file hashes is queried for state (Clean, Malicious, or Unknown). The API call is again visualized as a curl command here for simplicity. In ThreatGrid, the call follows this format:
“curl –request GET “https://panacea.threatgrid.com/api/v2/function?api_key=<API_KEY>&ids=[‘uuid1′,’uuid2’]” –header “Content-Type: application/json”“.
The full request is as follows:
curl –request GET “https://panacea.threatgrid.com/api/v2/samples/state?api_key=<API_KEY>&ids=[‘ba91b01cbfe87c0a71df3b1cd13899a2’]” –header “Content-Type: application/json”.
Umbrella’s Investigate API is the most used of the APIs provided by Umbrella. An interesting API call from Investigate provides us with the categories of all domains provided. As before, curl is used to visualize the API call. In this example, we want to see which categories google.com and yahoo.com fall into:
curl –include –request POST –header “Authorization: Bearer %YourToken%” –data-binary “[\”google.com\”,\”yahoo.com\”]” https://investigate.api.umbrella.com/domains/categorization.
Finally, Firepower’s Threat Defense API. Since deploying changes is required for most all modifications performed to the FTD system, the FTD API’s most useful call deploys configured changes to the system. The curl command needed to initiate a deploy and save the output to a file is:
“curl -o output_file.json -X POST –header ‘Content-Type: application/json’ –header ‘Accept: application/json’ https://ftd.example.com/api/fdm/v3/operational/deploy“.
Open the file after running the command to ensure that the state of the job is “QUEUED”.
Unit Testing Example Code
apiGET.py
#! /usr/bin/python3
"""
apiGET.py is a python script that makes use of the requests module to ask
the target server what API Versions it accepts.
There is only one input (first command line argument) which is an IP address.
The accepted API versions are the output.
To run the script use the following syntax:
./apiGET.py <IP address of server>
python3 apiGET.py <IP address of server>
"""
import requests # to enable native python API handling
import ipaddress # to enable native python IP address handling
import sys # to provide clean exit statuses in python
import os # to allow us to ping quickly within python
def sanitizeInput(inputs):
""" if there is more than one command line argument, exit """
if len(inputs) != 2:
print('Usage: {} <IP address of API server>'.format(inputs[0]))
sys.exit(1)
""" now that there is only one command line argument, make sure it's an IP & return """
try:
IPaddr = ipaddress.ip_address(inputs[1])
return IPaddr
except ValueError:
print('address/netmask is invalid: {}'.format(inputs[1]))
sys.exit(1)
except:
print('Usage: {} <IP address of API server>'.format(inputs[0]))
sys.exit(1)
def getVersions(IPaddr):
""" make sure IP exists """
if (os.system("ping -c 1 -t 1 " + IPaddr) != 0):
print("Please enter a useable IP address.")
sys.exit(1)
""" getting valid versions using the built-in module exceptions to handle errors """
r = requests.get('https://{}/api/versions'.format(str(IPaddr)), verify=False)
try:
r.raise_for_status()
except:
return "Unexpected error: " + str(sys.exc_info()[0])
return r
if __name__ == "__main__":
print(getVersions(sanitizeInput(sys.argv)).text + '\n')
apiGETtest.py
#! /usr/bin/python3
"""
apiGETtest.py is a python script that is used to perform
unittests against apiGET.py. The purpose of these tests is
to predict different ways users can incorrectly utilize
apiGET.py and ensure the script does not provide
an opportunity for exploitation.
"""
""" importing modules """
import os # python module which handles system calls
import unittest # python module for unit testing
import ipaddress # setting up IP addresses more logically
import requests # to help us unittest our responses below
""" importing what we're testing """
import apiGET # our actual script
""" A class is used to hold all test methods as we create them and fill out our main script """
class TestAPIMethods(unittest.TestCase):
def setUp(self):
"""
setting up the test
"""
def test_too_many_CLI_params(self):
"""
test that we are cleanly implementing command-line sanitization
correctly counting
"""
arguments = ['apiGET.py', '127.0.0.1', 'HELLO_WORLD!']
with self.assertRaises(SystemExit) as e:
ipaddress.ip_address(apiGET.sanitizeInput(arguments))
self.assertEqual(e.exception.code, 1)
def test_bad_CLI_input(self):
"""
test that we are cleanly implementing command-line sanitization
eliminating bad input
"""
arguments = ['apiGET.py', '2540abc']
with self.assertRaises(SystemExit) as e:
apiGET.sanitizeInput(arguments)
self.assertEqual(e.exception.code, 1)
def test_good_IPv4_CLI_input(self):
"""
test that we are cleanly implementing command-line sanitization
good for IPv4
"""
arguments = ['apiGET.py', '127.0.0.1']
self.assertTrue(ipaddress.ip_address(apiGET.sanitizeInput(arguments)))
def test_good_IPv6_CLI_input(self):
"""
test that we are cleanly implementing command-line sanitization
good for IPv6
"""
arguments = ['apiGET.py', '::1']
self.assertTrue(ipaddress.ip_address(apiGET.sanitizeInput(arguments)))
def test_default_api_call_to_bad_IP(self):
"""
test our API GET method which returns the supported API versions,
in this case 'v3' and 'latest'
this test will fail because the IP address 192.168.45.45 does not exist
"""
with self.assertRaises(SystemExit) as e:
apiGET.getVersions('192.168.45.45')
self.assertEqual(e.exception.code, 1)
def test_default_api_call_to_no_API(self):
"""
test our API GET method which returns the supported API versions,
in this case 'v3' and 'latest'
this test will fail because the IP address does exist but does not have an exposed API
"""
with self.assertRaises(requests.exceptions.ConnectionError):
apiGET.getVersions('192.168.10.10')
def test_default_api_call(self):
"""
test our API GET method which returns the supported API versions,
in this case 'v3' and 'latest'
this test will succeed because the IP address does exist and will respond
"""
self.assertEqual(apiGET.getVersions('192.168.10.195').text,
'{\n "supportedVersions":["v3", "latest"]\n}\n')
if __name__ == '__main__':
unittest.main()