/
/
/
1# Dependency Security Check Workflow
2# Checks Python dependencies for security vulnerabilities and supply chain risks.
3#
4# SECURITY: This workflow runs on `pull_request` (NOT `pull_request_target`), so
5# for PRs from forks it gets a read-only GITHUB_TOKEN and no access to repository
6# secrets. That makes it safe to check out and execute untrusted PR code here.
7# It cannot comment on the PR or change labels; that is handled by the companion
8# `dependency-security-report.yml` workflow, which runs with write permissions
9# but never checks out untrusted code.
10
11name: Dependency Security Check
12
13on:
14 pull_request:
15 types: [opened, synchronize, reopened, labeled]
16 paths:
17 - "requirements_all.txt"
18 - "**/manifest.json"
19 - "pyproject.toml"
20 branches:
21 - stable
22 - dev
23
24permissions:
25 contents: read
26
27# Keep urllib3-future from hijacking the urllib3 namespace (see pyproject.toml).
28env:
29 URLLIB3_NO_OVERRIDE: "1"
30
31jobs:
32 audit:
33 runs-on: ubuntu-latest
34 permissions:
35 contents: read
36 steps:
37 - name: Check out code from GitHub
38 uses: actions/checkout@v7
39 with:
40 ref: ${{ github.event.pull_request.head.sha }}
41 fetch-depth: 0 # Need full history for diff
42
43 - name: Set up Python
44 uses: actions/[email protected]
45 with:
46 python-version-file: ".python-version"
47 check-latest: true
48
49 - name: Install uv
50 run: pip install uv
51
52 - name: Install requirements for audit
53 # https://docs.astral.sh/uv/pip/compatibility/#packages-that-exist-on-multiple-indexes
54 run: |
55 uv venv
56 uv pip install --index-strategy unsafe-best-match -r requirements_all.txt pip-audit
57
58 # Step 1: Verify requirements_all.txt is in sync
59 - name: Check requirements_all.txt sync
60 id: req_sync
61 run: |
62 # Save current requirements_all.txt
63 cp requirements_all.txt requirements_all.txt.original
64
65 # Regenerate requirements_all.txt
66 python3 scripts/gen_requirements_all.py
67
68 # Check if it changed
69 if ! diff -q requirements_all.txt.original requirements_all.txt > /dev/null; then
70 echo "status=out_of_sync" >> $GITHUB_OUTPUT
71 echo "â ï¸ **requirements_all.txt is out of sync**" > sync_report.md
72 echo "" >> sync_report.md
73 echo "The \`requirements_all.txt\` file should be auto-generated from \`pyproject.toml\` and provider manifests." >> sync_report.md
74 echo "" >> sync_report.md
75 echo "**Action required:** Run \`python scripts/gen_requirements_all.py\` and commit the changes." >> sync_report.md
76 # Restore original
77 mv requirements_all.txt.original requirements_all.txt
78 else
79 echo "status=synced" >> $GITHUB_OUTPUT
80 echo "â
requirements_all.txt is properly synchronized" > sync_report.md
81 rm requirements_all.txt.original
82 fi
83
84 # Step 2: Detect new or changed dependencies
85 - name: Detect dependency changes
86 id: deps_check
87 run: |
88 # Get base branch (dev or stable)
89 BASE_BRANCH="${{ github.base_ref }}"
90
91 # Check for changes in requirements_all.txt
92 if git diff origin/$BASE_BRANCH...HEAD -- requirements_all.txt > /dev/null 2>&1; then
93 # Extract added lines (new or modified dependencies)
94 git diff origin/$BASE_BRANCH...HEAD -- requirements_all.txt | \
95 grep "^+" | grep -v "^+++" | sed 's/^+//' > new_deps_raw.txt || true
96
97 # Also check for version changes (lines that were modified)
98 git diff origin/$BASE_BRANCH...HEAD -- requirements_all.txt | \
99 grep "^-" | grep -v "^---" | sed 's/^-//' > old_deps_raw.txt || true
100
101 if [ -s new_deps_raw.txt ]; then
102 echo "has_changes=true" >> $GITHUB_OUTPUT
103 echo "## ð¦ Dependency Changes Detected" > deps_report.md
104 echo "" >> deps_report.md
105 echo "The following dependencies were added or modified:" >> deps_report.md
106 echo "" >> deps_report.md
107 echo '```diff' >> deps_report.md
108 git diff origin/$BASE_BRANCH...HEAD -- requirements_all.txt >> deps_report.md
109 echo '```' >> deps_report.md
110 echo "" >> deps_report.md
111
112 # Extract just package names for safety check
113 cat new_deps_raw.txt | grep -v "^#" | grep -v "^$" > new_deps.txt || true
114
115 if [ -s new_deps.txt ]; then
116 echo "New/modified packages to review:" >> deps_report.md
117 cat new_deps.txt | while read line; do
118 echo "- \`$line\`" >> deps_report.md
119 done
120 fi
121 else
122 echo "has_changes=false" >> $GITHUB_OUTPUT
123 echo "No dependency changes detected in requirements_all.txt" > deps_report.md
124 fi
125 else
126 echo "has_changes=false" >> $GITHUB_OUTPUT
127 echo "No dependency changes detected" > deps_report.md
128 fi
129
130 cat deps_report.md
131
132 # Step 3: Resolve what the target branch installs, so findings can be split into
133 # ones this PR introduces and ones that already exist there. Resolving is enough,
134 # no second environment has to be installed. pip-audit is included because it is
135 # part of the audited environment. On failure no file is written and the audit
136 # falls back to comparing the changed requirements.
137 - name: Resolve target branch dependencies
138 continue-on-error: true
139 run: |
140 BASE_BRANCH="${{ github.base_ref }}"
141 git show origin/$BASE_BRANCH:requirements_all.txt > base_requirements.txt
142 echo "pip-audit" >> base_requirements.txt
143
144 uv pip compile --index-strategy unsafe-best-match --python .venv/bin/python \
145 --no-annotate --no-header base_requirements.txt -o base_closure.txt
146
147 # Step 4: Run pip-audit for known vulnerabilities. The audit covers the whole
148 # installed environment, so only findings this PR introduces gate it; the ones
149 # the target branch already has are reported for information.
150 - name: Run pip-audit on installed environment
151 id: pip_audit
152 run: |
153 echo "## ð Vulnerability Scan Results" > audit_report.md
154 echo "" >> audit_report.md
155
156 .venv/bin/pip-audit --desc --format=markdown >> audit_report.md 2>&1 || true
157 .venv/bin/pip-audit --format=json --output=audit.json || true
158
159 # Gate when the scan itself could not be completed, rather than pass silently
160 STATUS=$(python3 scripts/audit_changed_packages.py audit.json new_deps.txt base_closure.txt) || STATUS=scan_failed
161 echo "status=$STATUS" >> $GITHUB_OUTPUT
162
163 echo "" >> audit_report.md
164 case "$STATUS" in
165 fail)
166 echo "â ï¸ **Vulnerabilities detected in the dependencies this PR introduces!**" >> audit_report.md
167 ;;
168 preexisting)
169 echo "â¹ï¸ The findings above are already present on the target branch, so this PR does not introduce them." >> audit_report.md
170 ;;
171 scan_failed)
172 echo "â The vulnerability scan could not be completed." >> audit_report.md
173 ;;
174 *)
175 echo "â
No known vulnerabilities found" >> audit_report.md
176 ;;
177 esac
178
179 if [ "$STATUS" != "pass" ] && [ ! -s base_closure.txt ]; then
180 echo "" >> audit_report.md
181 echo "â ï¸ The target branch could not be resolved, so only the dependencies this PR changes directly were compared." >> audit_report.md
182 fi
183
184 cat audit_report.md
185
186 # Step 5: Check manifest.json changes
187 - name: Check provider manifest changes
188 id: manifest_check
189 run: |
190 BASE_BRANCH="${{ github.base_ref }}"
191
192 # Find all changed manifest.json files
193 CHANGED_MANIFESTS=$(git diff --name-only origin/$BASE_BRANCH...HEAD | grep "manifest.json" || true)
194
195 if [ -n "$CHANGED_MANIFESTS" ]; then
196 echo "## ð Provider Manifest Changes" > manifest_report.md
197 echo "" >> manifest_report.md
198
199 HAS_REQ_CHANGES=false
200
201 for manifest in $CHANGED_MANIFESTS; do
202 # Check if requirements actually changed
203 OLD_REQS=$(git show origin/$BASE_BRANCH:$manifest 2>/dev/null | python3 -c "import sys, json; data=json.load(sys.stdin); print(' '.join(data.get('requirements', [])))" 2>/dev/null || echo "")
204 NEW_REQS=$(cat $manifest | python3 -c "import sys, json; data=json.load(sys.stdin); print(' '.join(data.get('requirements', [])))" 2>/dev/null || echo "")
205
206 if [ "$OLD_REQS" != "$NEW_REQS" ]; then
207 HAS_REQ_CHANGES=true
208 echo "### \`$manifest\`" >> manifest_report.md
209 echo "" >> manifest_report.md
210
211 # Save old and new versions for comparison
212 git show origin/$BASE_BRANCH:$manifest > /tmp/old_manifest.json 2>/dev/null || echo '{"requirements":[]}' > /tmp/old_manifest.json
213 cp $manifest /tmp/new_manifest.json
214
215 # Use Python script to parse dependency changes
216 python3 scripts/parse_manifest_deps.py /tmp/old_manifest.json /tmp/new_manifest.json >> manifest_report.md
217 echo "" >> manifest_report.md
218 fi
219 done
220
221 if [ "$HAS_REQ_CHANGES" = "true" ]; then
222 echo "has_changes=true" >> $GITHUB_OUTPUT
223 else
224 echo "has_changes=false" >> $GITHUB_OUTPUT
225 echo "Manifest files changed but no dependency changes detected" > manifest_report.md
226 fi
227 else
228 echo "has_changes=false" >> $GITHUB_OUTPUT
229 echo "No provider manifest changes detected" > manifest_report.md
230 fi
231
232 cat manifest_report.md
233
234 # Step 6: Run package safety check on new dependencies
235 - name: Check new package safety
236 id: safety_check
237 if: steps.deps_check.outputs.has_changes == 'true'
238 continue-on-error: true
239 run: |
240 echo "## ð¡ï¸ Supply Chain Security Check" > safety_report.md
241 echo "" >> safety_report.md
242
243 if [ -f new_deps.txt ] && [ -s new_deps.txt ]; then
244 # Run our custom safety check script
245 python scripts/check_package_safety.py new_deps.txt > safety_output.txt 2>&1
246 SAFETY_EXIT=$?
247
248 cat safety_output.txt >> safety_report.md
249 echo "" >> safety_report.md
250
251 # Parse automated check results
252 if grep -q "â
.*Trusted Sources.*All packages" safety_output.txt; then
253 echo "trusted_sources=pass" >> $GITHUB_OUTPUT
254 else
255 echo "trusted_sources=fail" >> $GITHUB_OUTPUT
256 fi
257
258 if grep -q "â
.*Typosquatting.*No suspicious" safety_output.txt; then
259 echo "typosquatting=pass" >> $GITHUB_OUTPUT
260 else
261 echo "typosquatting=fail" >> $GITHUB_OUTPUT
262 fi
263
264 if grep -q "â
.*License.*All licenses" safety_output.txt; then
265 echo "license=pass" >> $GITHUB_OUTPUT
266 else
267 echo "license=fail" >> $GITHUB_OUTPUT
268 fi
269
270 if [ $SAFETY_EXIT -eq 2 ]; then
271 echo "status=high_risk" >> $GITHUB_OUTPUT
272 echo "" >> safety_report.md
273 echo "â ï¸ **HIGH RISK PACKAGES DETECTED**" >> safety_report.md
274 echo "Manual security review is **required** before merging this PR." >> safety_report.md
275 elif [ $SAFETY_EXIT -eq 1 ]; then
276 echo "status=medium_risk" >> $GITHUB_OUTPUT
277 echo "" >> safety_report.md
278 echo "â ï¸ **MEDIUM RISK PACKAGES DETECTED**" >> safety_report.md
279 echo "Please review the warnings above before merging." >> safety_report.md
280 else
281 echo "status=pass" >> $GITHUB_OUTPUT
282 fi
283 else
284 echo "No new dependencies to check" >> safety_report.md
285 echo "status=pass" >> $GITHUB_OUTPUT
286 echo "trusted_sources=pass" >> $GITHUB_OUTPUT
287 echo "typosquatting=pass" >> $GITHUB_OUTPUT
288 echo "license=pass" >> $GITHUB_OUTPUT
289 fi
290
291 cat safety_report.md
292
293 # Step 7: Persist analysis results for the reporting workflow.
294 # The report runs via workflow_run and cannot see this job's outputs, so
295 # the result strings are passed via the artifact. This data is UNTRUSTED
296 # (produced by fork code); the reporting workflow validates it and derives
297 # all PR identity/labels from the GitHub API, never from this artifact.
298 - name: Persist analysis results
299 if: always()
300 run: |
301 {
302 echo "REQ_SYNC=${{ steps.req_sync.outputs.status }}"
303 echo "PIP_AUDIT=${{ steps.pip_audit.outputs.status }}"
304 echo "DEPS_CHANGES=${{ steps.deps_check.outputs.has_changes }}"
305 echo "MANIFEST_CHANGES=${{ steps.manifest_check.outputs.has_changes }}"
306 echo "SAFETY_STATUS=${{ steps.safety_check.outputs.status }}"
307 echo "TRUSTED_SOURCES=${{ steps.safety_check.outputs.trusted_sources }}"
308 echo "TYPOSQUATTING=${{ steps.safety_check.outputs.typosquatting }}"
309 echo "LICENSE=${{ steps.safety_check.outputs.license }}"
310 } > status.env
311
312 # Step 8: Hand off report fragments and results to the reporting workflow
313 - name: Upload security reports
314 if: always()
315 uses: actions/[email protected]
316 with:
317 name: security-reports
318 path: |
319 sync_report.md
320 audit_report.md
321 deps_report.md
322 manifest_report.md
323 safety_report.md
324 status.env
325 if-no-files-found: ignore
326 retention-days: 1
327