Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e7fc94c
bump version to 2.5.1.beta1 and update deepdiff dependency to 9.0.0
May 13, 2026
fb3db32
Add compare functionality to analyze drift between backup directories
May 13, 2026
f5dfb55
change import logic in backup and update modules to fix compatibility…
May 13, 2026
03a51e9
Fix app config backup crash if one app could not be found #257
May 13, 2026
93cfa96
Enhance base64 validation and improve string formatting in clean_list…
May 13, 2026
601051c
Update pytest and pytest-cov versions in test requirements
May 13, 2026
753891b
Fix compliance updates stopping on object is not iterable #256
May 13, 2026
0d13791
Update expected data format in document_configs test and increase lim…
May 13, 2026
8caa5ff
Add match_id extraction and duplicate filename handling across update…
May 13, 2026
d882894
Sanitize control characters in backup filenames
akmhatey-ai May 16, 2026
2410da2
Address filename sanitizer review
akmhatey-ai May 16, 2026
c3a683a
Remove zero-width filename characters
akmhatey-ai May 16, 2026
ee0f267
fix: match proactive remediation scripts by policy
david6983 May 23, 2026
d4ecc28
formatting
May 29, 2026
4931274
Add color output options and HTML report generation to run_compare
May 29, 2026
e16ca5d
Merge pull request #260 from akmhatey-ai/codex/sanitize-control-chars…
almenscorner May 29, 2026
b7ccf3e
Merge pull request #262 from david6983/codex-fix-proactive-remediatio…
almenscorner May 29, 2026
b53e903
bump version
May 29, 2026
5af8ad0
bump version to 2.6.0
May 29, 2026
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
5 changes: 3 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[metadata]
name = IntuneCD
version = 2.5.0
version = 2.6.0
author = Tobias Almén
author_email = almenscorner@outlook.com
description = Tool to backup and update configurations in Intune
Expand All @@ -20,7 +20,7 @@ package_dir =
packages = find:
python_requires = >=3.9
install_requires =
deepdiff==8.4.2
deepdiff==9.1.0
pyyaml>=6.0.2
msrest>=0.7.1
markdown_toclify>=0.1.7
Expand All @@ -35,4 +35,5 @@ console_scripts =
IntuneCD-startbackup = IntuneCD.run_backup:start
IntuneCD-startupdate = IntuneCD.run_update:start
IntuneCD-startdocumentation = IntuneCD.run_documentation:start
IntuneCD-startcompare = IntuneCD.run_compare:start
IntuneCD = IntuneCD.__main__:main
6 changes: 6 additions & 0 deletions src/IntuneCD/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
start as run_documentation,
)
from IntuneCD.run_update import get_parser as get_update_parser, start as run_update
from IntuneCD.run_compare import get_parser as get_compare_parser, start as run_compare
from importlib.metadata import version, PackageNotFoundError


Expand Down Expand Up @@ -80,5 +81,10 @@ def main():
)
documentation_parser.set_defaults(func=run_documentation)

compare_parser = subparsers.add_parser(
"compare", parents=[get_compare_parser(include_help=False)]
)
compare_parser.set_defaults(func=run_compare)

args = parser.parse_args()
args.func(args)
26 changes: 17 additions & 9 deletions src/IntuneCD/backup/Intune/AppConfiguration.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,25 @@ def main(self) -> dict[str, any]:
return None

if self.graph_data["value"]:
item = ""
for item in self.graph_data["value"]:
for app in item["targetedMobileApps"]:
app_data = self.make_graph_request(
endpoint=self.endpoint + self.APP_ENDPOINT + "/" + app
app = None
try:
for app in item["targetedMobileApps"]:
app_data = self.make_graph_request(
endpoint=self.endpoint + self.APP_ENDPOINT + "/" + app
)
if app_data:
item.pop("targetedMobileApps")
item["targetedMobileApps"] = {}
item["targetedMobileApps"]["appName"] = app_data[
"displayName"
]
item["targetedMobileApps"]["type"] = app_data["@odata.type"]
except Exception as e:
self.log(
tag="error",
msg=f"Error getting app data for App Configuration {item.get('displayName', 'Unknown')} with app id {app}: {e}",
)
if app_data:
item.pop("targetedMobileApps")
item["targetedMobileApps"] = {}
item["targetedMobileApps"]["appName"] = app_data["displayName"]
item["targetedMobileApps"]["type"] = app_data["@odata.type"]
if item.get("payloadJson"):
item["payloadJson"] = self.decode_base64(item["payloadJson"])
item["payloadJson"] = json.loads(item["payloadJson"])
Expand Down
6 changes: 3 additions & 3 deletions src/IntuneCD/backup/Intune/DeviceCompliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@ def main(self) -> dict[str, any]:
for action in item["scheduledActionsForRule"]:
self.remove_keys(action)
self._get_notification_template(action)
for config in item["scheduledActionsForRule"][0][
"scheduledActionConfigurations"
]:
for config in item["scheduledActionsForRule"][0].get(
"scheduledActionConfigurations", []
):
self.remove_keys(config)

try:
Expand Down
6 changes: 3 additions & 3 deletions src/IntuneCD/backup_intune.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ def import_backup_module(module_path: str):
Dynamically imports a backup module, handling both installed and local Git repository cases.
"""
try:
# Try importing as an installed package
return importlib.import_module(module_path, package="IntuneCD")
# Resolve relative to the package this file is part of, so dev runs
# (src.IntuneCD.*) and installed runs (IntuneCD.*) both work.
return importlib.import_module(module_path, package=__package__)
except ModuleNotFoundError:
try:
# If that fails, assume we're running locally and try direct relative import
return importlib.import_module(module_path)
except ModuleNotFoundError as e:
print(f"[ERROR] Could not import {module_path}: {e}")
Expand Down
9 changes: 6 additions & 3 deletions src/IntuneCD/intunecdlib/BaseBackupModule.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
class BaseBackupModule(BaseGraphModule):
"""Base class for backup modules."""

REMOVE_CHARACTERS = '/\\:*?<>"|'
ZERO_WIDTH_CHARACTERS = "\u200b\u200c\u200d\u2060\ufeff"

def __init__(
self,
path: str = None,
Expand Down Expand Up @@ -80,11 +83,11 @@ def _prepare_file_name(self, filename: str) -> str:
str: The prepared filename
"""

remove_characters = '/\\:*?<>"|'
if not isinstance(filename, str):
filename = str(filename)
for character in remove_characters:
filename = filename.replace(character, "_")
filename = re.sub(r"[\x00-\x1f\x7f]+", "", filename)
filename = re.sub(rf"[{re.escape(self.ZERO_WIDTH_CHARACTERS)}]+", "", filename)
filename = re.sub(rf"[{re.escape(self.REMOVE_CHARACTERS)}]", "_", filename)

return filename

Expand Down
61 changes: 59 additions & 2 deletions src/IntuneCD/intunecdlib/BaseUpdateModule.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def __init__(
self.azure_update = False
self.config_type = None
self.match_info = None
self.match_id = None
self.config_endpoint = None
self.downstream_assignments = None
self.create_request = None
Expand Down Expand Up @@ -399,16 +400,72 @@ def create_downstream_data(
repo_assignments, [], self.assignment_key, self.create_request["id"]
)

def get_match_data(self, intune_data: dict, match_info: dict) -> tuple:
_FILENAME_ID_RE = re.compile(
r"__(?P<id>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?=\.[^.]+$)"
)

def _match_id_from_filename(self, filename: str) -> str:
"""Extracts an `__<guid>` suffix from a backup filename, if present."""
if not filename:
return None
match = self._FILENAME_ID_RE.search(filename)
return match.group("id") if match else None

def _duplicate_filenames_to_skip(self, filenames: list) -> set:
"""Detects filenames that would resolve to the same tenant object.

Groups files by their name with any `__<guid>` suffix stripped. If a
group has more than one file:
- If any file in the group has an ID suffix, files without one are
skipped (the ID-suffixed entry is the authoritative copy).
- If no file in the group has an ID, all but the first are skipped.
"""
groups: dict = {}
for f in filenames:
base = self._FILENAME_ID_RE.sub("", f)
groups.setdefault(base, []).append(f)

skip: set = set()
for base, fnames in groups.items():
if len(fnames) <= 1:
continue
with_id = [f for f in fnames if self._FILENAME_ID_RE.search(f)]
without_id = [f for f in fnames if not self._FILENAME_ID_RE.search(f)]
if with_id:
for f in without_id:
skip.add(f)
self.log(
tag="warning",
msg=f"Skipping {f}: duplicate of ID-suffixed file(s) {with_id}",
)
else:
for f in without_id[1:]:
skip.add(f)
self.log(
tag="error",
msg=f"Skipping {f}: duplicate of {without_id[0]} with no ID to disambiguate",
)
return skip

def get_match_data(
self, intune_data: dict, match_info: dict, match_id: str = None
) -> tuple:
"""Gets the matching data

Args:
intune_data (dict): The intune data
match_info (dict): The match info from the repository
match_id (str, optional): If provided, prefer an item with this id
before falling back to match_info matching.

Returns:
tuple: The matching data
"""
if match_id:
for item in intune_data:
if item.get("id") == match_id:
intune_data.remove(item)
return dict(item), item["id"]
config_match_count = len(match_info)
intune_item = None
intune_id = None
Expand Down Expand Up @@ -626,7 +683,7 @@ def process_update(
self.downstream_id = downstream_data.get("id", "")
else:
self.downstream_object, self.downstream_id = self.get_match_data(
downstream_data, self.match_info
downstream_data, self.match_info, self.match_id
)

if self.downstream_object:
Expand Down
Loading
Loading