diff --git a/detection_rules/version_lock.py b/detection_rules/version_lock.py index fea56ddf4ab..17dadae40fc 100644 --- a/detection_rules/version_lock.py +++ b/detection_rules/version_lock.py @@ -89,59 +89,94 @@ def manage_versions(self, rules: RuleCollection, verbose_echo('Rule changes detected!') + route = None + existing_rule_lock = {} + original_hash = None + changes = [] + + def add_changes(r, *msg): + if not original_hash or original_hash != current_rule_lock['sha256']: + new = [f' {route}: {r.id}, new version: {existing_rule_lock["version"]}'] + new.extend([f' - {m}' for m in msg if m]) + changes.extend(new) + for rule in rules: if rule.contents.metadata.maturity == "production" or rule.id in newly_deprecated: # assume that older stacks are always locked first min_stack = _convert_lock_version(rule.contents.metadata.min_stack_version) - lock_info = rule.contents.lock_info(bump=not exclude_version_update) - current_rule_lock: dict = current_version_lock.setdefault(rule.id, {}) + current_rule_lock = rule.contents.lock_info(bump=not exclude_version_update) + existing_rule_lock: dict = current_version_lock.setdefault(rule.id, {}) + original_hash = existing_rule_lock.get('sha256') # scenarios to handle, assuming older stacks are always locked first: # 1) no breaking changes ever made or the first time a rule is created # 2) on the latest, after a breaking change has been locked # 3) on the latest stack, locking in a breaking change # 4) on an old stack, after a breaking change has been made - latest_locked_stack_version = _convert_lock_version(current_rule_lock.get("min_stack_version")) + latest_locked_stack_version = _convert_lock_version(existing_rule_lock.get("min_stack_version")) - if not current_rule_lock or min_stack == latest_locked_stack_version: + if not existing_rule_lock or min_stack == latest_locked_stack_version: + route = 'A' # 1) no breaking changes ever made or the first time a rule is created # 2) on the latest, after a breaking change has been locked - current_rule_lock.update(lock_info) + existing_rule_lock.update(current_rule_lock) # add the min_stack_version to the lock if it's explicitly set + log_msg = None if rule.contents.metadata.min_stack_version is not None: - current_rule_lock["min_stack_version"] = str(min_stack) + existing_rule_lock["min_stack_version"] = str(min_stack) + log_msg = f'min_stack_version added: {min_stack}' + + add_changes(rule, log_msg) elif min_stack > latest_locked_stack_version: + route = 'B' # 3) on the latest stack, locking in a breaking change previous_lock_info = { - "rule_name": current_rule_lock["rule_name"], - "sha256": current_rule_lock["sha256"], - "version": current_rule_lock["version"], + "rule_name": existing_rule_lock["rule_name"], + "sha256": existing_rule_lock["sha256"], + "version": existing_rule_lock["version"], } - current_rule_lock.setdefault("previous", {}) + existing_rule_lock.setdefault("previous", {}) # move the current locked info into the previous section - current_rule_lock["previous"][str(latest_locked_stack_version)] = previous_lock_info + existing_rule_lock["previous"][str(latest_locked_stack_version)] = previous_lock_info # overwrite the "latest" part of the lock at the top level - current_rule_lock.update(lock_info, min_stack_version=str(min_stack)) + # TODO: would need to preserve space here as well if supporting forked version spacing + existing_rule_lock.update(current_rule_lock, min_stack_version=str(min_stack)) + add_changes( + rule, + f'previous {latest_locked_stack_version} saved as version: {previous_lock_info["version"]}', + f'current min_stack updated to {min_stack}' + ) elif min_stack < latest_locked_stack_version: - # 4) on an old stack, after a breaking change has been made - assert str(min_stack) in current_rule_lock.get("previous", {}), \ + route = 'C' + # 4) on an old stack, after a breaking change has been made (updated fork) + assert str(min_stack) in existing_rule_lock.get("previous", {}), \ f"Expected {rule.id} @ v{min_stack} in the rule lock" # TODO: Figure out whether we support locking old versions and if we want to # "leave room" by skipping versions when breaking changes are made. # We can still inspect the version lock manually after locks are made, # since it's a good summary of everything that happens - current_rule_lock["previous"][str(min_stack)] = lock_info + existing_rule_lock["previous"][str(min_stack)] = current_rule_lock + existing_rule_lock.update(current_rule_lock) + add_changes(rule, f'previous version {min_stack} updated version to {current_rule_lock["version"]}') continue else: raise RuntimeError("Unreachable code") + if 'previous' in existing_rule_lock: + current_rule_version = rule.contents.lock_info()['version'] + for min_stack_version, versioned_lock in existing_rule_lock['previous'].items(): + existing_lock_version = versioned_lock['version'] + if current_rule_version < existing_lock_version: + raise ValueError(f'{rule.id} - previous {min_stack_version=} {existing_lock_version=} ' + f'has a higher version than {current_rule_version=}') + for rule in rules.deprecated: if rule.id in newly_deprecated: current_deprecated_lock[rule.id] = { @@ -154,6 +189,8 @@ def manage_versions(self, rules: RuleCollection, click.echo(f' - {len(changed_rules)} changed rules') click.echo(f' - {len(new_rules)} new rules') click.echo(f' - {len(newly_deprecated)} newly deprecated rules') + if changes: + click.echo('Detailed changes: \n' + '\n'.join(changes)) if not save_changes: verbose_echo( diff --git a/etc/version.lock.json b/etc/version.lock.json index 90a51009840..7557fe80be8 100644 --- a/etc/version.lock.json +++ b/etc/version.lock.json @@ -1393,7 +1393,7 @@ }, "rule_name": "Google Workspace Admin Role Assigned to a User", "sha256": "afd34ab4f1d7e038c874333fd83de248c0b54d625f489e74359f3ce4ec9ac71b", - "version": 6 + "version": 8 }, "689b9d57-e4d5-4357-ad17-9c334609d79a": { "rule_name": "Scheduled Task Created by a Windows Script", @@ -1497,7 +1497,7 @@ }, "rule_name": "Google Workspace Role Modified", "sha256": "33a6f2e64d79ebfed4fe0f1b4e5c4a7968b9b4941e11fa0cf720ef3810e38a15", - "version": 6 + "version": 8 }, "7024e2a0-315d-4334-bb1a-441c593e16ab": { "rule_name": "AWS CloudTrail Log Deleted", @@ -1611,7 +1611,7 @@ }, "rule_name": "Application Added to Google Workspace Domain", "sha256": "ab5ac05b1f57b0e9a197d51506441eee921132528fde66e99b64021454556e71", - "version": 6 + "version": 8 }, "7882cebf-6cf1-4de3-9662-213aa13e8b80": { "rule_name": "Azure Privilege Identity Management Role Modified", @@ -1941,7 +1941,7 @@ }, "rule_name": "Google Workspace Admin Role Deletion", "sha256": "7f3e1672e2c15b1f4386242655493bbd483c0c30d377b65c94cadf17d5dbb100", - "version": 6 + "version": 8 }, "93f47b6f-5728-4004-ba00-625083b3dcb0": { "rule_name": "Modification of Standard Authentication Module or Configuration", @@ -2235,7 +2235,7 @@ }, "rule_name": "Google Workspace Password Policy Modified", "sha256": "7741aa9c38ba126329fbb075496847374a2dd8d65aadd49aa25b7f0f00e6aeb5", - "version": 7 + "version": 9 }, "a9b05c3b-b304-4bf9-970d-acdfaef2944c": { "rule_name": "Persistence via Hidden Run Key Detected", @@ -2298,7 +2298,7 @@ }, "rule_name": "Google Workspace API Access Granted via Domain-Wide Delegation of Authority", "sha256": "3d8eab60bf795ae6756c1c6058a7c1be2eb14e1c1777a7b4bda27e1906206c95", - "version": 6 + "version": 8 }, "acd611f3-2b93-47b3-a0a3-7723bcc46f6d": { "rule_name": "Potential Command and Control via Internet Explorer", @@ -2331,7 +2331,7 @@ }, "rule_name": "Google Workspace Custom Admin Role Created", "sha256": "72ff218857ba09e7c08970ebc6cdfcba3cd1dd4f0711dbd403b074fee911011c", - "version": 6 + "version": 8 }, "ad84d445-b1ce-4377-82d9-7c633f28bf9a": { "rule_name": "Suspicious Portable Executable Encoded in Powershell Script", @@ -2756,7 +2756,7 @@ }, "rule_name": "Google Workspace MFA Enforcement Disabled", "sha256": "de718fed93c2314061daddd300ddb5e01064210ddc42d687fcdd988aa2595d5a", - "version": 7 + "version": 9 }, "cb71aa62-55c8-42f0-b0dd-afb0bb0b1f51": { "rule_name": "Suspicious Calendar File Modification", @@ -2834,7 +2834,7 @@ }, "rule_name": "Domain Added to Google Workspace Trusted Domains", "sha256": "734ba85eb72a8c8167a1247c75d48bbd9abb0a9954f8a357a20017258da978de", - "version": 6 + "version": 8 }, "cff92c41-2225-4763-b4ce-6f71e5bda5e6": { "rule_name": "Execution from Unusual Directory - Command Line", @@ -3064,7 +3064,7 @@ }, "rule_name": "Whitespace Padding in Process Command Line", "sha256": "f182f841954adaa9009a1b62d0b98506f864adc4d7ab93e8467f26ada0f518d0", - "version": 4 + "version": 6 }, "e0f36de1-0342-453d-95a9-a068b257b053": { "rule_name": "Azure Event Hub Deletion", @@ -3158,7 +3158,7 @@ }, "rule_name": "MFA Disabled for Google Workspace Organization", "sha256": "aea30c3bf1eb96e0c6f0c64da484ca2310b1ae26e8679030c0a30a8058982a77", - "version": 7 + "version": 9 }, "e56993d2-759c-4120-984c-9ec9bb940fd5": { "rule_name": "RDP (Remote Desktop Protocol) to the Internet",