Skip to content

Commit

Permalink
Support istio auth policy synthesis (#10)
Browse files Browse the repository at this point in the history
* add support for istio auth policies synthesis - wip

* additional updates

* update documentation

* add support for baseline rules with istio policies synthesis - wip

* update generated istio auth policies with action ALLOW

* fix lint issues

* add a few fixes

* Update src/netpol_synth.py

Co-authored-by: Ziv Nevo <79099626+zivnevo@users.noreply.github.com>

* update submodule baseline-rules to point its master branch

Co-authored-by: Ziv Nevo <79099626+zivnevo@users.noreply.github.com>
  • Loading branch information
adisos and zivnevo authored Feb 28, 2022
1 parent b156e51 commit 281eacf
Show file tree
Hide file tree
Showing 8 changed files with 2,204 additions and 28 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# netpol-synthesizer
This application takes a JSON file, describing the connectivity in a given Kubernetes cluster,
and produces a set of Kubernetes NetworkPolicies that allow only the specified connectivity
and produces a set of policies (Kubernetes NetworkPolicies or Istio AuthorizationPolicies) that allow only the specified connectivity
and nothing more.

### Requirements:
Expand All @@ -18,11 +18,12 @@ pip install -r requirements.txt

### Usage:
```commandline
python src/netpol_synth.py [-o <output_file>] [-b baseline_rules_file] <connectivity_file>
python src/netpol_synth.py [-o <output_file>] [-b baseline_rules_file] [--policy_type=<policy_type_str>] <connectivity_file>
```
* `connectivity_file` is the path to a JSON file describing connectivity. This should be the output of running the [Network Topology Analyzer](https://github.com/np-guard/cluster-topology-analyzer).
* `output_file` *(optional)* is a path to output file where the resulting NetworkPolicy resources will be dumped (in YAML format). If omitted, output will be sent to stdout.
* `output_file` *(optional)* is a path to output file where the resulting policy resources will be dumped (in YAML format). If omitted, output will be sent to stdout.
* `baseline_rules_file` is a yaml file containing a list of baseline rules. See [these examples](https://github.com/np-guard/baseline-rules/tree/master/examples)
* `policy_type_str` is one of the values: `['k8s', 'istio']`, *default:* `k8s`

For example:
```commandline
Expand Down
2 changes: 1 addition & 1 deletion baseline-rules
145 changes: 121 additions & 24 deletions src/netpol_synth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@
common_services_dir = (base_dir / '../baseline-rules/src').resolve()
sys.path.insert(0, str(common_services_dir))

from baseline_rule import BaselineRules, BaselineRuleAction
from baseline_rule import BaselineRule, BaselineRules, BaselineRuleAction
from selector import LabelSelector, IpSelector


class NoAliasDumper(yaml.SafeDumper):
"""
This class is needed to avoid aliases and references in the generated yaml file
(so that users will be able to copy & paste individual NetworkPolicies)
"""

def ignore_aliases(self, data):
return True

Expand All @@ -38,6 +40,7 @@ class DeploymentLinks:
namespace: str = ''
selectors: Optional[dict] = None
labels: dict = field(default_factory=dict)
service_account_name: str = ''
ingress_conns: list = field(default_factory=list)
egress_conns: list = field(default_factory=list)

Expand All @@ -46,6 +49,7 @@ class NetpolSynthesizer:
"""
This is the main class for the conversion. Call its synthesize() method to generate k8s NetworkPolicy resources
"""

def __init__(self, connectivity_file, baseline_files):
self.deployments = {}
self.baseline_rules = BaselineRules(baseline_files)
Expand All @@ -66,9 +70,10 @@ def _process_connectivity_file(self, connectivity_file):
continue
for connection in element:
src_deploy = self._find_or_add_deployment(connection['source']['Resource'])
used_ports_src = connection['source']['Resource'].get('UsedPorts', [])
tgt_deploy = self._find_or_add_deployment(connection['target']['Resource'])
links = connection['link']['resource']
port_list = self._links_to_port_list(links.get('network'))
port_list = self._links_to_port_list(links.get('network'), used_ports_src)
if links.get('type') == 'LoadBalancer':
src_deploy = internet_src # A Service of type LoadBalancer exposes the target to the internet
elif not src_deploy.name:
Expand All @@ -80,8 +85,8 @@ def _process_connectivity_file(self, connectivity_file):
f'is disallowed by baseline rule {violated_baseline_rule}')
else:
if src_deploy not in [internet_src, namespace_src]:
self.deployments[src_deploy.name].egress_conns.append((tgt_deploy.selectors, port_list))
self.deployments[tgt_deploy.name].ingress_conns.append((src_deploy.selectors, port_list))
self.deployments[src_deploy.name].egress_conns.append((tgt_deploy, port_list))
self.deployments[tgt_deploy.name].ingress_conns.append((src_deploy, port_list))

def _find_or_add_deployment(self, resource):
"""
Expand All @@ -98,7 +103,8 @@ def _find_or_add_deployment(self, resource):
namespace = resource.get('namespace', '')
sel = self._selector_array_to_pod_selector(resource.get('selectors', []))
labels = resource.get('labels', {})
self.deployments[name] = DeploymentLinks(name, namespace, sel, labels)
sa_name = resource.get('serviceaccountname', 'default')
self.deployments[name] = DeploymentLinks(name, namespace, sel, labels, sa_name)
return self.deployments[name]

def _allowed_by_baseline(self, source_labels, target_labels, port_list):
Expand All @@ -119,7 +125,10 @@ def _selector_array_to_pod_selector(sel_array):
return {'podSelector': {'matchLabels': res}}

@staticmethod
def _links_to_port_list(links):
def _links_to_port_list(links, used_ports):
if used_ports:
# refer only to relevant ports (not all service ports are in use)
return [{'port': link.get('target_port')} for link in links if link.get('port') in used_ports]
return [{'port': link.get('target_port')} for link in links]

def _add_must_allow_connections(self):
Expand All @@ -128,19 +137,24 @@ def _add_must_allow_connections(self):
if not rule.action == BaselineRuleAction.allow:
continue
if rule.matches_source(deploy.labels):
deploy.egress_conns.append((rule.targets_as_netpol_peer(), rule.get_port_array()))
deploy.egress_conns.append((rule.target, rule.get_port_array()))
if rule.matches_target(deploy.labels):
deploy.ingress_conns.append((rule.sources_as_netpol_peer(), rule.get_port_array()))
deploy.ingress_conns.append((rule.source, rule.get_port_array()))

@staticmethod
def _xgress_conns_to_rules(conns, is_ingress):
def _xgress_conns_to_network_policy_rules(conns, is_ingress):
# TODO: peer type in connection has multiple options currently
# a conn is a tuple of (DeploymentLinks, port list) or (list[Selector], ports list)
res_rules = []
seen_rules = set()
for conn in conns:
rule = {'ports': conn[1]} if conn[1] else {}
if conn[0]:

selectors = conn[0].selectors if isinstance(conn[0], DeploymentLinks) else \
BaselineRule.selectors_as_netpol_peer(conn[0])
if selectors:
selector_key = 'from' if is_ingress else 'to'
rule[selector_key] = [conn[0]]
rule[selector_key] = [selectors]
rule_yaml = yaml.dump(rule)
if rule_yaml in seen_rules:
continue
Expand All @@ -154,33 +168,113 @@ def _xgress_conns_to_rules(conns, is_ingress):

return res_rules

def synthesize(self, output_file):
"""
Generates NetworkPolicies in yaml format based on the analysis done in the ctor.
If output file is specified, the output is dumped into the file. Otherwise, stdout is used
:param output_file: A file opened for writing
:return: None
"""
def _find_deployments_from_pod_selector(self, selectors):
# selectors is of type list[LabelSelector]
assert all(isinstance(selector, LabelSelector) for selector in selectors)
res = []
for deploy in self.deployments.values():
labels = deploy.labels
if all(selector.matches(labels) for selector in selectors):
res.append(deploy)
return res

def _get_auth_policy_source_from_baseline_rule_selector(self, selectors):
# selectors is of type list[LabelSelector] or IpSelector
if isinstance(selectors, IpSelector):
return {'ipBlocks': [str(selectors.ipn)]}

assert all(isinstance(selector, LabelSelector) for selector in selectors)
src_deployments = self._find_deployments_from_pod_selector(selectors)
return self._gst_auth_policy_source_from_deployments(src_deployments)

def _gst_auth_policy_source_from_deployments(self, deployments):
res = {}
principals_list = self._get_principals_list_from_deployments(deployments)
if principals_list:
res['principals'] = principals_list
return res

@staticmethod
def _get_principals_list_from_deployments(deployments):
principals_list = []
for src_deployment in deployments:
if src_deployment.service_account_name != '':
ns = src_deployment.namespace or 'default'
principals_list.append(f"cluster.local/ns/{ns}/sa/{src_deployment.service_account_name}")
return principals_list

def _ingress_conns_to_auth_policy_rules(self, conns):
# a conn is a tuple of (DeploymentLinks, port list) or (list[Selector], ports list)
res_rules = []
seen_rules = set()
for conn in conns:
rule = {}
if not isinstance(conn[0], DeploymentLinks): # connection from baseline rule with peer as selector
src_dict = self._get_auth_policy_source_from_baseline_rule_selector(conn[0])
else:
src_dict = self._gst_auth_policy_source_from_deployments([conn[0]])
if src_dict:
from_list = [{'source': src_dict}]
rule['from'] = from_list
if conn[1]:
ports_list = {'ports': [str(port['port']) for port in conn[1]]}
to_list = [{'operation': ports_list}]
rule['to'] = to_list
rule_yaml = yaml.dump(rule)
if rule_yaml in seen_rules:
continue
seen_rules.add(rule_yaml)
res_rules.append(rule)
return res_rules

def _synthesize_istio_authorization_policies(self):
authpolicies = []
for deployment in self.deployments.values():
metadata = {'name': deployment.name + '-authpol'}
if deployment.namespace:
metadata['namespace'] = deployment.namespace
spec = {'selector': deployment.selectors['podSelector'],
'action': 'ALLOW',
'rules': self._ingress_conns_to_auth_policy_rules(deployment.ingress_conns)}
authpol = {'apiVersion': 'security.istio.io/v1beta1',
'kind': 'AuthorizationPolicy',
'metadata': metadata,
'spec': spec}
authpolicies.append(authpol)
return authpolicies

def _synthesize_k8s_network_policies(self):
netpols = []
for deployment in self.deployments.values():
metadata = {'name': deployment.name + '-netpol'}
if deployment.namespace:
metadata['namespace'] = deployment.namespace
spec = {'podSelector': deployment.selectors['podSelector'],
'policyTypes': ['Ingress', 'Egress'],
'ingress': self._xgress_conns_to_rules(deployment.ingress_conns, True),
'egress': self._xgress_conns_to_rules(deployment.egress_conns, False)}
'ingress': self._xgress_conns_to_network_policy_rules(deployment.ingress_conns, True),
'egress': self._xgress_conns_to_network_policy_rules(deployment.egress_conns, False)}
netpol = {'apiVersion': 'networking.k8s.io/v1',
'kind': 'NetworkPolicy',
'metadata': metadata,
'spec': spec}
netpols.append(netpol)
return netpols

def synthesize(self, output_file, policy_type):
"""
Generates NetworkPolicies/AuthorizationPolicies in yaml format based on the analysis done in the ctor.
If output file is specified, the output is dumped into the file. Otherwise, stdout is used
:param output_file: A file opened for writing
:param policy_type: the required policy type (k8s/istio)
:return: None
"""
policy_list = self._synthesize_istio_authorization_policies() if policy_type == 'istio' \
else self._synthesize_k8s_network_policies()
if output_file:
yaml.dump_all(netpols, output_file, Dumper=NoAliasDumper)
yaml.dump_all(policy_list, output_file, Dumper=NoAliasDumper)
print(f'\nNetwork Policies were successfully written to {output_file.name}')
else:
print(yaml.dump_all(netpols))
print(yaml.dump_all(policy_list))


def netpol_synth_main(args=None):
Expand All @@ -189,15 +283,18 @@ def netpol_synth_main(args=None):
:param args: Commandline arguments
:return: None
"""
parser = argparse.ArgumentParser(description='A generator for K8s Network Policies')
parser = argparse.ArgumentParser(
description='A generator for micro-segmentation policies: K8s Network Policies / Istio Authorization Policies')
parser.add_argument('connectivity_file', type=open, help='A json input file describing connectivity')
parser.add_argument('--baseline', '-b', type=str, metavar='FILE', action='append',
help='A baseline-requirements file')
parser.add_argument('--output', '-o', type=argparse.FileType('w'), metavar='FILE',
help='Output file for NetworkPolicy resources')
parser.add_argument('--policy_type', choices=['k8s', 'istio'], help='Choose policy type to generate (k8s/istio)',
default='k8s')
args = parser.parse_args(args)

NetpolSynthesizer(args.connectivity_file, args.baseline).synthesize(args.output)
NetpolSynthesizer(args.connectivity_file, args.baseline).synthesize(args.output, args.policy_type)


if __name__ == "__main__":
Expand Down
Loading

0 comments on commit 281eacf

Please sign in to comment.