1. Escrever a intenção antes da regra
Uma alteração de firewall começa por uma matriz de fluxos. Para cada linha, escreve origem, destino, protocolo, serviço, resultado esperado, proprietário e justificação. No nosso caso fictício, uma aplicação de relatórios usa TCP/443 para 192.0.2.10. O cliente 10.20.30.41 deve comunicar; a estação 10.20.30.40 deve ficar bloqueada. Estar na mesma subnet não torna os dois pedidos equivalentes. Acrescenta um cliente exterior à subnet, outro destino e UDP/443 como testes negativos. Guarda a matriz com a alteração para que a equipa de RUN consiga repetir a aceitação quando o autor da política estiver ausente.
2. Seguir a ordem efetiva
A lista inicial do exercício contém observe-report, legacy-trust, deny-workstation e report-client. Todas são regras simples de endereço e porta. A regra Trust ampla encontra ambos os clientes antes da exceção Block. A regra estreita existe, mas não decide esses fluxos. Retirar a regra ampla do modelo faz a estação chegar a Block e o cliente a Allow. Monitor regista e deixa continuar neste âmbito simplificado. Na documentação FTD há uma ressalva para Monitor com condições de camada sete: os pacotes iniciais podem passar antes da decisão posterior. O código não representa descoberta de aplicações nem essa fase inicial.
3. Executar e interpretar o modelo
Executa python3 run.py num diretório de trabalho local. O programa cria dados em memória e realiza 30 verificações, incluindo fronteiras de subnet, regras desativadas, portas inválidas, seleção de sessões e preservação das entradas. A saída inicial indica trust e inspectionRequested=false para a estação. A candidata indica block para a estação e allow com inspectionRequested=true para o cliente. Esta última propriedade é apenas uma declaração do modelo: nenhum pacote foi inspecionado. Antes de executar, prevê também o resultado se moveres report-client para cima de deny-workstation. O ensaio confirma que a estação passa a corresponder a Allow; é uma regressão funcional, mesmo com a mesma coleção de regras.
4. Passar da hipótese para a janela
Uma prova local não autoriza a promoção direta para produção. Prepara a diferença revista, o conjunto de equipamentos, a versão anterior e o critério de interrupção. Consulta a previsão de deployment e investiga avisos ou impacto indeterminado; não interpretes falta de estimativa como ausência de impacto. Na janela fictícia, o piloto fica num único caminho de teste com proprietário disponível. Confirma o estado por equipamento e executa ligações novas autorizadas, positivas e negativas. Uma sessão já aberta não é um substituto universal para testar uma nova decisão de política. A gestão e a recolha de evidência também precisam de continuar acessíveis durante a alteração.
5. Aceitar com evidência delimitada
O registo final junta revisão da política, alvo, hora, fluxo, regra observada e resultado da aplicação. Se a estação bloqueada falha como esperado mas o cliente autorizado também falha, ainda falta aceitação; identifica se o problema é política, rota, DNS ou serviço. Se houver rollback, confirma o estado restaurado e revê o que continua pendente na gestão antes de outro deployment. O laboratório não inclui NAT, IPv6, estado de sessões, descodificação, TLS, pré-filtros, herança ou motores de intrusão. Serve para tornar uma hipótese reproduzível, não para certificar FTD. Resumo: explicita a intenção, segue a ordem e verifica o resultado no âmbito correto.
"""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))
10.20.30.40 → 192.0.2.10 TCP/443: observe-report → legacy-trust. Sem legacy-trust: observe-report → deny-workstation.
Armadilhas comuns
Confundir presença de Block com eficácia; aceitar só testes positivos; tratar inspectionRequested como inspeção executada; extrapolar o modelo a camada sete.
Tópicos relacionados: Segmentação · Gestão de alterações · Telemetria
Uma regra só produz a decisão pretendida quando o fluxo chega a ela e o efeito é confirmado no alvo.
Referência: Access control rules · 350-701 SCOR v2.0, effective 2026-08-27; core component of CCNP Security