GitHub Actions Workflow 提取器 v1 全面解析:用 dcg scan 拦截 CI 中的危险命令
【免费下载链接】destructive_command_guardThe Destructive Command Guard (dcg) is for blocking dangerous git and shell commands from being executed by agents.项目地址: https://gitcode.com/GitHub_Trending/de/destructive_command_guard
本文基于 Destructive Command Guard(dcg)仓库中的 GitHub Actions 提取器 v1 规范 编写,并结合 src/scan.rs 源码实现与测试用例进行纵深验证。读者读完本文,将完整掌握 dcg 如何从
.github/workflows/*.yml工作流文件中精准提取run:块内的 shell 命令用于安全扫描、提取器的上下文感知边界与 v1 已知限制,以及如何用dcg scan命令在本地与 CI 中实际落地防护。
一、Overview:这个提取器解决什么问题
GitHub Actions 是 CI/CD 攻击面中非常容易藏匿危险命令的场所:rm -rf、git reset --hard、gh secret delete这类命令一旦被写进 workflow 并合入主干,就可能在被触发时造成不可逆破坏。dcg 的GitHub Actions extractor正是为此设计——它解析 workflow 文件,从steps:数组内的run:块中提取 shell 命令,交给与 hook 模式完全相同的模式引擎进行危险命令评估(见 README 的 Repository Scanning 章节)。
提取器遵循一条根本原则:
prefer silence over false positives(宁可不报,不可误报)
这意味着提取器只扫描真正会被执行的命令上下文,而不是像 grep 一样对所有字符串做模式匹配。README 中明确强调了这一差异:"dcg scanunderstands that"rm -rf /"in a comment is data, not code",它使用理解文件结构的提取器,只找出实际会被执行的命令。
Extractor ID:
github_actions.steps.run—steps:中run:步骤内的 shell 命令
该 ID 是扫描结果归属与后续处理的唯一标识。在源码中它以常量形式出现于 src/scan.rs#L1859,并贯穿所有提取路径。
二、文件检测:什么样的文件会被扫描
2.1 支持的路径模式
| Pattern | 示例 | 说明 |
|---|---|---|
.github/workflows/*.yml | .github/workflows/ci.yml | 主要模式 |
.github/workflows/*.yaml | .github/workflows/deploy.yaml | 备用扩展名 |
.github/workflows/**/*.yml | .github/workflows/sub/ci.yml | 嵌套目录 |
判定要求:
- 路径必须包含
.github/workflows/目录结构; - 文件扩展名必须是
.yml或.yaml(大小写不敏感)。
2.2 不会被匹配的情况
| Pattern | 原因 |
|---|---|
workflows/ci.yml | 缺少.github/父目录 |
.github/workflow/ci.yml | 目录名错误(单数 workflow) |
.github/workflows/ci.json | 扩展名错误 |
action.yml | 复合 action 文件(结构不同) |
.github/actions/my-action/action.yml | 复合 action |
2.3 源码实现
文件判定逻辑在 src/scan.rs#L1832-L1849 的is_github_actions_workflow_path函数中:
fn is_github_actions_workflow_path(path: &Path) -> bool { let Some(ext) = path.extension().and_then(std::ffi::OsStr::to_str) else { return false; }; let ext = ext.to_ascii_lowercase(); if ext != "yml" && ext != "yaml" { return false; } let components: Vec<String> = path .components() .filter_map(|c| c.as_os_str().to_str().map(str::to_ascii_lowercase)) .collect(); components .windows(2) .any(|w| w[0] == ".github" && w[1] == "workflows") }两个关键实现细节值得注意:
- 扩展名大小写不敏感:
ext.to_ascii_lowercase()使得.GITHUB/WORKFLOWS/CI.YML也能被识别; - 目录匹配基于路径组件滑动窗口:
components.windows(2)在任意深度查找相邻的.github+workflows组件对,这正是嵌套目录(.github/workflows/sub/ci.yml)也能命中的原因,同时action.yml、workflows/ci.yml、.github/workflow/ci.yml均被排除。
对应测试位于 src/scan.rs#L5856-L5880 的github_actions_path_detection,覆盖了上述所有正例与反例,包括大小写变体.GITHUB/WORKFLOWS/CI.YML。
在扫描主流程中,文件被识别为 GitHub Actions workflow 后,会进入 src/scan.rs#L913-L919 调用extract_github_actions_workflow_from_str提取命令,与 Shell、Dockerfile、GitLab CI 等提取器并行工作(src/scan.rs#L853-L878 的提取器选择逻辑)。
三、GitHub Actions Workflow 结构与提取边界
一个典型的 workflow 文件结构如下:
name: CI on: [push, pull_request] # Triggers (not extracted) jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # uses: (not extracted) - name: Build # name: (not extracted) run: npm run build # run: (EXTRACTED) - run: | # Block scalar (EXTRACTED) npm test npm run lint核心结论:只有steps:数组内的run:值会被提取为 shell 命令。
on:触发器、name:、uses:、env:、with:、secrets:等均属于数据上下文(data context),不会被提取;- workflow 级与 job 级的
name:、on:、permissions:、concurrency:、defaults:、env:等元数据全部跳过。
README 中给出了同样的上下文示例(README.md#L1650-L1662):run: |内的npm install/npm run build被提取,而env.NODE_ENV被跳过。
四、支持的 Run 块形式(四种)
4.1 单行 Run(Flow Scalar)
语法:run: <command>
steps: - run: echo "Hello, World!" - run: npm install - run: git status行为:
- 提取
run:之后的命令文本; - 处理 YAML 引号(双引号、单引号、无引号);
- 去除首尾空白。
测试用例(对应源码测试):
run: echo hello→ 提取echo hellorun: "echo \"quoted\""→ 提取echo "quoted"(转义处理)run: 'rm -rf ./build'→ 提取rm -rf ./build(单引号)- run: cmd(列表项中的 run)→ 提取cmd
源码测试github_actions_extractor_extracts_run_steps_only(src/scan.rs#L5883-L5907)验证了单行 run 的提取,包括name: "rm -rf /"不被提取、run: git status与run: rm -rf ./build被正确提取且行号准确(第 8、9 行)。github_actions_quoted_run_value(src/scan.rs#L7522-L7533)验证了带引号的run: "rm -rf ./build"。
4.2 字面块 Run(Literal Block|)
语法:run: |后接缩进内容
steps: - run: | echo "First line" echo "Second line" npm run build行为:
- 识别
|(YAML 字面块标量指示符); - 捕获下方所有缩进行作为 shell 脚本;
- 保留命令之间的换行;
- 后续交给 shell 脚本提取器做命令解析(含注释剥离等)。
测试用例:
- 多行字面块提取所有命令;
- 块内注释由 shell 提取器处理;
- 块内空行保留;
- 缩进回到
run:层级时块结束。
源码测试覆盖丰富:
github_actions_extractor_handles_block_scalar_and_skips_comments(src/scan.rs#L5910-L5927):列表项与steps:键同缩进、块内# rm -rf /注释被跳过、rm -rf ./build被提取;github_actions_literal_block_with_comments(src/scan.rs#L7403-L7418):块内 shell 注释不提取;github_actions_literal_block_multiple_commands(src/scan.rs#L7421-L7435):多命令块按关键字过滤,仅提取含rm的命令;github_actions_literal_block_with_empty_lines(src/scan.rs#L7457-L7473):空行保留且不影响提取。
4.3 折叠块 Run(Folded Block>)
语法:run: >后接缩进内容
steps: - run: > echo "This is a very long command that spans multiple lines and gets folded into one"行为:
- 识别
>(折叠块标量指示符); - 换行按 YAML 标准折叠规则转换为空格;
- 作为单条长命令处理。
测试用例:
- 折叠块
>被识别; - 行折叠正确应用。
对应测试github_actions_folded_block(src/scan.rs#L7438-L7454)。
4.4 引用标量(Quoted Scalars)
语法:run: "command"或run: 'command'
steps: - run: "echo \"nested quotes\"" - run: 'single quoted: no escapes except '''行为:
- 双引号:处理
\n、\t、\"、\\等转义; - 单引号:仅
''转义为单个'; - 无引号:去空白后原样使用。
测试用例:
- 双引号含
\n→ 命令中出现换行; - 双引号含
\"→ 字面双引号; - 单引号含
''→ 单引号; - 无引号值直接提取。
这些行为在源码unquote_yaml_scalar(src/scan.rs#L1985-L2023)中有精确实现:双引号分支逐字符处理\n/\r/\t/\"/\\,未知转义保留原样;单引号分支直接content.replace("''", "'")。规范中的三个示例与源码行为完全对应:
Input: run: "echo \"hello\"" After unquoting: echo "hello" Input: run: 'echo ''quoted''' After unquoting: echo 'quoted' Input: run: echo plain After unquoting: echo plain五、上下文感知提取
5.1 Steps 数组检测
提取器跟踪steps:上下文:
jobs: test: steps: # <-- Enter steps context - run: cmd1 # Extracted - run: cmd2 # Extracted outputs: # <-- Exit steps context result: ...行为:
- 只有活动
steps:块内的run:被提取; - 文档根部或
steps:之外的run:被忽略; - 缩进跟踪决定上下文边界。
源码测试github_actions_extractor_ignores_run_outside_steps(src/scan.rs#L5930-L5941)验证了文档根部的run: rm -rf /不被提取,只有 steps 内的run: echo hello生效。
5.2 显式跳过的块
以下 step 属性明确跳过:
| Property | 原因 |
|---|---|
env: | 环境变量(数据,非命令) |
with: | Action 输入(数据,非命令) |
secrets: | 敏感数据(绝不提取) |
steps: - run: actual-command # Extracted env: FOO: "rm -rf /" # Skipped (data context) with: script: "rm -rf /" # Skipped (action input)测试用例:
env:块值不提取;with:块值不提取;secrets:块值不提取。
5.3 源码实现机制
上下文跟踪是 extract_github_actions_workflow_from_str 的核心状态机,涉及三个状态变量:
steps_indent: Option<usize>:进入steps:键时记录其缩进,作为上下文边界;skip_indent: Option<usize>:遇到env:/with:/secrets:时记录缩进,该缩进内的所有行(包括嵌套的run:键)全部跳过;- 行级处理:跳过空行与注释行 → 处理 skip 块 → 判断是否退出 steps 上下文 → 检测新的
steps:键 → 检测列表项 → 检测env/with/secrets→ 检测run。
关键测试:
github_actions_extractor_does_not_extract_env_or_with_fields(src/scan.rs#L5247-L5267):name、env.X、with.args中都含rm -rf /,但只有run: echo hello是执行上下文,提取结果为空;github_actions_extractor_ignores_run_key_inside_env_block(src/scan.rs#L5270-L5286):env.run: "rm -rf /"这种"藏在 env 里的 run 键"也不会被误提取——这是skip_indent机制的典型防护场景;github_actions_no_false_positive_on_env/on_with/on_name(src/scan.rs#L7476-L7519):分别验证env.CLEANUP、with.command、name:中出现的危险字符串不会触发误报。
六、不提取的行:哪些内容永不进入扫描
6.1 Step 属性(非命令)
| Property | 示例 | 原因 |
|---|---|---|
name: | name: "rm -rf /" | 显示名称,非命令 |
uses: | uses: actions/checkout@v4 | Action 引用 |
id: | id: build-step | Step 标识符 |
if: | if: github.event_name == 'push' | 条件表达式 |
working-directory: | working-directory: ./app | 目录路径 |
continue-on-error: | continue-on-error: true | 布尔标志 |
timeout-minutes: | timeout-minutes: 10 | 数值设置 |
6.2 Workflow 元数据
| Section | 原因 |
|---|---|
name:(workflow) | 显示名称 |
on: | 触发器配置 |
permissions: | 权限设置 |
concurrency: | 并发设置 |
defaults: | 默认设置 |
env:(workflow/job 级别) | 环境变量 |
这些内容属于"数据上下文"而非"执行上下文",跳过它们正是 extractor 区别于 grep 的核心价值。
七、Shell Override 处理:v1 的 Shell 无关行为
GitHub Actions 支持shell:指定命令解释器:
steps: - run: Write-Host "Hello" shell: pwsh - run: python -c "print('hi')" shell: pythonv1 当前行为:shell:属性不被解析,所有run:块一律按 shell(bash)命令处理。
影响:
- PowerShell 脚本被当作 bash 提取;
- Python 内联脚本被当作 shell 命令提取;
- 非 bash shell 可能产生误报。
测试用例:
run:+shell: bash正常提取;run:+shell: pwsh仍被提取(v1 限制);run:+shell: python仍被提取(v1 限制)。
这一点在源码的方言(dialect)映射测试中得到印证:github_actions.steps.run被映射为ShellDialect::Unknown,注释明确写道 "generic/unsupported extractor must not guess"(通用/不支持的提取器不得猜测方言),见 src/scan.rs#L5179-L5185。也就是说,提取器保持 shell 无关,不会对run:内容所属方言做任何假设,这正是 v1 规范"Shell Agnostic"设计的代码级体现。
八、v1 已知限制(Unsupported Constructs)
以下为 v1 已知限制,可能产生非预期行为,应在未来版本解决。
8.1 可复用工作流(workflow_call)
不支持:
# .github/workflows/reusable.yml on: workflow_call: inputs: command: type: string jobs: build: steps: - run: ${{ inputs.command }}当前行为:模板表达式(${{ }})按字面提取。
影响:来自 workflow 输入的动态命令值不会被展开。
8.2 复合 Actions(Composite Actions)
不支持:
# action.yml (composite action) runs: using: composite steps: - run: dangerous-command shell: bash当前行为:名为action.yml的文件不通过文件检测。
影响:复合 action 中的命令不被扫描。
8.3 表达式替换(Expression Substitution)
不支持:
steps: - run: ${{ github.event.inputs.command }} - run: echo "${{ secrets.SCRIPT }}"当前行为:表达式按字面${{ ... }}提取。
影响:来自 inputs、secrets 或 context 的命令不会被展开。
8.4 矩阵策略(Matrix Strategies)
部分支持:
jobs: test: strategy: matrix: cmd: ['cmd1', 'cmd2'] steps: - run: ${{ matrix.cmd }}当前行为:run: ${{ matrix.cmd }}按字面提取。
影响:矩阵展开后的命令不会逐个扫描。
8.5 Shell Override 解释
不支持:
steps: - run: | import os os.remove('/tmp/file') shell: python当前行为:Python 代码被提取并按 bash 扫描。
影响:Python 特有模式(如os.remove)不会被 bash 模式匹配。
8.6 条件 Run 块
部分支持:
steps: - run: dangerous-command if: github.ref == 'refs/heads/main'当前行为:无论if:条件如何,命令都会被提取。
影响:条件跳过(conditionally-skipped)的命令仍会被标记。
8.7 多 Job 工作流
支持:
jobs: job1: steps: - run: cmd1 job2: steps: - run: cmd2当前行为:所有 job 的 steps 都会被扫描。
说明:这是 v1 中已正确工作的部分。源码测试github_actions_extractor_extracts_run_steps_only与github_actions_extractor_handles_block_scalar_and_skips_comments中的多 job 结构均验证了这一点。
九、实现细节(源码级)
9.1 Steps 上下文跟踪
规范中的伪代码流程:
1. Find `steps:` key at job level 2. Track indentation of `steps:` line 3. Within steps block: - Look for list items (`- `) at appropriate indent - Check for `run:` key in each item - Skip `env:`, `with:`, `secrets:` sub-blocks 4. Exit steps context when indentation decreases对应源码 src/scan.rs#L1854-L1983 的实际实现要点:
steps_indent只在steps:键值后为空或注释时置位(src/scan.rs#L1895-L1903),即steps: [...]内联序列形式不被跟踪;- 列表项可以出现在与
steps:键相同的缩进层级(indent == steps && trimmed_start.starts_with('-')判定,见 src/scan.rs#L1910); - 块标量(
|/>)通过block_start_line = line_no + 1记录起始行,收集所有缩进大于run:键缩进的行,交给extract_shell_script_with_offset_and_id处理(src/scan.rs#L1935-L1968); - 普通 run 值先经
unquote_yaml_scalar去引号,再进入同一 shell 提取通道(src/scan.rs#L1970-L1977)。
9.2 块标量提取
对于run: |或run: >:
- 记录
run:行的缩进层级; - 收集后续所有缩进更大的行;
- 传给 shell 脚本提取器;
- shell 提取器负责注释剥离等处理。
空行在块内被保留(block.push('\n')),缩进回到run:键层级即结束块(src/scan.rs#L1940-L1956)。
9.3 YAML 标量去引号
unquote_yaml_scalar(src/scan.rs#L1985-L2023)完整实现了规范中的三种输入处理:
| 输入 | 处理后 |
|---|---|
"echo \"hello\"" | echo "hello" |
'echo ''quoted''' | echo 'quoted' |
echo plain | echo plain |
十、测试清单:验证实现的完整核对表
规范提供了一份完整的验证清单,可直接用于实现回归测试:
路径检测
.github/workflows/ci.yml匹配.github/workflows/ci.yaml匹配.github/workflows/sub/ci.yml匹配(嵌套).GITHUB/WORKFLOWS/CI.YML匹配(大小写不敏感).github/workflows/ci.json不匹配workflows/ci.yml不匹配(缺 .github).github/workflow/ci.yml不匹配(单数)action.yml不匹配
单行 Run
run: echo hello提取命令run: "quoted command"处理双引号run: 'single quoted'处理单引号- run: cmd(列表项)可用- run 值后的行内注释(如有)
块标量 Run
run: |字面块提取所有行run: >折叠块被识别- 多行内容正确拼接
- 块在正确缩进处结束
上下文感知
steps:内的run:被提取steps:外的run:不提取- 多 job 的 steps 全部扫描
env:块值跳过with:块值跳过secrets:块值跳过
非 Run 属性
name: "cmd"不提取uses: action@v1不提取if: condition不提取id: step-id不提取
引用处理
- 含转义序列的双引号
- 含
''转义的单引号 - 无引号值可用
边界情况
- 空 workflow 无命令返回
- 仅含
uses:步骤的 workflow 无命令返回 - 关键字过滤限制提取范围
- 块标量中的注释被处理
上述清单几乎每一项都能在 src/scan.rs 的测试模块中找到对应断言。此外,提取器还通过extractors_handle_crlf_line_endings_identically_to_lf测试(src/scan.rs#L5189-L5244)验证了 CRLF 与 LF 行尾的提取结果完全一致——Windows 上编辑的 workflow 文件(\r\n)不会泄漏多余的\r到匹配命令中,这对跨平台 CI 防护至关重要。
十一、实战落地:用 dcg scan 扫描 GitHub Actions workflow
11.1 基本命令
# 扫描指定路径(目录递归展开),包括 .github/workflows/ dcg scan --paths scripts/ .github/workflows/ # 扫描暂存区文件(git index),适合 pre-commit dcg scan --staged # 扫描 git diff 范围 dcg scan --git-diff HEAD~3..HEAD # 扫描时临时启用额外 packs dcg scan --paths scripts/ --with-packs careful_company_running_windows11.2 输出与策略控制
--format pretty|json|markdown|sarif:dcg scan是唯一能输出真实 SARIF 2.1.0 报告的命令(README.md#L744);--fail-on error:仅当命中灾难级规则(error 级)时才非零退出,适合保守起步;--max-file-size BYTES、--max-findings N:控制扫描资源;--exclude GLOB/--include GLOB:细粒度文件过滤。
推荐的分阶段落地策略(来自 README.md#L1706-L1742):
# Week 1-2: 仅对灾难级规则失败 dcg scan --staged --fail-on error配合.dcg/hooks.toml保守配置:
[scan] fail_on = "error" # 仅对高置信灾难规则失败 format = "pretty" # 人类可读输出 redact = "quoted" # 隐藏敏感字符串 truncate = 120 # 缩短长命令 [scan.paths] include = [ ".github/workflows/**", # 先从 CI 配置开始 "Dockerfile", # 容器构建 "Makefile", # 构建脚本 ] exclude = [ "target/**", "node_modules/**", "vendor/**", ]11.3 输出解读
.github/workflows/ci.yml:42:5: [ERROR] core.git:reset-hard Command: git reset --hard HEAD Reason: git reset --hard destroys uncommitted changes Suggestion: Consider using 'git stash' first to save changes.File:Line:Col直接指向 workflow 文件中run:的实际位置——这正是 extractor 逐行跟踪行号(line_no)并在块标量场景记录block_start_line的意义所在。
11.4 Pre-commit 集成
# 一键安装(创建 .git/hooks/pre-commit,内部执行 dcg scan --staged) dcg scan install-pre-commit # 卸载(仅移除带 dcg sentinel 标记的钩子) dcg scan uninstall-pre-commit也可以手动在 hook 中写入dcg scan --staged --fail-on error。
十二、版本历史与相关任务
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2026-01-16 | Initial specification |
本规范属于 Scan Mode Extractors for CI/DevOps Files 系列,与 Dockerfile 提取器 v1 规范 同批制定。相关任务包括:GitHub Actions workflow 提取器的实现与单元测试。规范中明确该提取器当前状态为 first-pass(第一轮实现),shell:解析、模板表达式展开、复合 action 支持等均列入未来工作范畴。
结语
GitHub Actions workflow 提取器 v1 的核心设计哲学可以概括为三句话:只提取steps:内run:的可执行上下文;对env:/with:/secrets:等数据上下文坚决沉默;对无法确定语义的shell:覆盖、模板表达式保持保守不猜。这套"prefer silence over false positives"的设计,配合dcg scan的 pre-commit 与 CI 集成能力,让团队能够以极低的误报成本,将 CI 配置纳入危险命令防护体系。本文所引用的 规范文档、提取器实现 与 README 扫描章节 可作为深入研究的起点。
【免费下载链接】destructive_command_guardThe Destructive Command Guard (dcg) is for blocking dangerous git and shell commands from being executed by agents.项目地址: https://gitcode.com/GitHub_Trending/de/destructive_command_guard
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考