/
/
/
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 both branches install, so findings can be split into ones
133 # this PR introduces and ones that already exist on the target branch. Resolving is
134 # enough, no second environment has to be installed. pip-audit is included because
135 # it is part of the audited environment. Both are resolved back to back so that a
136 # release published mid-run cannot make them disagree. On failure no file is written
137 # and the audit falls back to comparing the changed requirements.
138 - name: Resolve branch dependencies
139 continue-on-error: true
140 run: |
141 BASE_BRANCH="${{ github.base_ref }}"
142 git show origin/$BASE_BRANCH:requirements_all.txt > base_requirements.txt
143 echo "pip-audit" >> base_requirements.txt
144
145 # Step 1 leaves the PR's own requirements_all.txt in place, in sync or not
146 cp requirements_all.txt head_requirements.txt
147 echo "pip-audit" >> head_requirements.txt
148
149 uv pip compile --index-strategy unsafe-best-match --python .venv/bin/python \
150 --no-annotate --no-header base_requirements.txt -o base_closure.txt
151 uv pip compile --index-strategy unsafe-best-match --python .venv/bin/python \
152 --no-annotate --no-header head_requirements.txt -o head_closure.txt
153
154 # Step 4: Run pip-audit for known vulnerabilities. The audit covers the whole
155 # installed environment, so only findings this PR introduces gate it; the ones
156 # the target branch already has are reported for information.
157 - name: Run pip-audit on installed environment
158 id: pip_audit
159 run: |
160 echo "## ð Vulnerability Scan Results" > audit_report.md
161 echo "" >> audit_report.md
162
163 .venv/bin/pip-audit --desc --format=markdown >> audit_report.md 2>&1 || true
164 .venv/bin/pip-audit --format=json --output=audit.json || true
165
166 # Gate when the scan itself could not be completed, rather than pass silently.
167 # Only the status goes to stdout; stderr carries the findings that were not
168 # gated on, and is echoed back so it stays in the job log as well.
169 STATUS=$(python3 scripts/audit_changed_packages.py audit.json new_deps.txt base_closure.txt head_closure.txt 2>audit_notes.txt) || STATUS=scan_failed
170 echo "status=$STATUS" >> $GITHUB_OUTPUT
171 cat audit_notes.txt >&2
172
173 echo "" >> audit_report.md
174 case "$STATUS" in
175 fail)
176 echo "â ï¸ **Vulnerabilities detected in the dependencies this PR introduces!**" >> audit_report.md
177 ;;
178 preexisting)
179 echo "â¹ï¸ The findings above are not ones this PR introduces." >> audit_report.md
180 ;;
181 scan_failed)
182 echo "â The vulnerability scan could not be completed." >> audit_report.md
183 ;;
184 *)
185 echo "â
No known vulnerabilities found" >> audit_report.md
186 ;;
187 esac
188
189 # On a failed scan stderr holds the traceback, which belongs in the log only
190 if [ "$STATUS" != "scan_failed" ] && [ -s audit_notes.txt ]; then
191 echo "" >> audit_report.md
192 echo "â¹ï¸ $(cat audit_notes.txt)" >> audit_report.md
193 fi
194
195 if [ "$STATUS" != "pass" ] && [ ! -s base_closure.txt ]; then
196 echo "" >> audit_report.md
197 echo "â ï¸ The target branch could not be resolved, so only the dependencies this PR changes directly were compared." >> audit_report.md
198 fi
199
200 cat audit_report.md
201
202 # Step 5: Check manifest.json changes
203 - name: Check provider manifest changes
204 id: manifest_check
205 run: |
206 BASE_BRANCH="${{ github.base_ref }}"
207
208 # Find all changed manifest.json files
209 CHANGED_MANIFESTS=$(git diff --name-only origin/$BASE_BRANCH...HEAD | grep "manifest.json" || true)
210
211 if [ -n "$CHANGED_MANIFESTS" ]; then
212 echo "## ð Provider Manifest Changes" > manifest_report.md
213 echo "" >> manifest_report.md
214
215 HAS_REQ_CHANGES=false
216
217 for manifest in $CHANGED_MANIFESTS; do
218 # Check if requirements actually changed
219 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 "")
220 NEW_REQS=$(cat $manifest | python3 -c "import sys, json; data=json.load(sys.stdin); print(' '.join(data.get('requirements', [])))" 2>/dev/null || echo "")
221
222 if [ "$OLD_REQS" != "$NEW_REQS" ]; then
223 HAS_REQ_CHANGES=true
224 echo "### \`$manifest\`" >> manifest_report.md
225 echo "" >> manifest_report.md
226
227 # Save old and new versions for comparison
228 git show origin/$BASE_BRANCH:$manifest > /tmp/old_manifest.json 2>/dev/null || echo '{"requirements":[]}' > /tmp/old_manifest.json
229 cp $manifest /tmp/new_manifest.json
230
231 # Use Python script to parse dependency changes
232 python3 scripts/parse_manifest_deps.py /tmp/old_manifest.json /tmp/new_manifest.json >> manifest_report.md
233 echo "" >> manifest_report.md
234 fi
235 done
236
237 if [ "$HAS_REQ_CHANGES" = "true" ]; then
238 echo "has_changes=true" >> $GITHUB_OUTPUT
239 else
240 echo "has_changes=false" >> $GITHUB_OUTPUT
241 echo "Manifest files changed but no dependency changes detected" > manifest_report.md
242 fi
243 else
244 echo "has_changes=false" >> $GITHUB_OUTPUT
245 echo "No provider manifest changes detected" > manifest_report.md
246 fi
247
248 cat manifest_report.md
249
250 # Step 6: Run package safety check on new dependencies
251 - name: Check new package safety
252 id: safety_check
253 if: steps.deps_check.outputs.has_changes == 'true'
254 continue-on-error: true
255 run: |
256 echo "## ð¡ï¸ Supply Chain Security Check" > safety_report.md
257 echo "" >> safety_report.md
258
259 if [ -f new_deps.txt ] && [ -s new_deps.txt ]; then
260 # Run our custom safety check script
261 python scripts/check_package_safety.py new_deps.txt > safety_output.txt 2>&1
262 SAFETY_EXIT=$?
263
264 cat safety_output.txt >> safety_report.md
265 echo "" >> safety_report.md
266
267 # Parse automated check results
268 if grep -q "â
.*Trusted Sources.*All packages" safety_output.txt; then
269 echo "trusted_sources=pass" >> $GITHUB_OUTPUT
270 else
271 echo "trusted_sources=fail" >> $GITHUB_OUTPUT
272 fi
273
274 if grep -q "â
.*Typosquatting.*No suspicious" safety_output.txt; then
275 echo "typosquatting=pass" >> $GITHUB_OUTPUT
276 else
277 echo "typosquatting=fail" >> $GITHUB_OUTPUT
278 fi
279
280 if grep -q "â
.*License.*All licenses" safety_output.txt; then
281 echo "license=pass" >> $GITHUB_OUTPUT
282 else
283 echo "license=fail" >> $GITHUB_OUTPUT
284 fi
285
286 if [ $SAFETY_EXIT -eq 2 ]; then
287 echo "status=high_risk" >> $GITHUB_OUTPUT
288 echo "" >> safety_report.md
289 echo "â ï¸ **HIGH RISK PACKAGES DETECTED**" >> safety_report.md
290 echo "Manual security review is **required** before merging this PR." >> safety_report.md
291 elif [ $SAFETY_EXIT -eq 1 ]; then
292 echo "status=medium_risk" >> $GITHUB_OUTPUT
293 echo "" >> safety_report.md
294 echo "â ï¸ **MEDIUM RISK PACKAGES DETECTED**" >> safety_report.md
295 echo "Please review the warnings above before merging." >> safety_report.md
296 else
297 echo "status=pass" >> $GITHUB_OUTPUT
298 fi
299 else
300 echo "No new dependencies to check" >> safety_report.md
301 echo "status=pass" >> $GITHUB_OUTPUT
302 echo "trusted_sources=pass" >> $GITHUB_OUTPUT
303 echo "typosquatting=pass" >> $GITHUB_OUTPUT
304 echo "license=pass" >> $GITHUB_OUTPUT
305 fi
306
307 cat safety_report.md
308
309 # Step 7: Persist analysis results for the reporting workflow.
310 # The report runs via workflow_run and cannot see this job's outputs, so
311 # the result strings are passed via the artifact. This data is UNTRUSTED
312 # (produced by fork code); the reporting workflow validates it and derives
313 # all PR identity/labels from the GitHub API, never from this artifact.
314 - name: Persist analysis results
315 if: always()
316 run: |
317 {
318 echo "REQ_SYNC=${{ steps.req_sync.outputs.status }}"
319 echo "PIP_AUDIT=${{ steps.pip_audit.outputs.status }}"
320 echo "DEPS_CHANGES=${{ steps.deps_check.outputs.has_changes }}"
321 echo "MANIFEST_CHANGES=${{ steps.manifest_check.outputs.has_changes }}"
322 echo "SAFETY_STATUS=${{ steps.safety_check.outputs.status }}"
323 echo "TRUSTED_SOURCES=${{ steps.safety_check.outputs.trusted_sources }}"
324 echo "TYPOSQUATTING=${{ steps.safety_check.outputs.typosquatting }}"
325 echo "LICENSE=${{ steps.safety_check.outputs.license }}"
326 } > status.env
327
328 # Step 8: Hand off report fragments and results to the reporting workflow
329 - name: Upload security reports
330 if: always()
331 uses: actions/[email protected]
332 with:
333 name: security-reports
334 path: |
335 sync_report.md
336 audit_report.md
337 deps_report.md
338 manifest_report.md
339 safety_report.md
340 status.env
341 if-no-files-found: ignore
342 retention-days: 1
343