Last active
January 3, 2019 03:07
-
-
Save kangtastic/314512a0cc87b6fedc0cd7af75c6d31a to your computer and use it in GitHub Desktop.
List Cisco Security Advisories for IOS devices in a Nornir inventory.
This file contains hidden or 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
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
# | |
# Copyright © 2018 James Seo <[email protected]> (github.com/kangtastic). | |
# Hat tip Eric Tedor (github.com/etedor) for the idea! | |
# | |
# This file is released under the WTFPL, version 2 (wtfpl.net). | |
# | |
# nornir-iosvulns.py: List Cisco Security Advisories for IOS devices in a | |
# Nornir inventory. | |
# | |
# Description: A quick-and-very-dirty Python 3 script demonstrating the use of | |
# the Nornir automation framework (github.com/nornir-automation) | |
# to obtain the Advisory IDs of Cisco Security Advisories | |
# applicable to IOS versions running on nearby network devices | |
# using the Cisco PSIRT openVuln REST API. | |
# | |
# Entering an Advisory ID into any search engine returns the | |
# corresponding official web page on Cisco's web site containing | |
# extensive human-readable documentation concerning that Security | |
# Advisory. Note that most of this information is also available | |
# via the openVuln API, and this script could easily be adapted to | |
# extract it. | |
# | |
# The API is limited to returning results only for non-interim | |
# versions of IOS and advisories with an impact rating of Critical | |
# or High. Daily/monthly API hit frequency limits also apply. | |
# | |
# API access implies some sort of support contract, and OAuth2 | |
# Client ID and Client Secret credentials MUST be obtained from | |
# https://apiconsole.cisco.com/ and placed in a plain-text JSON | |
# file (formatted as described below) for this script to work. | |
# | |
# See https://developer.cisco.com/psirt/ for more API information. | |
# | |
# Usage: $ ./nornir-iosvulns.py [outfile] | |
# | |
# [outfile] is an optional path to a JSON output file containing: | |
# i) the Nornir hostnames of network devices and their IOS versions | |
# ii) the IOS versions on the network and their associated advisory | |
# IDs, excluding hostnames for which the IOS version could not | |
# be determined and IOS versions for which security advisories | |
# could not be queried. | |
# | |
# Requirements: Python 3.6+, nornir | |
# | |
# Sample console output (some advisory IDs omitted for length): | |
# | |
# HOSTS | |
# R1 | |
# 15.5(3)M4a | |
# S1 | |
# 15.0(2)SE4 | |
# S2 | |
# 15.0(2)SE4 | |
# | |
# IOS VERSIONS | |
# 15.0(2)SE4 | |
# cisco-sa-20180926-cdp-dos, cisco-sa-20180926-cmp, | |
# cisco-sa-20180926-tacplus, ... (45 advisories) | |
# 15.5(3)M4a | |
# cisco-sa-20180926-cdp-dos, cisco-sa-20180926-ipv6hbh, | |
# cisco-sa-20180926-pnp-memleak, ... (16 advisories) | |
# | |
# | |
# Sample JSON output (ditto): | |
# | |
# { | |
# "hosts": { | |
# "R1": "15.5(3)M4a", | |
# "S1": "15.0(2)SE4", | |
# "S2": "15.0(2)SE4" | |
# }, | |
# "advisories": { | |
# "15.0(2)SE4": [ | |
# "cisco-sa-20180926-cdp-dos", | |
# "cisco-sa-20180926-cmp", | |
# ... | |
# "cisco-sa-20140326-nat" | |
# ], | |
# "15.5(3)M4a": [ | |
# "cisco-sa-20180926-cdp-dos", | |
# "cisco-sa-20180926-ipv6hbh", | |
# ... | |
# "cisco-sa-20170322-l2tp" | |
# ] | |
# } | |
# } | |
# | |
import json | |
import re | |
import requests | |
import sys | |
from nornir.core import InitNornir | |
from nornir.core.task import Result | |
from nornir.plugins.tasks.networking import napalm_get | |
# | |
# FILES: Edit to change file paths. | |
# | |
CRED_FILE = "creds.json" # Text file with OAuth2 Client ID and Client Secret | |
# Format: '{ "id": "xxxx", "secret": "yyyy" }' | |
HOST_FILE = "hosts.yaml" # Nornir hosts file | |
GROUP_FILE = "groups.yaml" # Nornir group file | |
OUT_FILE = "" # Output JSON file (none by default) | |
# | |
# OTHER GLOBALS | |
# | |
ERROR = False # Exit cleanly if this remains False | |
USER_AGENT = "nornir-iosvulns" # User-Agent to send in API requests | |
DESCRIPTION = "List Cisco Security Advisories for IOS devices in a Nornir inventory." | |
def iprint(msg, indent=1, **kwargs): | |
print(" " * indent * 4 + msg, **kwargs) | |
def err(msg, indent=0): | |
global ERROR | |
ERROR = True | |
iprint("ERROR: " + msg, indent=indent, file=sys.stderr) | |
def die(msg): | |
print("") | |
err(msg + " Exiting.") | |
print("") | |
sys.exit(ERROR) | |
def print_mapping(d, title, vfmt, dempty): | |
print(title) | |
if d: | |
for k in d: | |
v = d[k] | |
iprint(k) | |
iprint(vfmt(v), 2) | |
else: | |
iprint(dempty, 2) | |
def dfilter(d): | |
return {k: v for k, v in d.items() if v} | |
def nornir_ios_version(task): | |
result = None | |
version_re = r".*Version\s(\S+).*" # Regex to parse Nornir Result object | |
# Get facts for the current host and determine its IOS version. | |
r = task.run(task=napalm_get, getters=["facts"]) | |
m = re.match(version_re, r.result["facts"]["os_version"]) | |
if m: | |
result = m.group(1).rstrip(",") # Trailing comma on IOS, not IOS XE | |
# Return a Nornir Result object. | |
return Result(host=task.host, result=result, | |
failed=False if result else True) | |
def psirt_api_token(id, secret): | |
r = requests.post("https://cloudsso.cisco.com/as/token.oauth2", | |
params={"client_id": id, "client_secret": secret}, | |
data={"grant_type": "client_credentials"}) | |
r.raise_for_status() | |
return r.json()["access_token"] | |
def psirt_api_request(version, tok): | |
# The PSIRT openVuln API returns a (large!) JSON object like the following: | |
# | |
# { | |
# "advisories": [ | |
# { | |
# "advisoryId": "cisco-sa-20180926-cdp-dos", | |
# "advisoryTitle": <string>, | |
# "bugIDs": <list of strings>, | |
# "ipsSignatures": <list of strings>, | |
# "cves": [ <list of strings> ], | |
# ... | |
# "summary": <HTML string> | |
# }, | |
# { | |
# "advisoryId": "cisco-sa-20170927-ike", | |
# ... | |
# }, | |
# ... | |
# ] | |
# } | |
# | |
# It would be trivial to extract other properties if desired, but for the | |
# purposes of this script, extract only "advisoryId". Searching for this | |
# brings up the security advisory's official web page. | |
r = requests.get("https://api.cisco.com/security/advisories/ios", | |
headers={"Authorization": f"Bearer {tok}", | |
"Accept": "application/json", | |
"User-Agent": USER_AGENT}, params={"version": version}) | |
r.raise_for_status() | |
return [advisory["advisoryId"] for advisory in r.json()["advisories"]] | |
def main(): | |
global OUT_FILE | |
print(f"{USER_AGENT}: {DESCRIPTION}") | |
# Set output file if one was passed on the command line. | |
if len(sys.argv) == 2: | |
OUT_FILE = sys.argv[1] | |
# Load credentials and initialize Nornir. | |
with open(CRED_FILE, "r") as f: | |
try: | |
cred = json.loads(f.read()) | |
except Exception as e: | |
die('Could not load credentials file: "str(e)"') | |
nr = InitNornir( | |
num_workers=10, | |
inventory="nornir.plugins.inventory.simple.SimpleInventory", | |
SimpleInventory={"host_file": HOST_FILE, "group_file": GROUP_FILE} | |
) | |
# Select desired hosts (in this case, IOS devices). | |
nr.inventory = nr.inventory.filter(nornir_nos="ios") | |
# Parse host facts with a custom Nornir Task to glean IOS versions. | |
ar = nr.run(task=nornir_ios_version) # ar is a Nornir AggregatedResult | |
hosts = dict.fromkeys(ar, "") # Map hosts to IOS versions | |
advisories = {} # Map IOS versions to advisories | |
# Populate mappings. | |
for host in hosts: | |
mr = ar[host] # mr is a Nornir MultiResult | |
r = mr[0] # r is the Result of a nornir_ios_version Task | |
if not mr.failed: | |
hosts.update({host: r.result}) # or, hosts["host"] = r.result | |
advisories.update({r.result: []}) | |
if advisories: # At least one IOS version was found | |
advisories = dict(sorted(advisories.items())) | |
try: | |
tok = psirt_api_token(cred["id"], cred["secret"]) | |
except Exception as e: | |
die('Cannot obtain OAuth2 token: "str(e)"') | |
for version in advisories: | |
try: | |
advisories.update({version: psirt_api_request(version, tok)}) | |
except requests.exceptions.HTTPError as e: | |
err(f'PSIRT API does not know about IOS version "{version}".' | |
if "406 Client Error: Not Acceptable" in str(e) else | |
f'Cannot query PSIRT API: "{str(e)}"', 0) | |
print("") | |
# Print hosts and their IOS versions. | |
print_mapping(hosts, "HOSTS", lambda host: f"{host}" if host | |
else "Unknown", "None found in inventory.") | |
print("") | |
# Print IOS versions and their advisory IDs. | |
print_mapping(advisories, "IOS ADVISORIES", | |
lambda ids: f"{', '.join(ids)} ({len(ids)} advisories)" | |
if ids else "Could not query PSIRT API", | |
"None found among hosts.") | |
if OUT_FILE: | |
print("") | |
try: | |
with open(OUT_FILE, "w") as f: | |
f.write(json.dumps({"hosts": dfilter(hosts), | |
"advisories": dfilter(advisories)}, | |
indent=2, separators=(", ", ": "))) | |
print(f'Wrote JSON to "{OUT_FILE}".') | |
except Exception as e: | |
err(f'Cannot write JSON to "{OUT_FILE}": "{str(e)}"') | |
sys.exit(ERROR) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment