← CCNP Security: SCOR core and operations
17 / 25 · 55 MIN

APIs: inventory and controlled change

Interpret an HTTP client, detect incomplete inventories and recover from changes with uncertain outcomes.

1. Define the contract before automation

An APS team prepares a rule review before migrating a reporting service. Its script must collect inventory without changing production. Define the resource, version, administrative domain, permissions and completeness criteria before writing the request loop. FMC 10.0 authentication uses token generation and specific headers; this exercise’s synthetic Bearer scheme is not FMC integration code. A dedicated account reduces interference with UI sessions. Credentials, tokens and responses containing sensitive data do not belong in the change ticket. Retain correlation identifiers, time and status, keeping secrets in an appropriate mechanism. Technical write permission also does not replace change authorization.

2. Reconcile every page

The lab returns five objects in pages of two, two and one. Stopping at the first 200 response produces an incomplete export that appears successful. The client follows approved continuation, retains IDs, rejects duplicates and cycles, and compares the advertised total when present. If the second page fails, it does not present the first as complete inventory. Even matching counts do not prove a transactional snapshot from an API allowing changes during reads. In production, document endpoint consistency and collection time span. A next reference to another origin is rejected before requesting it; credentials are not automatically sent to a URL merely because it appeared in a response.

3. Distinguish errors and bound retries

In the fictional server, 401 means the valid credential is missing and 403 identifies a role lacking resource access. Repeating the same forbidden request does not repair permission. A 429 calls for respecting limits, but the limit dimension depends on the service. The example actually waits one second and retries GET only once. The client accepts only integer Retry-After between zero and two seconds; HTTP also allows a date, which this implementation does not parse. These values are local limits rather than universal Cisco recommendations. The FMC guide documents rate and concurrency limits; identify the exhausted dimension before increasing parallelism. Invalid JSON inside a 200 response is a parsing error rather than empty inventory.

4. Resolve uncertain effects and conflicts

POST /changes creates an in-memory object and returns 503. The client records the error without automatic retry; a later read finds exactly one effect. Retrying a non-idempotent operation without knowing its outcome can duplicate it. Use correlation or idempotency mechanisms when the contract provides them and reconcile before deciding on another write. On a different resource, GET returns ETag 1; writing with If-Match 0 receives 412 and leaves state unchanged. Read the current revision, compare intent against the concurrent change and renew approval where necessary. The following experiment uses the correct version and confirms the result by reading. This demonstrates generic HTTP without asserting ETag support on a particular FMC endpoint.

5. Run and hand over scoped evidence

Run python3 run.py on a machine with Python 3.13 and permission for loopback sockets. The program opens a temporary local port, makes 24 requests, checks 30 conditions and shuts down the server and thread. It uses no external destinations, TLS, real accounts or device configuration files. Changes disappear with the process. For RUN, supplement real automation with timeouts, body limits, reconciled inventory, secret-free logs, ownership and recovery. Read-back confirms a resource representation but proves neither firewall deployment nor working service. Summary: treat authentication, completeness, retry and effect as separate problems and report exactly which stage was demonstrated.

#!/usr/bin/env python3
"""Original generic HTTP API lab. All state and credentials are synthetic."""
import hashlib,http.client,json,platform,threading,time
from collections import Counter
from http.server import BaseHTTPRequestHandler,HTTPServer
from pathlib import Path
from urllib.parse import urlsplit,urljoin,parse_qs
TOKEN='synthetic-read'
state={'requests':[],'rate':0,'changes':[],'version':1,'config':{'enabled':False}}
class Handler(BaseHTTPRequestHandler):
 def log_message(self,*args):pass
 def reply(self,status,body,headers=None):
 raw=body if isinstance(body,bytes)else json.dumps(body).encode
 self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(raw)))
 for k,v in (headers or {}).items:self.send_header(k,v)
 self.end_headers;self.wfile.write(raw)
 def request(self):
 route=urlsplit(self.path);p=route.path;state['requests'].append({'method':self.command,'path':self.path})
 if self.headers.get('Authorization')!='Bearer '+TOKEN:return self.reply(401,{'error':'synthetic credential required'},{'WWW-Authenticate':'Bearer realm="synthetic-lab"'})
 if p=='/forbidden':return self.reply(403,{'error':'synthetic role lacks permission'})
 if p in ['/rate','/permanent-rate']:
 if p=='/rate':state['rate']+=1
 if p=='/permanent-rate'or state['rate']==1:return self.reply(429,{'error':'limit'}, {'Retry-After':'1'})
 return self.reply(200,{'ok':True})
 if p=='/redirect':return self.reply(302,{}, {'Location':'http://example.invalid/collect'})
 if p=='/invalid-json':return self.reply(200,b'{broken')
 if p=='/cycle':return self.reply(200,{'items':[{'id':'r1'}],'next':'/cycle'})
 if p=='/foreign':return self.reply(200,{'items':[{'id':'r1'}],'next':'http://example.invalid/collect'})
 if p=='/count-mismatch':return self.reply(200,{'items':[{'id':'r1'}],'next':None,'count':5})
 if p=='/duplicate':return self.reply(200,{'items':[{'id':'r1'},{'id':'r1'}],'next':None})
 if p=='/broken-page':return self.reply(500,{'error':'page unavailable'})
 if p=='/partial':return self.reply(200,{'items':[{'id':'r1'}],'next':'/broken-page'})
 if p=='/inventory':
 offset=int(parse_qs(route.query).get('offset',['0'])[0]);items=[{'id':f'r{i}','name':f'Rule {i}'}for i in range(1,6)]
 return self.reply(200,{'items':items[offset:offset+2],'next':f'/inventory?offset={offset+2}'if offset+2<len(items)else None,'count':5})
 if p=='/changes':
 if self.command=='POST':
 self.rfile.read(int(self.headers.get('Content-Length','0')));state['changes'].append({'id':'change-1'});return self.reply(503,{'error':'synthetic response error after effect'})
 return self.reply(200,{'items':state['changes']})
 if p=='/config':
 if self.command=='PUT':
 raw=self.rfile.read(int(self.headers.get('Content-Length','0')))
 if self.headers.get('If-Match')!=f'"{state["version"]}"':return self.reply(412,{'error':'stale version'})
 state['config']=json.loads(raw);state['version']+=1
 return self.reply(200,state['config'],{'ETag':f'"{state["version"]}"'})
 return self.reply(404,{'error':'unknown local resource'})
 do_GET=request;do_POST=request;do_PUT=request

class APIError(Exception):pass
class Client:
 def __init__(self,origin,token=TOKEN):self.origin=origin;self.token=token;self.waits=[]
 def path(self,target):
 dest=urlsplit(urljoin(self.origin,target));base=urlsplit(self.origin)
 if (dest.scheme,dest.hostname,dest.port)!=(base.scheme,base.hostname,base.port)or dest.username or dest.password:raise APIError('unapproved-origin')
 if dest.fragment:raise APIError('unexpected-fragment')
 return dest.path+('?' + dest.query if dest.query else '')
 def call(self,method,target,body=None,headers=None):
 path=self.path(target);base=urlsplit(self.origin)
 for attempt in range(2):
 conn=http.client.HTTPConnection(base.hostname,base.port,timeout=2)
 try:
 h={'Authorization':'Bearer '+self.token,'Content-Type':'application/json',**(headers or {})};raw=None if body is None else json.dumps(body).encode
 conn.request(method,path,body=raw,headers=h);response=conn.getresponse;status=response.status;rh={k.lower:v for k,v in response.getheaders};payload=response.read(16385)
 finally:conn.close
 if len(payload)>16384:raise APIError('body-too-large')
 if status==429 and method=='GET'and attempt==0:
 wait=rh.get('retry-after','')
 if not wait.isdigitor not 0<=int(wait)<=2:raise APIError('retry-outside-local-budget')
 self.waits.append(int(wait));time.sleep(int(wait));continue
 if status<200 or status>=300:raise APIError('http-'+str(status))
 try:return json.loads(payload),rh
 except json.JSONDecodeError:raise APIError('invalid-json')
 def inventory(self,target='/inventory?offset=0'):
 seen=set;ids=set;result=[];expected=None
 while target:
 path=self.path(target)
 if path in seen:raise APIError('pagination-cycle')
 if len(seen)>=5:raise APIError('page-budget')
 seen.add(path);data,_=self.call('GET',target)
 if 'count'in data:
 if not isinstance(data['count'],int)or data['count']<0:raise APIError('invalid-count')
 if expected is not None and expected!=data['count']:raise APIError('count-drift')
 expected=data['count']
 if not isinstance(data.get('items'),list):raise APIError('invalid-items')
 for item in data['items']:
 if not isinstance(item,dict)or not isinstance(item.get('id'),str):raise APIError('invalid-item')
 if item['id']in ids:raise APIError('duplicate-id')
 ids.add(item['id']);result.append(item)
 target=data.get('next')
 if expected is not None and len(result)!=expected:raise APIError('count-mismatch')
 return result

def main:
 checks=[];results={}
 def check(label,ok):
 if not ok:raise AssertionError(label)
 checks.append(label)
 def error(label,fn,expected):
 try:fn
 except APIError as e:check(label,str(e)==expected)
 else:raise AssertionError(label+' accepted')
 server=HTTPServer(('127.0.0.1',0),Handler);thread=threading.Thread(target=server.serve_forever,kwargs={'poll_interval':0.05});thread.start;origin='http://127.0.0.1:'+str(server.server_port);client=Client(origin)
 try:
 error('missing valid token is 401',lambda:Client(origin,'invalid').call('GET','/inventory'),'http-401')
 error('forbidden role is 403 without retry',lambda:client.call('GET','/forbidden'),'http-403')
 inventory=client.inventory;check('five objects recovered across three pages',[x['id']for x in inventory]==['r1','r2','r3','r4','r5'])
 check('names retained with IDs',inventory[-1]['name']=='Rule 5')
 error('cyclic pagination rejected',lambda:client.inventory('/cycle'),'pagination-cycle')
 error('foreign next link rejected before request',lambda:client.inventory('/foreign'),'unapproved-origin')
 error('automatic redirect not followed',lambda:client.call('GET','/redirect'),'http-302')
 error('declared count mismatch rejected',lambda:client.inventory('/count-mismatch'),'count-mismatch')
 error('duplicate inventory ID rejected',lambda:client.inventory('/duplicate'),'duplicate-id')
 error('incomplete inventory fails instead of returning partial success',lambda:client.inventory('/partial'),'http-500')
 error('invalid JSON is not treated as empty inventory',lambda:client.call('GET','/invalid-json'),'invalid-json')
 start=time.monotonic;data,_=client.call('GET','/rate');check('GET retry succeeds after one rate limit',data=={'ok':True});check('Retry-After one second actually waited',time.monotonic-start>=1)
 error('persistent rate limit exhausts two-attempt budget',lambda:client.call('GET','/permanent-rate'),'http-429');check('bounded waits recorded',client.waits==[1,1])
 error('POST response error does not trigger automatic retry',lambda:client.call('POST','/changes',{'name':'synthetic change'}),'http-503')
 data,_=client.call('GET','/changes');check('effect exists despite POST 503',data=={'items':[{'id':'change-1'}]})
 before,h=client.call('GET','/config');check('initial representation has version one',h['etag']=='"1"'and before=={'enabled':False})
 error('stale If-Match rejected',lambda:client.call('PUT','/config',{'enabled':True},{'If-Match':'"0"'}),'http-412')
 data,_=client.call('GET','/config');check('stale write leaves representation unchanged',data==before)
 data,h=client.call('PUT','/config',{'enabled':True},{'If-Match':'"1"'});check('current If-Match applies once',data=={'enabled':True}and h['etag']=='"2"')
 data,h=client.call('GET','/config');check('read-back confirms resulting representation',data=={'enabled':True}and h['etag']=='"2"')
 counts=Counter((x['method'],urlsplit(x['path']).path)for x in state['requests'])
 check('forbidden request sent only once',counts['GET','/forbidden']==1)
 check('cycle detected after one page',counts['GET','/cycle']==1)
 check('rate endpoint called twice',counts['GET','/rate']==2)
 check('permanent rate endpoint bounded to two calls',counts['GET','/permanent-rate']==2)
 check('side-effecting POST sent exactly once',counts['POST','/changes']==1)
 check('all observed requests stay on local paths',all(x['path'].startswith('/')for x in state['requests']))
 check('request evidence excludes authentication tokens',TOKEN not in json.dumps(state['requests']))
 results={'inventoryIDs':[x['id']for x in inventory],'waitsSeconds':client.waits,'createdChanges':len(state['changes']),'finalConfig':state['config'],'finalVersion':state['version'],'requests':state['requests'],'requestsCount':len(state['requests'])}
 finally:server.shutdown;server.server_close;thread.join(3)
 check('owned HTTP server and thread stopped',not thread.is_alive)
 print(json.dumps({'passed':len(checks),'failed':0,'checks':checks,'observations':results,'python':platform.python_version,'scriptSHA256':hashlib.sha256(Path(__file__).read_bytes).hexdigest,'actualLocalHTTP':True,'actualCiscoAPI':False,'actualDLP':False,'tlsUsedInThisLab':False,'externalRequests':False,'realCredentialsUsed':False,'durableWrites':False,'fmcSchemaCompatibilityClaimed':False,'scope':'Generic synthetic API; integer Retry-After only, local retry/body/page budgets, in-memory effects. No real deployment or DLP enforcement.'},indent=2))
if __name__=='__main__':main
IN PRACTICE

A POST responds 503 after creating change-1. Reading finds one object; blindly repeating risks creating another.

Common pitfalls

First page as total; credentials in a redirect; indiscriminate retries; 200 as business proof; generic ETag as an FMC capability.

Related topics: Automation and APIs · Change and rollback · Observability

Take this idea with you

An operational script needs a contract, bounds, reconciliation and evidence of effect.

Create account

Reference: Secure Firewall Management Center REST API Quick Start Guide · 350-701 SCOR v2.0, effective 2026-08-27; core component of CCNP Security

CCNP® and Cisco® are registered trademarks of Cisco Systems, Inc. and/or its affiliates. bigsavant.com is an independent preparation platform and is not affiliated with, associated with, sponsored, authorised or endorsed by Cisco. Content and questions are original, are not official exam questions, and completing our tests does not award or guarantee any certification. Names are used only to identify the subject. All other trademarks belong to their respective owners.