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

Rule order and evidence of effect

Explain a preempted rule, rehearse a change and separate configuration from observed effect.

1. Write intent before the rule

A firewall change starts with a flow matrix. For each row, record source, destination, protocol, service, expected result, owner and justification. In our fictional case, a reporting application uses TCP/443 to 192.0.2.10. Client 10.20.30.41 should communicate; workstation 10.20.30.40 must remain blocked. Sharing a subnet does not make the requests equivalent. Add an external subnet client, another destination and UDP/443 as negative tests. Store the matrix with the change so RUN can repeat acceptance when the policy author is absent.

2. Follow effective order

The exercise starts with observe-report, legacy-trust, deny-workstation and report-client. All are simple address-and-port rules. Broad Trust matches both clients before the Block exception. The narrow rule exists but does not decide these flows. Removing the broad rule from the model sends the workstation to Block and the client to Allow. Monitor records and continues within this simplified scope. FTD documentation includes a caveat for Monitor with layer-seven conditions: early packets may pass before a later decision. The code does not represent application discovery or that initial phase.

3. Run and interpret the model

Run python3 run.py in a local working directory. The program creates in-memory data and performs 30 checks, including subnet boundaries, disabled rules, invalid ports, session selection and input preservation. Initial output reports trust and inspectionRequested=false for the workstation. The candidate reports block for the workstation and allow with inspectionRequested=true for the client. That last property is only a model assertion: no packet was inspected. Before running it, also predict the result of moving report-client above deny-workstation. The experiment confirms that the workstation then matches Allow; this is a functional regression even with the same collection of rules.

4. Move from hypothesis to change window

A local proof does not authorize direct promotion to production. Prepare the reviewed difference, device set, previous version and stop criterion. Inspect the deployment preview and investigate warnings or undetermined impact; missing estimates do not mean zero impact. In the fictional window, the pilot uses one test path with an available owner. Confirm each device status and run authorized new positive and negative connections. An already-open session is not a universal substitute for testing a new policy decision. Management access and evidence collection also need to remain available during the change.

5. Accept with bounded evidence

The final record combines policy revision, target, time, flow, observed rule and application result. If the blocked workstation fails as expected but the authorized client also fails, acceptance is still incomplete; locate the issue in policy, routing, DNS or service. After rollback, confirm restored state and review what remains pending in management before another deployment. The lab excludes NAT, IPv6, session state, decoding, TLS, prefilters, inheritance and intrusion engines. It makes a hypothesis reproducible rather than certifying FTD. Summary: state intent, follow order and verify the result within the correct scope.

"""Offline teaching model, not an FTD or RADIUS implementation. Python 3.13."""
import copy
import hashlib
import ipaddress
import json
from pathlib import Path
import platform


def evaluate(rules, flow):
 """IPv4 source/destination, protocol and destination port only; default block."""
 src = ipaddress.IPv4Address(flow['src'])
 dst = ipaddress.IPv4Address(flow['dst'])
 if flow['proto'] not in ('tcp', 'udp') or type(flow['port']) is not int or not 1 <= flow['port'] <= 65535:
 raise ValueError('Invalid protocol or port')
 seen = set
 for r in rules:
 if r['id'] in seen or r['action'] not in ('monitor', 'trust', 'allow', 'block'):
 raise ValueError('Duplicate rule ID or unsupported action')
 seen.add(r['id'])
 ipaddress.IPv4Network(r['src']); ipaddress.IPv4Network(r['dst'])
 if type(r['enabled']) is not bool or type(r['inspect']) is not bool:
 raise ValueError('Expected boolean flags')
 if r['proto'] not in ('tcp', 'udp') or type(r['port']) is not int or not 1 <= r['port'] <= 65535:
 raise ValueError('Invalid rule protocol or port')
 if r['inspect'] and r['action']!= 'allow':
 raise ValueError('Model inspection flag is only supported on Allow')
 trace = []
 for r in rules:
 if not r['enabled'] or src not in ipaddress.IPv4Network(r['src']) or dst not in ipaddress.IPv4Network(r['dst']) or flow['proto']!= r['proto'] or flow['port']!= r['port']:
 continue
 trace.append(r['id'])
 if r['action'] == 'monitor':
 continue
 return dict(action=r['action'], rule=r['id'], trace=trace, inspectionRequested=r['inspect'])
 return dict(action='block', rule='default', trace=trace, inspectionRequested=False)


def plan_single_session(sessions, selector):
 """Local one-session change guard, stricter than RFC multi-session behavior."""
 if not selector or set(selector) - {'nas', 'sid', 'user'} or any(not isinstance(v, str) or not v for v in selector.values):
 raise ValueError('Invalid selector')
 identities = [(s['nas'], s['sid']) for s in sessions]
 if len(identities)!= len(set(identities)):
 raise ValueError('Duplicate session identity in evidence')
 found = [s for s in sessions if s['active'] and all(s[k] == v for k, v in selector.items)]
 if not found:
 return dict(decision='stop-no-active-match', matches=[])
 if len(found)!= 1:
 return dict(decision='stop-ambiguous', matches=[(s['nas'], s['sid']) for s in found])
 if 'nas' not in selector or 'sid' not in selector:
 return dict(decision='stop-insufficient-selector', matches=[(found[0]['nas'], found[0]['sid'])])
 return dict(decision='ready-for-human-review', matches=[(found[0]['nas'], found[0]['sid'])])


def run:
 def rule(id, src, dst, action, inspect=False):
 return dict(id=id, src=src, dst=dst, action=action, inspect=inspect, enabled=True, proto='tcp', port=443)
 rules = [rule('observe-report', '10.0.0.0/8', '192.0.2.10/32', 'monitor'),
 rule('legacy-trust', '10.0.0.0/8', '192.0.2.0/24', 'trust'),
 rule('deny-workstation', '10.20.30.40/32', '192.0.2.10/32', 'block'),
 rule('report-client', '10.20.30.0/24', '192.0.2.10/32', 'allow', True)]
 fixed = [r for r in rules if r['id']!= 'legacy-trust']
 flow = dict(src='10.20.30.40', dst='192.0.2.10', proto='tcp', port=443)
 client = {**flow, 'src': '10.20.30.41'}
 sessions = [dict(nas='edge-a', sid='S1', user='analyst', active=True),
 dict(nas='edge-a', sid='S2', user='analyst', active=True),
 dict(nas='edge-b', sid='S1', user='operator', active=True),
 dict(nas='edge-a', sid='S0', user='analyst', active=False)]
 before = json.dumps([rules, sessions], sort_keys=True)
 checks = []
 def check(name, condition):
 if not condition: raise AssertionError(name)
 checks.append(name)
 def rejects(name, f):
 try: f
 except (ValueError, ipaddress.AddressValueError): checks.append(name)
 else: raise AssertionError(name)
 a = evaluate(rules, flow); b = evaluate(fixed, flow); c = evaluate(fixed, client)
 check('broad trust preempts narrow deny', a['action'] == 'trust' and a['rule'] == 'legacy-trust')
 check('monitor records then continues', a['trace'] == ['observe-report', 'legacy-trust'])
 check('trust does not request model inspection', a['inspectionRequested'] is False)
 check('candidate blocks denied workstation', b['action'] == 'block' and b['rule'] == 'deny-workstation')
 check('candidate allows permitted client with inspection requested', c['action'] == 'allow' and c['inspectionRequested'])
 check('unexpected subnet denied', evaluate(fixed, {**client, 'src':'10.40.1.2'})['rule'] == 'default')
 check('other destination denied', evaluate(fixed, {**client, 'dst':'192.0.2.11'})['rule'] == 'default')
 check('UDP does not match TCP service', evaluate(fixed, {**client, 'proto':'udp'})['rule'] == 'default')
 check('wrong port denied', evaluate(fixed, {**client, 'port':8443})['rule'] == 'default')
 check('monitor alone ends at default block', evaluate([rules[0]], flow)['action'] == 'block')
 check('empty policy defaults to block', evaluate([], flow)['rule'] == 'default')
 disabled = copy.deepcopy(fixed); disabled[-1]['enabled'] = False
 check('disabled allow ignored', evaluate(disabled, client)['rule'] == 'default')
 check('moving allow before deny changes result', evaluate([fixed[0], fixed[2], fixed[1]], flow)['action'] == 'allow')
 check('subnet upper boundary inside', evaluate(fixed, {**client, 'src':'10.20.30.255'})['action'] == 'allow')
 check('adjacent subnet outside', evaluate(fixed, {**client, 'src':'10.20.31.0'})['action'] == 'block')
 rejects('duplicate rule IDs rejected', lambda:evaluate(rules+[rules[0]], flow))
 rejects('malformed source rejected', lambda:evaluate(fixed, {**flow, 'src':'10.20.30.999'}))
 rejects('boolean port rejected', lambda:evaluate(fixed, {**flow, 'port':True}))
 invalid=copy.deepcopy(fixed);invalid[0]['inspect']=True
 rejects('unsupported monitor inspection rejected',lambda:evaluate(invalid,flow))
 check('same user at NAS selects two and stops', plan_single_session(sessions, {'nas':'edge-a','user':'analyst'})['decision'] == 'stop-ambiguous')
 check('session ID alone spans NAS devices and stops', plan_single_session(sessions, {'sid':'S1'})['decision'] == 'stop-ambiguous')
 check('NAS plus session ID scopes one proposal', plan_single_session(sessions, {'nas':'edge-a','sid':'S2'})['matches'] == [('edge-a','S2')])
 check('specific proposal still requires review', plan_single_session(sessions, {'nas':'edge-a','sid':'S2'})['decision'] == 'ready-for-human-review')
 check('stale session excluded', plan_single_session(sessions, {'nas':'edge-a','sid':'S0'})['decision'] == 'stop-no-active-match')
 check('contradictory identity does not match', plan_single_session(sessions, {'nas':'edge-a','sid':'S1','user':'operator'})['decision'] == 'stop-no-active-match')
 check('accidental uniqueness is insufficient', plan_single_session(sessions, {'user':'operator'})['decision'] == 'stop-insufficient-selector')
 rejects('empty selector rejected', lambda:plan_single_session(sessions, {}))
 rejects('unknown selector field rejected', lambda:plan_single_session(sessions, {'role':'admin'}))
 rejects('duplicate session evidence rejected',lambda:plan_single_session(sessions+[sessions[0]], {'nas':'edge-a','sid':'S1'}))
 check('input rules and sessions unchanged', json.dumps([rules, sessions],sort_keys=True) == before)
 return dict(passed=len(checks),failed=0,checks=checks,python=platform.python_version,scriptSHA256=hashlib.sha256(Path(__file__).read_bytes).hexdigest,observations=dict(baseline=a,candidateDenied=b,candidateAllowed=c,ambiguous=plan_single_session(sessions,{'nas':'edge-a','user':'analyst'})),actualDeviceConfigured=False,actualRADIUS=False,actualTrafficInspected=False)

if __name__ == '__main__':
 print(json.dumps(run, indent=2))
IN PRACTICE

10.20.30.40 → 192.0.2.10 TCP/443: observe-report → legacy-trust. Without legacy-trust: observe-report → deny-workstation.

Common pitfalls

Confusing Block presence with effectiveness; accepting only positive tests; treating inspectionRequested as executed inspection; extrapolating the model to layer seven.

Related topics: Segmentation · Change management · Telemetry

Take this idea with you

A rule delivers its intended decision only when the flow reaches it and the effect is confirmed at the target.

Create account

Reference: Access control rules · 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.