Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions capirca/lib/ciscoasa.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

"""Cisco ASA renderer."""

import copy
import datetime
import ipaddress
import logging
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -274,8 +276,18 @@ 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
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,
Expand Down Expand Up @@ -320,6 +332,13 @@ 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'], ['mixed']):
raise UnsupportedCiscoAccessListError(
'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
Expand All @@ -333,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)))
# 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))

Expand Down
13 changes: 12 additions & 1 deletion doc/generators/ciscoasa.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@

The ciscoasa header designation has the following format:
```
target:: ciscoasa [filter name]
target:: ciscoasa [filter name] [inet|inet6|mixed]
```

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.

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.
Expand Down
158 changes: 158 additions & 0 deletions tests/lib/ciscoasa_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -140,6 +141,163 @@ 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)

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()
Loading