在软件工程领域,版本控制是指为软件产品的不同状态或迭代分配唯一标识符的过程。这些标识符 (即版本号) 帮助开发者和用户追踪软件随时间推移所经历的更新、变更或错误修复。版本控制对于软件开发管理、兼容性保障以及向终端用户传达变更信息都至关重要。
在检测工程领域,尤其是实践检测即代码 (Detection-as-Code) 时,版本控制同样不可或缺。检测库 (detection library) 中的版本控制帮助我们维护可追溯性,并追踪单个检测规则和内容包的变更历史。它能够帮助我们精确定位特定检测在某个时间点的确切状态,提供清晰的更新记录,并通过识别引入特定变更的版本来辅助故障排查和调试工作。
两种最常见的版本控制方案是日历版本控制 [1] 和语义版本控制 [2]。在本文中,我们将探讨如何在检测库中采用这些版本控制方案。
日历版本控制
日历版本控制,通常简称为 CalVer,是一种基于发布日期的版本控制方案。版本号通常包含发布的年份和月份,也可以采用 YYYY.MM.DD 格式 (如 2025.08.23)。日历版本控制特别适合有规律发布周期的项目,因为它非常直观,便于理解发布的时间安排。
在检测工程领域,Sigma 规则库的发布 [3] 就是采用日历版本控制的典型例子。Sigma 规则还会标注修改日期,可作为规则每次迭代更新的标识符。
语义版本控制
语义版本控制,通常简称为 SemVer,是一种采用三段式数字格式的版本控制系统 – MAJOR.MINOR.PATCH(如 v1.0.1)。版本号的每个部分都传达特定的含义:
MAJOR– 当存在可能影响向后兼容性的不兼容变更时递增。 MINOR– 当以向后兼容的方式添加新功能时递增。 PATCH– 当进行向后兼容的错误修复时递增。
这种方法帮助开发者和用户理解每次更新的变更性质,确保更新对现有实现的影响清晰明了。语义版本控制对于有效的依赖管理和软件项目演进期间的沟通特别有用,在 API 开发中应用非常广泛。
在检测工程领域,Azure Sentinel 检测规则库 [4] 就是采用语义版本控制的典型例子。
在检测工程,特别是检测即代码的语境下谈论版本控制时,我们主要关注三个方面:检测库发布版本控制、检测规则版本控制和内容包版本控制。我们将深入探讨这些领域,看看如何在检测库中采用或调整前面讨论的版本控制原则。
检测规则版本控制
在 Part 2 中,我们定义了元数据文件和内容包格式,并采用了语义版本控制方案。虽然语义版本控制的定义主要面向软件开发,但我们可以将其调整应用于检测规则,为 MAJOR.MINOR.PATCH格式赋予类似的含义。
MAJOR– 主版本号变更表示对检测规则的重大修改,这些修改可能会改变检测逻辑。这可能包括检测逻辑的完全重写,或由规则所依赖的其他组件 (如日志解析器) 的变更所引发的修改。 MINOR– 次版本号变更反映了对检测规则的增强优化。这可能包括增强规则元数据 (如添加调查步骤、标签、更新描述等),也包括对查询的优化性修改 (如重新排列条件以提升性能、重命名字段等)。 PATCH– 补丁版本号变更包括对检测规则的错误修复。这涉及修复规则的不正确行为,但不改变规则的核心检测逻辑。
通过使用语义版本控制为版本赋予明确含义,我们可以轻松追踪哪些检测规则需要检测工程团队投入最多精力以及原因。当我们对检测库进行定期审查时,这是一个重要优势,能够帮助我们找出经历最多迭代的检测规则。这样我们就可以有效审查检测内容,并识别可能存在问题的检测规则。
内容包版本控制
考虑到内容包是规则的集合,检测规则版本的变更也会带动内容包版本的递增。但还有其他情况也可能导致内容包版本递增。
MAJOR– 主版本号变更表示对内容包的重大更新,这些更新可能不向后兼容。例如,添加仅适用于特定解析器版本的规则,或添加一批能够显著增强内容包检测能力的规则。 MINOR– 次版本号变更代表对内容包检测规则的增强,或添加新的检测规则。例如,向内容包添加或移除检测规则,但不会像在拥有 100 条规则的包中添加或移除 1 条规则那样显著影响整体检测能力。 PATCH– 补丁版本号变更包括对内容包的错误修复。这可能涉及修复现有检测规则中的错误,或移除导致告警激增的检测规则。
内容包的版本控制同样重要,它能够指示哪些内容包最稳定成熟,或哪些已经较长时间未经审查。它还帮助我们了解在特定时间点的检测能力状况,使我们能够轻松推断为什么在某次事件中可能遗漏了某些指标。
检测规则和内容包的构建验证机制
建立了版本控制方案后,最后一步是确保对检测规则或内容包的任何修改都始终伴随版本更新。
我们将再次利用在 Part 3 中介绍的构建验证 [5],来自动化版本控制检查机制。当创建面向主分支的 Pull Request 时,将执行脚本以确保应用了版本控制,如果未应用则阻止合并。但首先,让我们了解一下实现这一目标的步骤。
Azure DevOps Pipelines 提供了一些本地变量,可在构建验证中用于获取 Pull Request 的相关信息 [6]。
System.PullRequest.PullRequestId– 触发此次构建的 Pull Request ID。 System.PullRequest.SourceBranch– Pull Request 的源分支名称。 System.PullRequest.TargetBranch– Pull Request 中被审查的目标分支。 System.PullRequest.SourceCommitId– Pull Request 中被审查的提交。
我们可以使用 Azure DevOps 管道中的以下命令从目标分支获取最新变更。由于我们将构建验证策略应用于主分支,因此在我们的场景中目标分支就是主分支。
git fetch origin $(System.PullRequest.TargetBranch)
然后我们可以执行以下命令,识别 Pull Request 正在修改的文件。
git diff --name-only --pretty= origin/main..HEAD
在这个示例中,我们更新了一个 Sentinel 检测规则。
对于每个被修改的文件,我们执行以下命令来显示变更的差异内容,然后可以解析这些内容以判断是否需要递增版本号。
git --no-pager diff --unified=0 origin/main..HEAD -- detections/os/windows/download_via_certutil_exe/download_via_certutil_exe_sentinel.json
在我们于 Part 2 设计的存储库中,判断版本是否正确递增的方式如下:
如果被修改的文件是内容包,我们直接检查版本字段是否正确递增。 如果被修改的文件是元数据 YAML 文件,我们直接检查版本字段是否正确递增。 如果被修改的文件是规则文件,我们定位该检测规则的 YAML 元数据文件,并检查版本字段是否正确递增。 如果被修改的文件是元数据文件或规则文件,我们还要检查引用此检测规则的所有内容包的版本是否有相应修改。
为了将所有逻辑整合在一起,我们使用以下脚本来验证 Pull Request 中被修改文件的版本递增是否符合上述逻辑。
import sysimport reimport subprocessimport osimport jsondefrun_command(command: list) -> str:"""Executes a shell command and returns the output."""try:# print(f"[R] Running command: {' '.join(command)}") output = subprocess.check_output(command, text=True, encoding="utf-8", errors="replace").strip()# print(f"[O] Command output:n{'n'.join(['t'+line for line in output.splitlines()])}")return outputexcept subprocess.CalledProcessError as e: print(f"#[task.logissue type=error]Error executing command: {' '.join(command)}") print(f"#[task.logissue type=error]Error message: {str(e)}")return""except UnicodeDecodeError as e: print(f"#[task.logissue type=error]Unicode decode error: {e}")return""defget_pr_modified_files() -> list:"""Get the pr modified files"""return run_command(["git", "diff", "--name-only", "--pretty=""", "origin/main..HEAD"]).splitlines()defget_pr_modified_file_diff_lines(file: str) -> str:"""Get the pr modified file diff"""return run_command(["git", "--no-pager", "diff", "--unified=0", "origin/main..HEAD", "--", file]).splitlines()defextract_version(line: str, version_regex) -> str:"""Extract the version number from a line.""" match = version_regex.search(line)return match.group(1) if match elseNonedefis_version_incremented_correctly(old_version: str, new_version: str) -> bool:"""Check if the version is incremented correctly according to specified rules.""" old_version_parts = old_version.split(".") new_version_parts = new_version.split(".")# Convert version parts to integers for comparison old_version_parts = [int(part) for part in old_version_parts] new_version_parts = [int(part) for part in new_version_parts]# Destructure the version parts for clarity old_major, old_minor, old_patch = old_version_parts new_major, new_minor, new_patch = new_version_parts# Check if major version is incrementedif new_major > old_major:# Major version incremented, minor and patch must be reset to 0return new_minor == 0and new_patch == 0# Check if minor version is incrementedif new_minor > old_minor:# Minor version incremented, patch must be reset to 0return new_patch == 0# Check if patch version is incrementedif new_patch > old_patch:# Patch version can be incremented freely if no other changesreturn new_major == old_major and new_minor == old_minor# If no version part is incremented correctlyreturnFalsedefcheck_version_in_file(file: str, remove_pattern, add_pattern) -> bool:"""Check if file has both added and removed version lines.""" diff_lines = get_pr_modified_file_diff_lines(file) removed_version_found = False added_version_found = False version_incremented_correctly = False old_version = None new_version = Nonefor line in diff_lines:if remove_pattern.search(line): removed_version_found = True old_version = extract_version(line, remove_pattern)elif add_pattern.search(line): added_version_found = True new_version = extract_version(line, add_pattern)if removed_version_found and added_version_found: print(" - Version updated.")else: print(" - Version NOT updated.")if old_version and new_version: print(f" - {old_version} -> {new_version}") version_incremented_correctly = is_version_incremented_correctly(old_version, new_version) print(f" - Version incremented correctly: {version_incremented_correctly}")return removed_version_found and added_version_found and version_incremented_correctlydefget_content_packs_for_detection(detection: str) -> list: detection = os.path.dirname(detection.removeprefix("detections/")) content_packs = []for filename in os.listdir("content_packs"):ifnot filename.endswith(".json"):continue full_path = os.path.join("content_packs", filename)with open(full_path, "r", encoding="utf-8") as f:try: data = json.load(f)except json.JSONDecodeError: print(f"Invalid JSON in {filename}")continue content_pack_detections = data.get("detections", [])if detection in content_pack_detections: content_packs.append(os.path.join("content_packs", filename))return content_packsdefvalidate_version(): version_updated_files = [] version_not_updated_files = [] cp_version_pattern_remove = re.compile(r'^-s*"version"s*:s*"(d+.d+.d+)"') cp_version_pattern_added = re.compile(r'^+s*"version"s*:s*"(d+.d+.d+)"') de_version_pattern_remove = re.compile(r"^-s*versions*:s*(d+.d+.d+)") de_version_pattern_added = re.compile(r"^+s*versions*:s*(d+.d+.d+)") pr_modified_files = get_pr_modified_files() print(f"Modified Files:n{', '.join(pr_modified_files)}")# Content packs first as they will be used in the detections check belowfor pr_modified_file in pr_modified_files:if pr_modified_file.startswith("content_pack"):# for content_pack check version directly in file print(f"Checking file: {pr_modified_file}")if check_version_in_file(pr_modified_file, cp_version_pattern_remove, cp_version_pattern_added): version_updated_files.append(pr_modified_file)else: version_not_updated_files.append(pr_modified_file)# Detections checks for version updatesfor pr_modified_file in pr_modified_files:if pr_modified_file.startswith("detections"): print(f"Checking file: {pr_modified_file}")if pr_modified_file.endswith("_meta.yml"):# Check version in _meta.yml file directlyif check_version_in_file(pr_modified_file, de_version_pattern_remove, de_version_pattern_added): version_updated_files.append(pr_modified_file)else: version_not_updated_files.append(pr_modified_file)elif pr_modified_file.endswith("_sentinel.json"):# check the _meta.yml file for version changes base_name = pr_modified_file.removesuffix("_sentinel.json") meta_file = base_name + "_meta.yml"# Check if meta file is changed in this prif meta_file in pr_modified_files:if check_version_in_file(meta_file, de_version_pattern_remove, de_version_pattern_added): version_updated_files.append(meta_file)else: version_not_updated_files.append(meta_file)else: print(f" - Metadata file for {pr_modified_file} not updated at all.") version_not_updated_files.append(meta_file)else:pass# If a detection belonging to a content pack increments is modified, content pack version must be modified too. included_in_content_packs = get_content_packs_for_detection(pr_modified_file)for content_pack in included_in_content_packs:if content_pack notin version_updated_files: print(f" - Detection {pr_modified_file} modified but version not incremented in content pack {content_pack} that references it." ) version_not_updated_files.append(content_pack)if version_not_updated_files: print("#[task.logissue type=error] The following files did NOT increment their version:")for f in version_not_updated_files: print(f"#[task.logissue type=error] - {f}") sys.exit(1)else: print("All relevant files have updated their version.")defmain(): validate_version()if __name__ == "__main__": main()
然后,我们为该脚本创建管道,并将其添加到主分支的构建验证策略中。
name:ValidateVersiontrigger:nonejobs:-job:ValidateVersiondisplayName:"Validate Version"steps:-checkout:selffetchDepth:0persistCredentials:true-script:| echo "PR ID: $(System.PullRequest.PullRequestId)" echo "Source Branch: $(System.PullRequest.SourceBranch)" echo "Target Branch: $(System.PullRequest.TargetBranch)" echo "Latest Commit: $(System.PullRequest.SourceCommitId)" git fetch origin $(System.PullRequest.TargetBranch)displayName:'Fetch Target Branch'-script:| python pipelines/scripts/validate_version.pydisplayName:'Run Validate Version'
一旦我们创建 Pull Request,构建验证会自动运行,如果存在版本控制错误,将阻止我们合并变更,直到错误被纠正。
在这个示例中,我们修改了检测规则文件,但没有在元数据文件或引用此检测规则的内容包中递增版本号,这与我们上面指定的要求不符。
我们更新这两个文件中的版本号后,可以看到构建验证现在成功通过了。
可追溯性
使用版本控制的最大好处之一是可追溯性。通过在部署检测规则之前为其添加检测规则和内容包版本信息,并确保这些信息传播到生成的安全告警中,我们可以非常快速地从告警追溯到该时间点的规则版本。
在我们的存储库中追溯规则版本非常简单。我们可以执行以下 Git 命令,在存储库的提交历史中搜索特定文件中涉及字符串 "1.2.0" 的变更。"-p" 选项会显示每次提交引入的差异内容,提供变更的详细视图。
git log -p -S "1.2.0" -- detectionscloudazureazure_ad_azure_discovery_using_offensive_toolsazure_ad_azure_discovery_using_offensive_tools_meta.yml
最后,通过使用以下命令和前面输出中的提交 ID,我们可以获取该时间点的规则版本,并查看当时的查询内容。
git checkout 14693f7c8b35c977ece8f664ea28e7d1270cada3 -- detections/cloud/azure/azure_ad_azure_discovery_using_offensive_tools/*
检测库发布版本控制
如果您作为服务或产品的一部分提供检测内容,很可能会需要进行检测内容发布,即定期发布内容。我们在 Part 2 中讨论了利用发布的不同分支策略。您可以通过在主分支上用 "release-*" 标签标记提交来管理发布,该标签将包含遵循前面讨论的 CalVer 方案的日期。
我们现在将探讨一种自动生成发布说明的方法,总结每次发布之间的变更。但在提供脚本之前,我们先了解一下将要使用的 Git 命令。
首先,我们通过执行以下命令列出项目中可用的发布标签:
git tag -l release-*
然后我们为每个标记的提交检出 detections/和 content_packs/目录。
git checkout release-2025-08-03 -- content_packs/* detections/*
接下来,对于该版本检测库中的每个内容包及其引用的检测规则,我们将版本和元数据收集到 JSON 结构中。我们将每次发布与前一次发布进行比较,以识别内容包和检测规则中的变更,从而判断每项内容是新增、更新还是既有的。第一次发布的内容包和检测规则都被视为存储库的新增内容。JSON 结构应该如下所示。
我们将利用以下 Jinja [7] 模板,将此 JSON 转换为可用于呈现发布说明的 MD 文件。
# Release Notes{%- for release, content_packs in releases.items() %}## {{ release }}{% for content_pack_filename, content_pack_data in content_packs.items() -%}{%- if content_pack_data.status == 'new' or content_pack_data.status == 'updated' %}<details><summary><b>{{content_pack_data.status | title}}: {{ content_pack_data.name }} ({{ content_pack_filename }}) - {{content_pack_data.version}}</b></summary>{{ content_pack_data.detections.description }}#### Detections:{%- for detection in content_pack_data.detections -%}{%- if detection.status == 'new' or detection.status == 'updated' %}**{{ detection.status| title}}**: <ahref="{{repo_url}}?path=/detections/{{detection.path}}&version=GT{{release}}">{{ detection.name }}</a> - {{ detection.version }}{% endif -%}{%- endfor %}</details>{% endif -%}{%- endfor -%}{%- endfor -%}
我们将上述逻辑实现到以下脚本中。
git diff --name-only --pretty= origin/main..HEAD
0
我们配置管道,以便在创建发布标签时,上述脚本将被执行,管道会将发布说明 MD 文件提交到存储库的文档目录下。
git diff --name-only --pretty= origin/main..HEAD
1
为了让管道能够提交内容,我们需要向 Azure DevOps 项目的构建服务账号授予 "贡献" 权限。我们可以通过导航到项目设置 -> 存储库 -> 选择我们的存储库 -> 点击安全性 -> 选择主分支 -> 选择 DAC 构建服务账号并分配贡献权限来完成此操作。
我们的管道在创建 "release-*" 标签时执行,生成发布说明 MD 文件,然后将其上传到存储库的文档目录下。该文件还可以通过我们在 Part 4 中创建的 Wiki 页面访问。
发布说明 MD 文件如下图所示,包含了每次发布之间的变更内容。
检测规则名称是可点击的链接,将带我们跳转到该发布版本中存在的检测规则。
总结
在检测工程领域,特别是在检测即代码实践中,版本控制确保了可追溯性,并促进了检测规则和内容包的有效管理。通过应用日历版本控制和语义版本控制等方案,团队能够清晰了解变更的性质和影响,这有助于故障排查、依赖管理以及变更沟通。
本系列博客的下一部分将介绍将检测规则部署到目标平台的各种方法。
参考文献
[1] https://calver.org/
[2] https://semver.org/
[3] https://github.com/SigmaHQ/sigma/releases
[4] https://github.com/Azure/Azure-Sentinel/blob/master/Detections/SecurityEvent/AdminSDHolder_Modifications.yaml
[5] https://learn.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops&tabs=browser-validation
[6] https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables
[7] https://jinja.palletsprojects.com/en/stable/intro/
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...