/
/
/
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 STATUS=$(python3 scripts/audit_changed_packages.py audit.json new_deps.txt base_closure.txt head_closure.txt) || STATUS=scan_failed
168 echo "status=$STATUS" >> $GITHUB_OUTPUT
169
170 echo "" >> audit_report.md
171 case "$STATUS" in
172 fail)
173 echo "â ï¸ **Vulnerabilities detected in the dependencies this PR introduces!**" >> audit_report.md
174 ;;
175 preexisting)
176 echo "â¹ï¸ The findings above are not ones this PR introduces." >> audit_report.md
177 ;;
178 scan_failed)
179 echo "â The vulnerability scan could not be completed." >> audit_report.md
180 ;;
181 *)
182 echo "â
No known vulnerabilities found" >> audit_report.md
183 ;;
184 esac
185
186 if [ "$STATUS" != "pass" ] && [ ! -s base_closure.txt ]; then
187 echo "" >> audit_report.md
188 echo "â ï¸ The target branch could not be resolved, so only the dependencies this PR changes directly were compared." >> audit_report.md
189 fi
190
191 cat audit_report.md
192
193 # Step 5: Check manifest.json changes
194 - name: Check provider manifest changes
195 id: manifest_check
196 run: |
197 BASE_BRANCH="${{ github.base_ref }}"
198
199 # Find all changed manifest.json files
200 CHANGED_MANIFESTS=$(git diff --name-only origin/$BASE_BRANCH...HEAD | grep "manifest.json" || true)
201
202 if [ -n "$CHANGED_MANIFESTS" ]; then
203 echo "## ð Provider Manifest Changes" > manifest_report.md
204 echo "" >> manifest_report.md
205
206 HAS_REQ_CHANGES=false
207
208 for manifest in $CHANGED_MANIFESTS; do
209 # Check if requirements actually changed
210 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 "")
211 NEW_REQS=$(cat $manifest | python3 -c "import sys, json; data=json.load(sys.stdin); print(' '.join(data.get('requirements', [])))" 2>/dev/null || echo "")
212
213 if [ "$OLD_REQS" != "$NEW_REQS" ]; then
214 HAS_REQ_CHANGES=true
215 echo "### \`$manifest\`" >> manifest_report.md
216 echo "" >> manifest_report.md
217
218 # Save old and new versions for comparison
219 git show origin/$BASE_BRANCH:$manifest > /tmp/old_manifest.json 2>/dev/null || echo '{"requirements":[]}' > /tmp/old_manifest.json
220 cp $manifest /tmp/new_manifest.json
221
222 # Use Python script to parse dependency changes
223 python3 scripts/parse_manifest_deps.py /tmp/old_manifest.json /tmp/new_manifest.json >> manifest_report.md
224 echo "" >> manifest_report.md
225 fi
226 done
227
228 if [ "$HAS_REQ_CHANGES" = "true" ]; then
229 echo "has_changes=true" >> $GITHUB_OUTPUT
230 else
231 echo "has_changes=false" >> $GITHUB_OUTPUT
232 echo "Manifest files changed but no dependency changes detected" > manifest_report.md
233 fi
234 else
235 echo "has_changes=false" >> $GITHUB_OUTPUT
236 echo "No provider manifest changes detected" > manifest_report.md
237 fi
238
239 cat manifest_report.md
240
241 # Step 6: Run package safety check on new dependencies
242 - name: Check new package safety
243 id: safety_check
244 if: steps.deps_check.outputs.has_changes == 'true'
245 continue-on-error: true
246 run: |
247 echo "## ð¡ï¸ Supply Chain Security Check" > safety_report.md
248 echo "" >> safety_report.md
249
250 if [ -f new_deps.txt ] && [ -s new_deps.txt ]; then
251 # Run our custom safety check script
252 python scripts/check_package_safety.py new_deps.txt > safety_output.txt 2>&1
253 SAFETY_EXIT=$?
254
255 cat safety_output.txt >> safety_report.md
256 echo "" >> safety_report.md
257
258 # Parse automated check results
259 if grep -q "â
.*Trusted Sources.*All packages" safety_output.txt; then
260 echo "trusted_sources=pass" >> $GITHUB_OUTPUT
261 else
262 echo "trusted_sources=fail" >> $GITHUB_OUTPUT
263 fi
264
265 if grep -q "â
.*Typosquatting.*No suspicious" safety_output.txt; then
266 echo "typosquatting=pass" >> $GITHUB_OUTPUT
267 else
268 echo "typosquatting=fail" >> $GITHUB_OUTPUT
269 fi
270
271 if grep -q "â
.*License.*All licenses" safety_output.txt; then
272 echo "license=pass" >> $GITHUB_OUTPUT
273 else
274 echo "license=fail" >> $GITHUB_OUTPUT
275 fi
276
277 if [ $SAFETY_EXIT -eq 2 ]; then
278 echo "status=high_risk" >> $GITHUB_OUTPUT
279 echo "" >> safety_report.md
280 echo "â ï¸ **HIGH RISK PACKAGES DETECTED**" >> safety_report.md
281 echo "Manual security review is **required** before merging this PR." >> safety_report.md
282 elif [ $SAFETY_EXIT -eq 1 ]; then
283 echo "status=medium_risk" >> $GITHUB_OUTPUT
284 echo "" >> safety_report.md
285 echo "â ï¸ **MEDIUM RISK PACKAGES DETECTED**" >> safety_report.md
286 echo "Please review the warnings above before merging." >> safety_report.md
287 else
288 echo "status=pass" >> $GITHUB_OUTPUT
289 fi
290 else
291 echo "No new dependencies to check" >> safety_report.md
292 echo "status=pass" >> $GITHUB_OUTPUT
293 echo "trusted_sources=pass" >> $GITHUB_OUTPUT
294 echo "typosquatting=pass" >> $GITHUB_OUTPUT
295 echo "license=pass" >> $GITHUB_OUTPUT
296 fi
297
298 cat safety_report.md
299
300 # Step 7: Persist analysis results for the reporting workflow.
301 # The report runs via workflow_run and cannot see this job's outputs, so
302 # the result strings are passed via the artifact. This data is UNTRUSTED
303 # (produced by fork code); the reporting workflow validates it and derives
304 # all PR identity/labels from the GitHub API, never from this artifact.
305 - name: Persist analysis results
306 if: always()
307 run: |
308 {
309 echo "REQ_SYNC=${{ steps.req_sync.outputs.status }}"
310 echo "PIP_AUDIT=${{ steps.pip_audit.outputs.status }}"
311 echo "DEPS_CHANGES=${{ steps.deps_check.outputs.has_changes }}"
312 echo "MANIFEST_CHANGES=${{ steps.manifest_check.outputs.has_changes }}"
313 echo "SAFETY_STATUS=${{ steps.safety_check.outputs.status }}"
314 echo "TRUSTED_SOURCES=${{ steps.safety_check.outputs.trusted_sources }}"
315 echo "TYPOSQUATTING=${{ steps.safety_check.outputs.typosquatting }}"
316 echo "LICENSE=${{ steps.safety_check.outputs.license }}"
317 } > status.env
318
319 # Step 8: Hand off report fragments and results to the reporting workflow
320 - name: Upload security reports
321 if: always()
322 uses: actions/[email protected]
323 with:
324 name: security-reports
325 path: |
326 sync_report.md
327 audit_report.md
328 deps_report.md
329 manifest_report.md
330 safety_report.md
331 status.env
332 if-no-files-found: ignore
333 retention-days: 1
334