Skip to content

Commit 5b4d5c2

Browse files
Test Userstevefulme1
authored andcommitted
fix(security): address code review and security findings
- Remove plugin_routing self-redirect from runtime.yml - Strip reserved keys (offset/limit) from device_filters to prevent pagination override injection - Replace bare except Exception with specific ApiError/AnsibleError catches - Replace silent except/pass in site hierarchy with warning on failure - Add warnings for skipped devices with no usable hostname - Add warnings for failed tag member fetches instead of silent continue - Include error details in tag list fetch failure warning - Validate cached data keys before use, refetch on corruption - Add safe fallback with warning in _device_to_dict conversion - Skip sites with missing id/name with warning - Use vault reference in EXAMPLES instead of cleartext password - Switch from count-based pagination to fetch-until-empty strategy to avoid count/filter mismatch
1 parent 0ed2bb5 commit 5b4d5c2

2 files changed

Lines changed: 92 additions & 28 deletions

File tree

meta/runtime.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,2 @@
11
---
22
requires_ansible: '>=2.16.0'
3-
plugin_routing:
4-
inventory:
5-
catalystcenter:
6-
redirect: cisco.catalystcenter.catalystcenter

plugins/inventory/catalystcenter.py

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
description:
105105
- Additional keyword arguments passed to the Catalyst Center C(get_device_list) API call.
106106
- Use this to pre-filter devices by family, role, management IP, etc.
107+
- Keys C(offset) and C(limit) are reserved for pagination and will be stripped if provided.
107108
type: dict
108109
default: {}
109110
api_page_size:
@@ -145,11 +146,11 @@
145146
"""
146147

147148
EXAMPLES = r"""
148-
# Minimal configuration
149+
# Minimal configuration (use ansible-vault for the password in production)
149150
plugin: cisco.catalystcenter.catalystcenter
150151
host: catalyst.example.com
151152
username: admin
152-
password: secret
153+
password: "{{ vault_cc_password }}"
153154
validate_certs: false
154155
155156
# With environment variables (CATALYSTCENTER_HOST, CATALYSTCENTER_USERNAME, etc.)
@@ -215,6 +216,7 @@
215216
except ImportError:
216217
HAS_CATALYSTCENTERSDK = False
217218

219+
_PAGINATION_RESERVED_KEYS = frozenset(("offset", "limit"))
218220

219221
_NETWORK_OS_MAP = {
220222
"ios": {
@@ -255,6 +257,8 @@
255257
ord("."): "_",
256258
}
257259

260+
_EXPECTED_DATA_KEYS = frozenset(("devices", "sites", "device_site_map", "tags"))
261+
258262

259263
class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):
260264
"""Cisco Catalyst Center dynamic inventory plugin."""
@@ -289,6 +293,17 @@ def parse(self, inventory, loader, path, cache=True):
289293
except KeyError:
290294
cache_needs_update = True
291295

296+
if data is not None:
297+
missing = _EXPECTED_DATA_KEYS - set(data.keys())
298+
if missing:
299+
self.display.warning(
300+
"Cached inventory data is missing keys {0}, refetching".format(
301+
sorted(missing)
302+
)
303+
)
304+
data = None
305+
cache_needs_update = True
306+
292307
if data is None:
293308
data = self._fetch_all_data()
294309

@@ -319,7 +334,7 @@ def _get_client(self):
319334
verify=self.get_option("validate_certs"),
320335
debug=self.get_option("debug"),
321336
)
322-
except Exception as e:
337+
except (ApiError, ConnectionError, OSError) as e:
323338
raise AnsibleError(
324339
"Failed to connect to Catalyst Center at {0}:{1}: {2}".format(
325340
host, port, to_native(e)
@@ -351,33 +366,39 @@ def _fetch_devices(self, client):
351366
device_filters = self.get_option("device_filters")
352367
include_aps = self.get_option("include_aps")
353368

354-
try:
355-
count_resp = client.devices.get_device_count()
356-
device_count = count_resp.response
357-
except (ApiError, Exception) as e:
358-
raise AnsibleParserError(
359-
"Failed to get device count: {0}".format(to_native(e))
369+
safe_filters = {
370+
k: v for k, v in device_filters.items()
371+
if k not in _PAGINATION_RESERVED_KEYS
372+
}
373+
if len(safe_filters) != len(device_filters):
374+
self.display.warning(
375+
"device_filters contained reserved keys {0} which were "
376+
"stripped to avoid breaking pagination".format(
377+
sorted(set(device_filters) & _PAGINATION_RESERVED_KEYS)
378+
)
360379
)
361380

362-
pages = max(1, math.ceil(device_count / page_size))
363381
all_devices = []
382+
offset = 1
364383

365-
for page in range(pages):
366-
offset = page * page_size + 1
384+
while True:
367385
try:
368386
resp = client.devices.get_device_list(
369387
offset=offset,
370388
limit=page_size,
371-
**device_filters
389+
**safe_filters
372390
)
373391
page_devices = resp.response if resp.response else []
374-
except (ApiError, Exception) as e:
392+
except ApiError as e:
375393
raise AnsibleParserError(
376394
"Failed to get device list (offset={0}): {1}".format(
377395
offset, to_native(e)
378396
)
379397
)
380398

399+
if not page_devices:
400+
break
401+
381402
for device in page_devices:
382403
device_dict = self._device_to_dict(device)
383404

@@ -388,6 +409,11 @@ def _fetch_devices(self, client):
388409

389410
all_devices.append(device_dict)
390411

412+
if len(page_devices) < page_size:
413+
break
414+
415+
offset += page_size
416+
391417
return all_devices
392418

393419
def _device_to_dict(self, device):
@@ -402,18 +428,37 @@ def _device_to_dict(self, device):
402428
if not k.startswith("_")
403429
}
404430
except TypeError:
405-
return dict(device)
431+
self.display.warning(
432+
"Unexpected device object type ({0}), "
433+
"attempting dict() conversion".format(type(device).__name__)
434+
)
435+
try:
436+
return dict(device)
437+
except (TypeError, ValueError) as e:
438+
raise AnsibleParserError(
439+
"Cannot convert device to dict: {0}".format(to_native(e))
440+
)
406441

407442
def _fetch_site_topology(self, client):
408443
try:
409444
resp = client.topology.get_site_topology()
410445
sites = resp.response.sites if resp.response and resp.response.sites else []
411-
except (ApiError, Exception) as e:
446+
except ApiError as e:
412447
raise AnsibleParserError(
413448
"Failed to get site topology: {0}".format(to_native(e))
414449
)
415450

416-
return [self._site_to_dict(s) for s in sites]
451+
result = []
452+
for s in sites:
453+
site_dict = self._site_to_dict(s)
454+
if not site_dict.get("id") or not site_dict.get("name"):
455+
self.display.warning(
456+
"Skipping site with missing id or name: {0}".format(site_dict)
457+
)
458+
continue
459+
result.append(site_dict)
460+
461+
return result
417462

418463
def _site_to_dict(self, site):
419464
if hasattr(site, "to_dict"):
@@ -434,7 +479,7 @@ def _fetch_device_site_map(self, client):
434479
try:
435480
resp = client.topology.get_physical_topology()
436481
nodes = resp.response.nodes if resp.response and resp.response.nodes else []
437-
except (ApiError, Exception) as e:
482+
except ApiError as e:
438483
raise AnsibleParserError(
439484
"Failed to get physical topology: {0}".format(to_native(e))
440485
)
@@ -463,11 +508,16 @@ def _fetch_device_tags(self, client, device_ids):
463508
if not device_ids:
464509
return tags
465510

511+
device_id_set = set(device_ids)
512+
466513
try:
467514
resp = client.tag.get_tag()
468515
all_tags = resp.response if resp.response else []
469-
except (ApiError, Exception):
470-
self.display.warning("Failed to fetch tags, skipping tag-based grouping")
516+
except ApiError as e:
517+
self.display.warning(
518+
"Failed to fetch tags from Catalyst Center, "
519+
"skipping tag-based grouping: {0}".format(to_native(e))
520+
)
471521
return tags
472522

473523
for tag_obj in all_tags:
@@ -485,15 +535,21 @@ def _fetch_device_tags(self, client, device_ids):
485535
id=tag_id, member_type="networkdevice"
486536
)
487537
members = members_resp.response if members_resp.response else []
488-
except (ApiError, Exception):
538+
except ApiError as e:
539+
self.display.warning(
540+
"Failed to fetch members for tag '{0}' (id={1}), "
541+
"this tag will be missing from inventory: {2}".format(
542+
tag_name, tag_id, to_native(e)
543+
)
544+
)
489545
continue
490546

491547
for member in members:
492548
member_dict = member if isinstance(member, dict) else (
493549
member.to_dict() if hasattr(member, "to_dict") else vars(member)
494550
)
495551
member_id = member_dict.get("instanceUuid", "")
496-
if member_id in device_ids:
552+
if member_id in device_id_set:
497553
tags.setdefault(member_id, []).append(tag_name)
498554

499555
return tags
@@ -505,13 +561,15 @@ def _populate(self, data):
505561
tags = data.get("tags", {})
506562

507563
strict = self.get_option("strict")
564+
skipped_count = 0
508565

509566
if self.get_option("group_by_site"):
510567
self._build_site_groups(sites)
511568

512569
for device in devices:
513570
hostname = self._get_hostname(device)
514571
if not hostname:
572+
skipped_count += 1
515573
continue
516574

517575
self.inventory.add_host(hostname)
@@ -571,6 +629,13 @@ def _populate(self, data):
571629
self.get_option("keyed_groups"), host_vars, hostname, strict=strict
572630
)
573631

632+
if skipped_count:
633+
self.display.warning(
634+
"Skipped {0} device(s) with no usable hostname. "
635+
"Consider using hostname_source: managementIpAddress "
636+
"or hostname_source: id".format(skipped_count)
637+
)
638+
574639
def _get_hostname(self, device):
575640
source = self.get_option("hostname_source")
576641
hostname = device.get(source, "")
@@ -631,8 +696,11 @@ def _build_site_groups(self, sites):
631696
if parent_group and parent_group != child_group:
632697
try:
633698
self.inventory.add_child(parent_group, child_group)
634-
except Exception:
635-
pass
699+
except AnsibleError as e:
700+
self.display.warning(
701+
"Could not nest site group '{0}' under '{1}': "
702+
"{2}".format(child_group, parent_group, to_native(e))
703+
)
636704

637705
def _normalize_site_name(self, site):
638706
name = site.get("name", "unknown")

0 commit comments

Comments
 (0)