From b32975c82cd1a46960fa41ea6b574a0e75fe4df3 Mon Sep 17 00:00:00 2001 From: sdilli Date: Fri, 28 Nov 2025 10:59:30 +0530 Subject: [PATCH 1/4] Normalize Type-6 passwords in configdelta --- ncdiff/src/yang/ncdiff/config.py | 65 +++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/ncdiff/src/yang/ncdiff/config.py b/ncdiff/src/yang/ncdiff/config.py index 39d47ebd..19cf458b 100755 --- a/ncdiff/src/yang/ncdiff/config.py +++ b/ncdiff/src/yang/ncdiff/config.py @@ -564,18 +564,63 @@ def __init__(self, config_src, config_dst=None, delta=None, def device(self): return self.config_src.device + @staticmethod + def _mask_encrypted_passwords(xml_text): + """ + Mask encrypted passwords so that salted type-6 values are never pushed + back to the device via NETCONF/YANG. IOS-XE does not accept hashed type-6 + passwords, so we replace them with neutral placeholders. + """ + + # Mask generic HASH + xml_text = re.sub( + r'[^<]+', + r'', + xml_text + ) + + # Mask HASH (enable password) + xml_text = re.sub( + r'[^<]+', + r'', + xml_text + ) + + # SNMPv3 auth password + xml_text = re.sub( + r'.*?[^<]+', + r'', + xml_text, + flags=re.DOTALL + ) + + # SNMPv3 priv password + xml_text = re.sub( + r'.*?[^<]+', + r'', + xml_text, + flags=re.DOTALL + ) + + return xml_text + @property def nc(self): - return NetconfCalculator( - self.device, - self.config_dst.ele, self.config_src.ele, - preferred_create=self.preferred_create, - preferred_replace=self.preferred_replace, - preferred_delete=self.preferred_delete, - diff_type=self.diff_type, - replace_depth=self.replace_depth, - replace_xpath=self.replace_xpath, - ).sub + raw_nc = NetconfCalculator( + self.device, + self.config_dst.ele, self.config_src.ele, + preferred_create=self.preferred_create, + preferred_replace=self.preferred_replace, + preferred_delete=self.preferred_delete, + diff_type=self.diff_type, + replace_depth=self.replace_depth, + replace_xpath=self.replace_xpath, + ).sub + xml_text = etree.tostring(raw_nc, encoding="unicode") + + xml_text = self._mask_encrypted_passwords(xml_text) + + return etree.fromstring(xml_text) @property def rc(self): From 19991cb10697e79dd831316496d834a664054aa9 Mon Sep 17 00:00:00 2001 From: sdilli Date: Fri, 5 Dec 2025 11:15:18 +0530 Subject: [PATCH 2/4] Mask encrypted values in NETCONF get-config without affecting edit-config --- ncdiff/src/yang/ncdiff/config.py | 67 ++++++-------------------------- ncdiff/src/yang/ncdiff/device.py | 30 +++++++++++++- 2 files changed, 40 insertions(+), 57 deletions(-) diff --git a/ncdiff/src/yang/ncdiff/config.py b/ncdiff/src/yang/ncdiff/config.py index 19cf458b..58b621da 100755 --- a/ncdiff/src/yang/ncdiff/config.py +++ b/ncdiff/src/yang/ncdiff/config.py @@ -564,63 +564,20 @@ def __init__(self, config_src, config_dst=None, delta=None, def device(self): return self.config_src.device - @staticmethod - def _mask_encrypted_passwords(xml_text): - """ - Mask encrypted passwords so that salted type-6 values are never pushed - back to the device via NETCONF/YANG. IOS-XE does not accept hashed type-6 - passwords, so we replace them with neutral placeholders. - """ - - # Mask generic HASH - xml_text = re.sub( - r'[^<]+', - r'', - xml_text - ) - - # Mask HASH (enable password) - xml_text = re.sub( - r'[^<]+', - r'', - xml_text - ) - - # SNMPv3 auth password - xml_text = re.sub( - r'.*?[^<]+', - r'', - xml_text, - flags=re.DOTALL - ) - - # SNMPv3 priv password - xml_text = re.sub( - r'.*?[^<]+', - r'', - xml_text, - flags=re.DOTALL - ) - - return xml_text - @property def nc(self): - raw_nc = NetconfCalculator( - self.device, - self.config_dst.ele, self.config_src.ele, - preferred_create=self.preferred_create, - preferred_replace=self.preferred_replace, - preferred_delete=self.preferred_delete, - diff_type=self.diff_type, - replace_depth=self.replace_depth, - replace_xpath=self.replace_xpath, - ).sub - xml_text = etree.tostring(raw_nc, encoding="unicode") - - xml_text = self._mask_encrypted_passwords(xml_text) - - return etree.fromstring(xml_text) + src = getattr(self.config_src, "ele_original", self.config_src.ele) + dst = getattr(self.config_dst, "ele_original", self.config_dst.ele) + return NetconfCalculator( + self.device, + dst, src, + preferred_create=self.preferred_create, + preferred_replace=self.preferred_replace, + preferred_delete=self.preferred_delete, + diff_type=self.diff_type, + replace_depth=self.replace_depth, + replace_xpath=self.replace_xpath, + ).sub @property def rc(self): diff --git a/ncdiff/src/yang/ncdiff/device.py b/ncdiff/src/yang/ncdiff/device.py index b76f6170..4f877111 100755 --- a/ncdiff/src/yang/ncdiff/device.py +++ b/ncdiff/src/yang/ncdiff/device.py @@ -1,6 +1,6 @@ import os import re - +from copy import deepcopy import logging from lxml import etree from ncclient import manager, operations, transport, xml_ @@ -361,7 +361,7 @@ def load_model(self, model): else: logger.info(f'Model {model} is already loaded') return self.models[model] - + def execute(self, operation, *args, **kwargs): '''execute @@ -503,6 +503,25 @@ def take_notification(self, block=True, timeout=None): isinstance(reply, transport.notify.Notification): reply.ns = self._get_ns(reply._root_ele) return reply + + def _mask_encrypted_values(self, element): + + for node in element.iter(): + if not node.text: + continue + + text = node.text.strip() + tag = node.tag.lower() + + if "snmp" in tag and ("password" in tag or "secret" in tag): + node.text = "ENCRYPTED" + continue + + # IOS-XE encrypted secrets are ALWAYS long (Type 6) + if ("password" in tag or "secret" in tag) and len(text) > 12: + node.text = "ENCRYPTED" + continue + def extract_config(self, reply, type='netconf'): '''extract_config @@ -551,6 +570,13 @@ def remove_read_only(parent): config = Config(self, reply) remove_read_only(config.ele) + config.ele_original = deepcopy(config.ele) + if type == 'netconf': + masked_copy = deepcopy(config.ele_original) + self._mask_encrypted_values(masked_copy) + config.ele = masked_copy + else: + config.ele = config.ele_original return config def get_schema_node(self, config_node): From a615ae76fd6351b091a5482448c00772f9bdc989 Mon Sep 17 00:00:00 2001 From: sdilli Date: Fri, 5 Dec 2025 17:06:29 +0530 Subject: [PATCH 3/4] Added change log --- .../undistributed/change_log_device_202512051700.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 ncdiff/docs/changelog/undistributed/change_log_device_202512051700.rst diff --git a/ncdiff/docs/changelog/undistributed/change_log_device_202512051700.rst b/ncdiff/docs/changelog/undistributed/change_log_device_202512051700.rst new file mode 100644 index 00000000..a76bf53f --- /dev/null +++ b/ncdiff/docs/changelog/undistributed/change_log_device_202512051700.rst @@ -0,0 +1,7 @@ + + +-------------------------------------------------------------------------------- + Fix +-------------------------------------------------------------------------------- +* yang.ncdiff + * NETCONF masking to hide encrypted values in get-config while preserving unmasked data for edit-config. \ No newline at end of file From fb8fca8adf747a37c29f11b66801725d88ba9065 Mon Sep 17 00:00:00 2001 From: sdilli Date: Mon, 8 Dec 2025 22:20:42 +0530 Subject: [PATCH 4/4] Addressed the comments --- ncdiff/src/yang/ncdiff/device.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ncdiff/src/yang/ncdiff/device.py b/ncdiff/src/yang/ncdiff/device.py index 4f877111..a4b043fe 100755 --- a/ncdiff/src/yang/ncdiff/device.py +++ b/ncdiff/src/yang/ncdiff/device.py @@ -505,6 +505,19 @@ def take_notification(self, block=True, timeout=None): return reply def _mask_encrypted_values(self, element): + """ + This function walks the given XML element tree and replaces sensitive + encrypted values with the placeholder string "ENCRYPTED". Masking is applied + only to GET/GET-CONFIG output. + + Note: + Masking is used only for output visibility. Internal diff/edit-config + logic must operate on unmasked values to avoid device-side failures. + + Returns: + + None. The input element tree is modified in place. + """ for node in element.iter(): if not node.text: @@ -512,7 +525,8 @@ def _mask_encrypted_values(self, element): text = node.text.strip() tag = node.tag.lower() - + + # Mask all SNMPv3 auth/priv passwords and secrets explicitly if "snmp" in tag and ("password" in tag or "secret" in tag): node.text = "ENCRYPTED" continue @@ -570,12 +584,17 @@ def remove_read_only(parent): config = Config(self, reply) remove_read_only(config.ele) + + # deepcopy of config.ele to store unmasked ele_original config.ele_original = deepcopy(config.ele) + if type == 'netconf': + # Apply masking only to NETCONF get/get-config output masked_copy = deepcopy(config.ele_original) self._mask_encrypted_values(masked_copy) config.ele = masked_copy else: + # Use unmasked config for all other operations config.ele = config.ele_original return config