From e2e5dd74be7d3aee0162a13d35d16afaa0d84c15 Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Fri, 2 Oct 2026 23:54:09 +0530 Subject: [PATCH 1/2] fix: enable IPv6 in Cisco ASA generator headers --- capirca/lib/ciscoasa.py | 19 ++++++-- doc/generators/ciscoasa.md | 9 +++- tests/lib/ciscoasa_test.py | 94 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/capirca/lib/ciscoasa.py b/capirca/lib/ciscoasa.py index a9c6df0f..7e0deb26 100644 --- a/capirca/lib/ciscoasa.py +++ b/capirca/lib/ciscoasa.py @@ -274,8 +274,15 @@ def _TermletToStr(self, filter_name, action, proto, saddr, sport, daddr, # pyre ret_lines = [] - # str(icmp_type) is needed to ensure 0 maps to '0' instead of FALSE - icmp_type = str(cisco.PortMap.GetProtocol(icmp_type, 'icmp')) + # IPv4 ICMP names have different meanings from the IPv6 type numbers. + if self.af == 4: + icmp_type = cisco.PortMap.GetProtocol(icmp_type, 'icmp') + icmp_type = str(icmp_type) + + if self.af == 6: + saddr = 'any6' if saddr == 'any' else saddr + daddr = 'any6' if daddr == 'any' else daddr + proto = 'icmp6' if proto == 'icmpv6' else proto ret_lines.append('access-list %s extended %s %s %s %s %s %s %s %s' % (filter_name, action, proto, saddr, @@ -320,6 +327,12 @@ def _TranslatePolicy(self, pol, exp_info): for header, terms in self.policy.filters: filter_name = header.FilterName('ciscoasa') + filter_options = header.FilterOptions('ciscoasa')[1:] + if filter_options not in ([], ['inet'], ['inet6']): + raise UnsupportedCiscoAccessListError( + 'Expected an optional inet or inet6 address family, ' + f'got {filter_options}') + af = 6 if filter_options == ['inet6'] else 4 new_terms = [] # now add the terms @@ -333,7 +346,7 @@ def _TranslatePolicy(self, pol, exp_info): 'will not be rendered.', term.name, filter_name) continue - new_terms.append(str(Term(term, filter_name))) + new_terms.append(str(Term(term, filter_name, af=af))) self.ciscoasa_policies.append((header, filter_name, new_terms)) diff --git a/doc/generators/ciscoasa.md b/doc/generators/ciscoasa.md index e83f8279..fb768216 100644 --- a/doc/generators/ciscoasa.md +++ b/doc/generators/ciscoasa.md @@ -2,8 +2,15 @@ The ciscoasa header designation has the following format: ``` -target:: ciscoasa [filter name] +target:: ciscoasa [filter name] [inet|inet6] ``` + +The address family defaults to `inet` (IPv4). Use `inet6` to render IPv6 +addresses and ICMPv6 terms, for example `target:: ciscoasa inbound-v6 inet6`. +IPv6 output uses ASA 9.0 or later extended ACL syntax, with `any6` for +unspecified addresses and `icmp6` for the `icmpv6` protocol. The default IPv4 +output is unchanged. + ## Term Format * _action::_ The action to take when matched. See Actions section for valid options. * _comment::_ A text comment enclosed in double-quotes. The comment can extend over multiple lines if desired, until a closing quote is encountered. diff --git a/tests/lib/ciscoasa_test.py b/tests/lib/ciscoasa_test.py index 43b4f0df..8679dfbe 100644 --- a/tests/lib/ciscoasa_test.py +++ b/tests/lib/ciscoasa_test.py @@ -18,6 +18,7 @@ from unittest import mock from capirca.lib import ciscoasa +from capirca.lib import nacaddr from capirca.lib import naming from capirca.lib import policy @@ -140,6 +141,99 @@ def testBuildWarningTokens(self): self.assertEqual(st, SUPPORTED_TOKENS) self.assertEqual(sst, SUPPORTED_SUB_TOKENS) + def test_ipv6_addresses(self): + """Render only IPv6 members of dual-stack address definitions.""" + self.naming.GetNetAddr.side_effect = lambda name: { + 'SOURCE': [nacaddr.IP('2001:db8::/64'), nacaddr.IP('192.0.2.0/24')], + 'DESTINATION': [nacaddr.IP('2001:db8:1::1/128'), + nacaddr.IP('198.51.100.1/32')], + }[name] + text = GOOD_HEADER.replace('test-filter', 'test-filter inet6') + """ +term ipv6 { + action:: accept + protocol:: tcp + source-address:: SOURCE + destination-address:: DESTINATION +} +""" + rendered = str(ciscoasa.CiscoASA( + policy.ParsePolicy(text, self.naming), EXP_INFO)) + self.assertIn('permit tcp 2001:db8::/64 host 2001:db8:1::1', rendered) + self.assertNotIn('192.0.2.', rendered) + self.assertNotIn('198.51.100.', rendered) + + def test_ipv6_unspecified_addresses(self): + """Keep IPv6 wildcards from also matching IPv4 traffic.""" + text = GOOD_HEADER.replace('test-filter', 'test-filter inet6') + """ +term ipv6 { + action:: accept + protocol:: tcp +} +""" + rendered = str(ciscoasa.CiscoASA( + policy.ParsePolicy(text, self.naming), EXP_INFO)) + self.assertIn('permit tcp any6 any6', rendered) + + def test_ipv6_icmp(self): + """Use the ASA protocol name and IPv6 ICMP type numbers.""" + text = GOOD_HEADER.replace('test-filter', 'test-filter inet6') + """ +term ping { + action:: accept + protocol:: icmpv6 + icmp-type:: echo-request +} +""" + rendered = str(ciscoasa.CiscoASA( + policy.ParsePolicy(text, self.naming), EXP_INFO)) + self.assertIn('permit icmp6 any6 any6 128', rendered) + rendered = str(ciscoasa.CiscoASA(policy.ParsePolicy( + text.replace('echo-request', 'time-exceeded'), self.naming), EXP_INFO)) + self.assertIn('permit icmp6 any6 any6 3', rendered) + + def test_address_family_is_per_filter(self): + """Do not carry the family over to the next filter.""" + term = """ +term allow-tcp { + action:: accept + protocol:: tcp +} +""" + text = (GOOD_HEADER.replace('test-filter', 'v6 inet6') + term + + GOOD_HEADER.replace('test-filter', 'v4') + term) + rendered = str(ciscoasa.CiscoASA( + policy.ParsePolicy(text, self.naming), EXP_INFO)) + self.assertIn('access-list v6 extended permit tcp any6 any6', rendered) + self.assertIn('access-list v4 extended permit tcp any any', rendered) + + def test_default_address_family_unchanged(self): + """Preserve the default IPv4 output with or without inet.""" + self.naming.GetNetAddr.return_value = [nacaddr.IP('192.0.2.0/24'), + nacaddr.IP('2001:db8::/64')] + text = GOOD_HEADER + """ +term ipv4 { + action:: accept + protocol:: tcp + source-address:: SOURCE +} +""" + rendered = str(ciscoasa.CiscoASA( + policy.ParsePolicy(text, self.naming), EXP_INFO)) + explicit = str(ciscoasa.CiscoASA(policy.ParsePolicy( + text.replace('test-filter', 'test-filter inet'), self.naming), + EXP_INFO)) + self.assertEqual(rendered, explicit) + self.assertIn('permit tcp 192.0.2.0 255.255.255.0 any', rendered) + self.assertNotIn('2001:db8', rendered) + + def test_invalid_address_family(self): + """Reject unsupported or conflicting header options.""" + for option in ('inet7', 'inet inet6'): + with self.subTest(option=option): + text = GOOD_HEADER.replace('test-filter', 'test-filter ' + option) + with self.assertRaises(ciscoasa.UnsupportedCiscoAccessListError): + ciscoasa.CiscoASA(policy.ParsePolicy(text + GOOD_TERM_1, + self.naming), EXP_INFO) + if __name__ == '__main__': absltest.main() From 7e88b2193205c5bffc0972d63394c925a53862e4 Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Fri, 13 Nov 2026 09:12:14 +0530 Subject: [PATCH 2/2] feat: render mixed IPv4 and IPv6 Cisco ASA policies --- capirca/lib/ciscoasa.py | 20 +++++++++--- doc/generators/ciscoasa.md | 6 +++- tests/lib/ciscoasa_test.py | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/capirca/lib/ciscoasa.py b/capirca/lib/ciscoasa.py index 7e0deb26..0eb99abd 100644 --- a/capirca/lib/ciscoasa.py +++ b/capirca/lib/ciscoasa.py @@ -15,6 +15,7 @@ """Cisco ASA renderer.""" +import copy import datetime import ipaddress import logging @@ -59,12 +60,13 @@ class NoCiscoPolicyError(Error): class Term(cisco.Term): """A single ACL Term.""" - def __init__(self, term, filter_name, af=4): + def __init__(self, term, filter_name, af=4, mixed=False): self.term = term self.filter_name = filter_name self.options = [] assert af in (4, 6) self.af = af + self.mixed = mixed def __str__(self): # Verify platform specific terms. Skip whole term if platform does not @@ -283,6 +285,9 @@ def _TermletToStr(self, filter_name, action, proto, saddr, sport, daddr, # pyre saddr = 'any6' if saddr == 'any' else saddr daddr = 'any6' if daddr == 'any' else daddr proto = 'icmp6' if proto == 'icmpv6' else proto + elif self.mixed: + saddr = 'any4' if saddr == 'any' else saddr + daddr = 'any4' if daddr == 'any' else daddr ret_lines.append('access-list %s extended %s %s %s %s %s %s %s %s' % (filter_name, action, proto, saddr, @@ -328,11 +333,12 @@ def _TranslatePolicy(self, pol, exp_info): for header, terms in self.policy.filters: filter_name = header.FilterName('ciscoasa') filter_options = header.FilterOptions('ciscoasa')[1:] - if filter_options not in ([], ['inet'], ['inet6']): + if filter_options not in ([], ['inet'], ['inet6'], ['mixed']): raise UnsupportedCiscoAccessListError( - 'Expected an optional inet or inet6 address family, ' + 'Expected an optional inet, inet6 or mixed address family, ' f'got {filter_options}') af = 6 if filter_options == ['inet6'] else 4 + mixed = filter_options == ['mixed'] new_terms = [] # now add the terms @@ -346,7 +352,13 @@ def _TranslatePolicy(self, pol, exp_info): 'will not be rendered.', term.name, filter_name) continue - new_terms.append(str(Term(term, filter_name, af=af))) + # Keep both families of a term before the next term in the same ACL. + # Verbatim text is already complete and must only be emitted once. + families = (4, 6) if mixed and not term.verbatim else (af,) + for family in families: + rendered_term = copy.deepcopy(term) if mixed else term + new_terms.append(str(Term(rendered_term, filter_name, af=family, + mixed=mixed))) self.ciscoasa_policies.append((header, filter_name, new_terms)) diff --git a/doc/generators/ciscoasa.md b/doc/generators/ciscoasa.md index fb768216..6217e522 100644 --- a/doc/generators/ciscoasa.md +++ b/doc/generators/ciscoasa.md @@ -2,7 +2,7 @@ The ciscoasa header designation has the following format: ``` -target:: ciscoasa [filter name] [inet|inet6] +target:: ciscoasa [filter name] [inet|inet6|mixed] ``` The address family defaults to `inet` (IPv4). Use `inet6` to render IPv6 @@ -11,6 +11,10 @@ IPv6 output uses ASA 9.0 or later extended ACL syntax, with `any6` for unspecified addresses and `icmp6` for the `icmpv6` protocol. The default IPv4 output is unchanged. +Use `mixed` to emit both families into one ACL. Each term's IPv4 and IPv6 +rules precede the next term, preserving policy order. Mixed output uses +`any4` and `any6` for family-specific wildcards; verbatim text is emitted once. + ## Term Format * _action::_ The action to take when matched. See Actions section for valid options. * _comment::_ A text comment enclosed in double-quotes. The comment can extend over multiple lines if desired, until a closing quote is encountered. diff --git a/tests/lib/ciscoasa_test.py b/tests/lib/ciscoasa_test.py index 8679dfbe..701025ab 100644 --- a/tests/lib/ciscoasa_test.py +++ b/tests/lib/ciscoasa_test.py @@ -234,6 +234,70 @@ def test_invalid_address_family(self): ciscoasa.CiscoASA(policy.ParsePolicy(text + GOOD_TERM_1, self.naming), EXP_INFO) + def test_mixed_addresses_and_rule_order(self): + """Keep both families of each term ahead of the following term.""" + self.naming.GetNetAddr.return_value = [nacaddr.IP('192.0.2.0/24'), + nacaddr.IP('2001:db8::/64')] + text = GOOD_HEADER.replace('test-filter', 'test-filter mixed') + """ +term allow-source { + action:: accept + protocol:: tcp + source-address:: SOURCE +} +term deny-rest { + action:: deny +} +""" + rendered = str(ciscoasa.CiscoASA( + policy.ParsePolicy(text, self.naming), EXP_INFO)) + lines = [line for line in rendered.splitlines() if ' extended ' in line] + self.assertEqual(lines, [ + 'access-list test-filter extended permit tcp ' + '192.0.2.0 255.255.255.0 any4', + 'access-list test-filter extended permit tcp 2001:db8::/64 any6', + 'access-list test-filter extended deny ip any4 any4', + 'access-list test-filter extended deny ip any6 any6', + ]) + self.assertEqual(rendered.count('clear configure access-list'), 1) + + def test_mixed_icmp(self): + """Render each ICMP protocol only for its matching address family.""" + text = GOOD_HEADER.replace('test-filter', 'test-filter mixed') + """ +term ping4 { + action:: accept + protocol:: icmp + icmp-type:: echo-request +} +term ping6 { + action:: accept + protocol:: icmpv6 + icmp-type:: echo-request +} +""" + rendered = str(ciscoasa.CiscoASA( + policy.ParsePolicy(text, self.naming), EXP_INFO)) + lines = [line for line in rendered.splitlines() if ' extended ' in line] + self.assertEqual(lines, [ + 'access-list test-filter extended permit icmp any4 any4 echo', + 'access-list test-filter extended permit icmp6 any6 any6 128', + ]) + + def test_mixed_verbatim_once_and_owner_not_mutated(self): + """Do not duplicate verbatim rules or mutate shared owner comments.""" + text = GOOD_HEADER.replace('test-filter', 'test-filter mixed') + text += GOOD_TERM_1 + """ +term owned { + owner:: network-team + action:: accept + protocol:: tcp +} +""" + pol = policy.ParsePolicy(text, self.naming) + rendered = str(ciscoasa.CiscoASA(pol, EXP_INFO)) + self.assertEqual(rendered.count('mary had a little lamb'), 1) + self.assertEqual(rendered.count('Owner: network-team'), 2) + self.assertNotIn('Owner: network-team', pol.filters[0][1][-1].comment) + if __name__ == '__main__': absltest.main()