Skip to content

Instantly share code, notes, and snippets.

@kangtastic
Last active January 3, 2019 03:07
Show Gist options
  • Save kangtastic/314512a0cc87b6fedc0cd7af75c6d31a to your computer and use it in GitHub Desktop.
Save kangtastic/314512a0cc87b6fedc0cd7af75c6d31a to your computer and use it in GitHub Desktop.
List Cisco Security Advisories for IOS devices in a Nornir inventory.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright © 2018 James Seo <james@equiv.tech> (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