GitHub Actions 安全扫描工具集:CVE-2025-30066 检测与防御
本工具集专为应对 CVE-2025-30066 漏洞设计,旨在帮助开发者和安全团队主动扫描 GitHub Actions 工作流中的潜在恶意行为、风险组件以及可能通过日志泄露的密钥。通过自动化扫描,您可以在攻击者利用之前发现并修复安全隐患,保障 CI/CD 流水线的安全。
功能特性
CxGithubActionsScan - 工作流恶意行为扫描
- 多维度扫描模式:支持按组织(
--org)、按单个仓库(--repo)或按特定用户(--user)进行扫描,灵活适配不同管理场景。 - 高风险 Actions 检测:自动识别工作流文件中是否使用了已知与 CVE-2025-30066 相关的风险 Actions,如
reviewdog/*和tj-actions/*系列。 - 恶意代码片段检测:扫描工作流文件中的 Base64 编码可疑载荷,识别潜在的隐蔽后门或信息窃取脚本。
- 递归目录扫描:能够深入
.github/workflows目录及其子目录,全面审查所有 YAML 配置文件。
CxGithub2msScan - 工作流日志密钥泄露扫描
- 自动化日志收集:通过 GitHub API 批量获取指定时间范围内(如最近 7 天)的 GitHub Actions 工作流运行记录。
- 强大的密钥检测引擎:集成 Checkmarx 2ms 工具,对下载的日志文件进行深度扫描,精准识别已泄露的密钥、令牌和密码。
- 并发处理:利用
concurrent.futures实现多线程扫描,大幅提升大规模日志文件的处理效率。 - 扫描报告输出:清晰输出每个日志文件中发现的密钥数量及详细扫描结果,便于快速定位和响应。
安装指南
系统要求
- 操作系统:Windows、Linux、macOS
- Python 版本:3.6 或更高版本
- GitHub 访问权限:需要具有相应权限的 GitHub 个人访问令牌
依赖安装
-
克隆或下载本工具集 到本地目录。
-
安装 Python 依赖包:
pip install requests -
下载并配置 Checkmarx 2ms 工具(仅
CxGithub2msScan需要):- 访问 Checkmarx/2ms GitHub 仓库 下载适用于您操作系统的
2ms可执行文件。 - 将可执行文件放置于系统 PATH 环境变量包含的目录中,或直接放置在与
CxGithub2msScan.py相同的目录下。
- 访问 Checkmarx/2ms GitHub 仓库 下载适用于您操作系统的
配置 GitHub 个人访问令牌
- 登录 GitHub,点击右上角头像 → Settings。
- 进入 Developer settings → Personal access tokens → Tokens (classic)。
- 点击 Generate new token,为令牌命名并选择以下权限范围:
- 对于组织扫描:需要
repo(完全控制私有仓库)和read:org(读取组织数据)。 - 对于用户/仓库扫描:至少需要
public_repo(访问公共仓库)或repo(访问私有仓库)。
- 对于组织扫描:需要
- 生成并复制令牌,在运行脚本时通过
--token参数提供。
使用说明
CxGithubActionsScan:扫描工作流中的风险
扫描一个组织内所有仓库
python CxGithubActionsScan.py --org your-organization-name --token YOUR_GITHUB_PAT
扫描单个指定仓库
python CxGithubActionsScan.py --repo owner/repo-name --token YOUR_GITHUB_PAT
或使用完整 GitHub URL:
python CxGithubActionsScan.py --repo https://github.com/owner/repo-name --token YOUR_GITHUB_PAT
扫描特定用户的所有公共仓库
python CxGithubActionsScan.py --user github-username --token YOUR_GITHUB_PAT
CxGithub2msScan:扫描工作流日志中的密钥泄露
扫描指定仓库最近 7 天的日志
python CxGithub2msScan.py --owner your-org-or-username --repo your-repo-name --days 7 --token YOUR_GITHUB_TOKEN --output ./logs
参数说明
--owner:仓库所属的组织名或用户名。--repo:仓库名称。--days:要扫描的日志天数范围(从当前时间向前推)。--token:GitHub 个人访问令牌。--output:下载的日志文件存储目录。
核心代码
CxGithubActionsScan - 核心扫描逻辑 (片段)
# 递归扫描仓库中的指定路径,检测风险 Actions 和恶意代码
def scan_path(owner, repo, path, search_terms):
url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}"
response = requests.get(url, headers=HEADERS)
if response.status_code == 404:
return {}
if response.status_code != 200:
print(f"Error retrieving {path} in {owner}/{repo}: {response.status_code}")
return {}
data = response.json()
results = {}
if isinstance(data, dict):
# 处理单个文件(Base64 编码)
if "content" in data and data.get("encoding") == "base64":
try:
content = base64.b64decode(data["content"]).decode("utf-8")
except Exception as e:
print(f"Error decoding content in {owner}/{repo}/{path}: {e}")
return results
# 检查文件中是否包含任何风险特征词
matches = [term for term in search_terms if term in content]
if matches:
results[path] = matches
return results
# ... 处理目录的逻辑
CxGithub2msScan - 日志扫描核心逻辑 (片段)
# 获取指定时间范围内的所有工作流运行记录
def get_workflow_runs(owner, repo, days, token):
headers = {"Accept": "application/vnd.github.v3+json", "Authorization": f"Bearer {token}"}
runs = []
page = 1
per_page = 100
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=days)
while True:
url = f"https://api.github.com/repos/{owner}/{repo}/actions/runs"
params = {"per_page": per_page, "page": page}
resp = requests.get(url, headers=headers, params=params)
if resp.status_code != 200:
break
data = resp.json()
if "workflow_runs" not in data or not data["workflow_runs"]:
break
for run in data["workflow_runs"]:
created_at = datetime.datetime.strptime(run["created_at"], "%Y-%m-%dT%H:%M:%SZ")
if created_at < cutoff:
return runs # 返回截止时间之前的 runs
runs.append(run)
page += 1
return runs
# 使用 2ms 引擎扫描单个文件
def scan_file(file_path):
print(f"Running Checkmarx 2ms on {file_path}...")
try:
result = subprocess.run(["2ms.exe", "filesystem", "--path", file_path],
capture_output=True, text=True)
if result.returncode != 0:
return {"file": file_path, "error": result.stderr.strip()}
# 从输出中提取找到的密钥数量
match = re.search(r"totalsecretsfound:\s*(\d+)", result.stdout, re.IGNORECASE)
secret_count = int(match.group(1)) if match else 0
if secret_count > 0:
return {"file": file_path, "secrets_found": secret_count, "output": result.stdout.strip()}
else:
return {"file": file_path, "secrets_found": 0}
except Exception as e:
return {"file": file_path, "error": str(e)}
```FINISHED
6HFtX5dABrKlqXeO5PUv/7b4YVDDMwcrE+FmfPoZGK2bxM2ff+ga26S+bYmqNFP+wkH49J3l6n8JiqJi3LVqOw==