Skip to content

Commit c77e064

Browse files
WilliamBerryiiiBill Berry
andauthored
build(workflows): add pip-audit CI dependency scanning (#1052)
## Description Added Python dependency vulnerability scanning through *pip-audit* at both CI and local execution levels. A new **reusable GitHub Actions workflow** audits Python projects via `uvx pip-audit@2.10.0`, with change-detection optimization that skips audits when no dependency files changed. The workflow integrates into PR validation through a matrix job gated on discovered Python projects. The existing **dependency-review workflow** gained a license allowlist of 13 OSI-approved SPDX identifiers and OSSF Scorecard display with a warning threshold. A **local PowerShell wrapper** (*Invoke-PipAudit.ps1*) mirrors the CI scanning behavior and exposes an `audit:pip` npm script for developer use. All new functionality includes **11 Pester tests** covering discovery, audit execution, and orchestration paths. ### CI Pipeline > The reusable workflow bridges the gap between *uv*-managed lock files and *pip-audit*'s requirements.txt input using `uv export`. - Added *pip-audit.yml* reusable workflow with `working-directory`, `soft-fail`, and `changed-files-only` inputs - Implemented dependency-file change detection via `git diff` scoped to *pyproject.toml*, *uv.lock*, and *requirements\*.txt* - Pinned *pip-audit* to version 2.10.0, all actions SHA-pinned with version comments - *actions/checkout* v4.2.2, *astral-sh/setup-uv* v7.5.0, *actions/setup-python* v6.2.0, *actions/upload-artifact* v4.4.3 - Uploaded JSON audit results as artifacts with 30-day retention - Set `permissions: contents: read` at workflow and job level ### PR Validation Integration - Wired *pip-audit* into *pr-validation.yml* as a matrix job calling the reusable workflow - Gated on `discover-python-projects` output with `fail-fast: false` and `changed-files-only: true` ### Dependency Review Hardening - Added license allowlist to *dependency-review.yml* covering MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD, BlueOak-1.0.0, CC0-1.0, Unlicense, CC-BY-4.0, CC-BY-3.0, PSF-2.0, and Python-2.0 - Enabled OSSF Scorecard display with warning threshold at level 3 ### Local Execution - Added *Invoke-PipAudit.ps1* with `Find-PythonProjects`, `Invoke-PipAuditForProject`, and `Start-PipAudit` functions - Used try/finally for temporary requirements file cleanup in `Invoke-PipAuditForProject` - Followed existing security-script conventions: comment-based help, copyright header, *CIHelpers.psm1* import, dot-source guard - Added `audit:pip` npm script to *package.json* ### Testing - Added 11 Pester tests in *Invoke-PipAudit.Tests.ps1* across three describe blocks - Covered project discovery (5 tests), audit execution (2 tests), and orchestration (4 tests) - Used TestDrive isolation, CI annotation assertions, and dot-source testing pattern ## Related Issue(s) Closes #1020 ## Type of Change Select all that apply: **Code & Documentation:** * [ ] Bug fix (non-breaking change fixing an issue) * [x] New feature (non-breaking change adding functionality) * [ ] Breaking change (fix or feature causing existing functionality to change) * [ ] Documentation update **Infrastructure & Configuration:** * [x] GitHub Actions workflow * [ ] Linting configuration (markdown, PowerShell, etc.) * [x] Security configuration * [ ] DevContainer configuration * [ ] Dependency update **AI Artifacts:** * [ ] Reviewed contribution with `prompt-builder` agent and addressed all feedback * [ ] Copilot instructions (`.github/instructions/*.instructions.md`) * [ ] Copilot prompt (`.github/prompts/*.prompt.md`) * [ ] Copilot agent (`.github/agents/*.agent.md`) * [ ] Copilot skill (`.github/skills/*/SKILL.md`) > Note for AI Artifact Contributors: > > * Agents: Research, indexing/referencing other project (using standard VS Code GitHub Copilot/MCP tools), planning, and general implementation agents likely already exist. Review `.github/agents/` before creating new ones. > * Skills: Must include both bash and PowerShell scripts. See [Skills](../docs/contributing/skills.md). > * Model Versions: Only contributions targeting the **latest Anthropic and OpenAI models** will be accepted. Older model versions (e.g., GPT-3.5, Claude 3) will be rejected. > * See [Agents Not Accepted](../docs/contributing/custom-agents.md#agents-not-accepted) and [Model Version Requirements](../docs/contributing/ai-artifacts-common.md#model-version-requirements). **Other:** * [x] Script/automation (`.ps1`, `.sh`, `.py`) * [ ] Other (please describe): ## Sample Prompts (for AI Artifact Contributions) <!-- Not applicable - no AI artifact contributions in this PR --> ## Testing All validation passed locally: - **Pester tests**: 11/11 passing across `Find-PythonProjects`, `Invoke-PipAuditForProject`, and `Start-PipAudit` describe blocks - **PSScriptAnalyzer**: Clean analysis on *Invoke-PipAudit.ps1* - **YAML validation**: All workflow files validated - **Copyright headers**: All new files include required headers ## Checklist ### Required Checks * [ ] Documentation is updated (if applicable) * [x] Files follow existing naming conventions * [x] Changes are backwards compatible (if applicable) * [x] Tests added for new functionality (if applicable) ### AI Artifact Contributions <!-- Not applicable - no AI artifact contributions in this PR --> * [ ] Used `/prompt-analyze` to review contribution * [ ] Addressed all feedback from `prompt-builder` review * [ ] Verified contribution follows common standards and type-specific requirements ### Required Automated Checks The following validation commands must pass before merging: * [x] Markdown linting: `npm run lint:md` * [x] Spell checking: `npm run spell-check` * [x] Frontmatter validation: `npm run lint:frontmatter` * [x] Skill structure validation: `npm run validate:skills` * [x] Link validation: `npm run lint:md-links` * [x] PowerShell analysis: `npm run lint:ps` --------- Co-authored-by: Bill Berry <wbery@microsoft.com>
1 parent 26a32ea commit c77e064

File tree

6 files changed

+500
-0
lines changed

6 files changed

+500
-0
lines changed

.github/workflows/dependency-review.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,10 @@ jobs:
2727
with:
2828
fail-on-severity: moderate
2929
comment-summary-in-pr: always
30+
license-check: true
31+
allow-licenses: >-
32+
MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC,
33+
0BSD, BlueOak-1.0.0, CC0-1.0, Unlicense,
34+
CC-BY-4.0, CC-BY-3.0, PSF-2.0, Python-2.0
35+
show-openssf-scorecard: true
36+
warn-on-openssf-scorecard-level: 3

.github/workflows/pip-audit.yml

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
name: pip-audit
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
working-directory:
7+
description: 'Directory containing the Python project to audit'
8+
required: true
9+
type: string
10+
soft-fail:
11+
description: 'When true, audit failures do not fail the build'
12+
required: false
13+
type: boolean
14+
default: false
15+
changed-files-only:
16+
description: 'Only run audit if dependency files changed in this directory'
17+
required: false
18+
type: boolean
19+
default: false
20+
21+
permissions:
22+
contents: read
23+
24+
jobs:
25+
pip-audit:
26+
name: Audit Python Dependencies
27+
runs-on: ubuntu-latest
28+
permissions:
29+
contents: read
30+
defaults:
31+
run:
32+
shell: pwsh
33+
34+
steps:
35+
- name: Checkout repository
36+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
37+
with:
38+
persist-credentials: false
39+
fetch-depth: 0
40+
41+
- name: Derive artifact name
42+
id: meta
43+
run: |
44+
$name = '${{ inputs.working-directory }}' -replace '/', '-' -replace '^\.', '' -replace '^-', ''
45+
"artifact-name=pip-audit-$name" >> $env:GITHUB_OUTPUT
46+
47+
- name: Check for dependency file changes
48+
if: inputs.changed-files-only
49+
run: |
50+
$baseRef = if ($env:GITHUB_BASE_REF) { $env:GITHUB_BASE_REF } else { 'main' }
51+
$workingDir = '${{ inputs.working-directory }}'
52+
$changed = git diff --name-only --diff-filter=ACMR "origin/$baseRef...HEAD" -- $workingDir |
53+
Where-Object { $_ -match '(pyproject\.toml|uv\.lock|requirements.*\.txt)$' }
54+
if ($changed) {
55+
"HAS_CHANGES=true" >> $env:GITHUB_ENV
56+
$fileCount = @($changed).Count
57+
Write-Output "Detected $fileCount changed dependency file(s)"
58+
} else {
59+
Write-Output "No dependency file changes detected under $workingDir"
60+
}
61+
62+
- name: Install uv
63+
if: "!inputs.changed-files-only || env.HAS_CHANGES == 'true'"
64+
uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
65+
with:
66+
version: "0.10.9"
67+
68+
- name: Setup Python
69+
if: "!inputs.changed-files-only || env.HAS_CHANGES == 'true'"
70+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
71+
with:
72+
python-version: "3.12"
73+
74+
- name: Export locked dependencies
75+
if: "!inputs.changed-files-only || env.HAS_CHANGES == 'true'"
76+
run: |
77+
uv export --format requirements-txt --no-hashes > "$env:RUNNER_TEMP/requirements.txt"
78+
$lineCount = (Get-Content "$env:RUNNER_TEMP/requirements.txt" | Measure-Object).Count
79+
Write-Output "Exported $lineCount locked dependencies"
80+
working-directory: ${{ inputs.working-directory }}
81+
82+
- name: Run pip-audit
83+
id: audit
84+
if: "!inputs.changed-files-only || env.HAS_CHANGES == 'true'"
85+
run: |
86+
$name = '${{ steps.meta.outputs.artifact-name }}'
87+
New-Item -ItemType Directory -Path "$env:GITHUB_WORKSPACE/logs" -Force | Out-Null
88+
uvx pip-audit@2.10.0 `
89+
-r "$env:RUNNER_TEMP/requirements.txt" `
90+
--no-deps `
91+
--format json `
92+
-o "$env:GITHUB_WORKSPACE/logs/$name.json" `
93+
--desc on `
94+
--aliases on `
95+
--progress-spinner off `
96+
--strict
97+
if ($LASTEXITCODE -ne 0) { "PIP_AUDIT_FAILED=true" >> $env:GITHUB_ENV }
98+
$global:LASTEXITCODE = 0
99+
working-directory: ${{ inputs.working-directory }}
100+
continue-on-error: ${{ inputs.soft-fail }}
101+
102+
- name: Upload audit results
103+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v4.4.3
104+
if: always() && (!inputs.changed-files-only || env.HAS_CHANGES == 'true')
105+
with:
106+
name: ${{ steps.meta.outputs.artifact-name }}
107+
path: logs/${{ steps.meta.outputs.artifact-name }}.json
108+
retention-days: 30
109+
if-no-files-found: ignore
110+
111+
- name: Check results
112+
if: "!inputs.soft-fail && (!inputs.changed-files-only || env.HAS_CHANGES == 'true')"
113+
run: |
114+
if ($env:PIP_AUDIT_FAILED -eq 'true') {
115+
Write-Output '::error::pip-audit found vulnerabilities in Python dependencies'
116+
exit 1
117+
}
118+
Write-Output 'pip-audit: no vulnerabilities found'

.github/workflows/pr-validation.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,22 @@ jobs:
145145
matrix:
146146
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
147147

148+
pip-audit:
149+
name: "pip-audit (${{ matrix.directory }})"
150+
needs: discover-python-projects
151+
if: needs.discover-python-projects.outputs.has-projects == 'true'
152+
strategy:
153+
fail-fast: false
154+
matrix:
155+
directory: ${{ fromJson(needs.discover-python-projects.outputs.directories) }}
156+
uses: ./.github/workflows/pip-audit.yml
157+
permissions:
158+
contents: read
159+
with:
160+
working-directory: ${{ matrix.directory }}
161+
soft-fail: false
162+
changed-files-only: true
163+
148164
docusaurus-tests:
149165
name: Docusaurus Tests
150166
uses: ./.github/workflows/docusaurus-tests.yml

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"lint:version-consistency": "pwsh -NoProfile -Command \"./scripts/security/Test-ActionVersionConsistency.ps1 -FailOnMismatch -Format Json -OutputPath logs/action-version-consistency-results.json\"",
2020
"lint:permissions": "pwsh -NoProfile -Command \"& './scripts/security/Test-WorkflowPermissions.ps1' -FailOnViolation\"",
2121
"lint:dependency-pinning": "pwsh -NoProfile -Command \"& './scripts/security/Test-DependencyPinning.ps1' -FailOnUnpinned\"",
22+
"audit:pip": "pwsh -NoProfile -File scripts/security/Invoke-PipAudit.ps1",
2223
"lint:all": "npm run format:tables && npm run lint:md && npm run lint:ps && npm run lint:yaml && npm run lint:links && npm run lint:frontmatter && npm run lint:collections-metadata && npm run lint:marketplace && npm run lint:version-consistency && npm run lint:permissions && npm run lint:dependency-pinning && npm run lint:py && npm run validate:skills",
2324
"format:tables": "markdown-table-formatter \"**/*.md\"",
2425
"extension:prepare": "pwsh ./scripts/extension/Prepare-Extension.ps1",
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
#!/usr/bin/env pwsh
2+
# Copyright (c) Microsoft Corporation.
3+
# SPDX-License-Identifier: MIT
4+
#Requires -Version 7.0
5+
6+
<#
7+
.SYNOPSIS
8+
Runs pip-audit against Python project dependencies for vulnerability scanning.
9+
10+
.DESCRIPTION
11+
Discovers Python projects containing pyproject.toml files, exports locked dependencies
12+
via uv export, and runs pip-audit to check for known vulnerabilities. Results are written
13+
as JSON to the logs directory.
14+
15+
.PARAMETER Path
16+
Root path to scan for Python projects. Defaults to repository root.
17+
18+
.PARAMETER OutputPath
19+
Directory for JSON results. Defaults to 'logs' under repository root.
20+
21+
.PARAMETER FailOnVulnerability
22+
Exit with error code if vulnerabilities are found. Default is false.
23+
24+
.PARAMETER ExcludePaths
25+
Comma-separated list of path patterns to exclude from scanning.
26+
27+
.EXAMPLE
28+
./Invoke-PipAudit.ps1
29+
Scan all Python projects and report results.
30+
31+
.EXAMPLE
32+
./Invoke-PipAudit.ps1 -FailOnVulnerability
33+
Scan and fail if any vulnerabilities are found.
34+
35+
.EXAMPLE
36+
./Invoke-PipAudit.ps1 -Path ".github/skills/experimental/powerpoint"
37+
Scan a specific Python project directory.
38+
#>
39+
40+
[CmdletBinding()]
41+
param(
42+
[Parameter()]
43+
[string]$Path = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)),
44+
45+
[Parameter()]
46+
[string]$OutputPath = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'logs'),
47+
48+
[Parameter()]
49+
[switch]$FailOnVulnerability,
50+
51+
[Parameter()]
52+
[string[]]$ExcludePaths = @()
53+
)
54+
55+
$ErrorActionPreference = 'Stop'
56+
57+
Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
58+
59+
function Find-PythonProjects {
60+
<#
61+
.SYNOPSIS
62+
Discovers Python projects containing pyproject.toml files.
63+
#>
64+
[CmdletBinding()]
65+
param(
66+
[Parameter(Mandatory)]
67+
[string]$SearchPath,
68+
69+
[Parameter()]
70+
[string[]]$Exclude = @()
71+
)
72+
73+
@(Get-ChildItem -Path $SearchPath -Recurse -Force -Filter pyproject.toml |
74+
Where-Object { $_.FullName -notmatch 'node_modules' } |
75+
ForEach-Object { $_.DirectoryName } |
76+
Where-Object {
77+
$dir = $_
78+
$excluded = $false
79+
foreach ($pattern in $Exclude) {
80+
if ($dir -like "*$pattern*") { $excluded = $true; break }
81+
}
82+
-not $excluded
83+
} |
84+
Sort-Object)
85+
}
86+
87+
function Invoke-PipAuditForProject {
88+
<#
89+
.SYNOPSIS
90+
Runs pip-audit against a single Python project directory.
91+
#>
92+
[CmdletBinding()]
93+
param(
94+
[Parameter(Mandatory)]
95+
[string]$ProjectPath,
96+
97+
[Parameter(Mandatory)]
98+
[string]$OutputPath
99+
)
100+
101+
$name = (Resolve-Path -Relative $ProjectPath) -replace '[\\/]', '-' -replace '^\.', '' -replace '^-', ''
102+
$requirementsFile = Join-Path ([System.IO.Path]::GetTempPath()) "requirements-$name.txt"
103+
$resultsFile = Join-Path $OutputPath "pip-audit-$name.json"
104+
105+
Write-Host "Auditing: $ProjectPath"
106+
107+
# Export locked dependencies
108+
Push-Location $ProjectPath
109+
try {
110+
uv export --format requirements-txt --no-hashes > $requirementsFile
111+
} finally {
112+
Pop-Location
113+
}
114+
115+
# Run pip-audit; finally block ensures temp file cleanup on terminating errors
116+
try {
117+
uvx pip-audit@2.10.0 `
118+
-r $requirementsFile `
119+
--no-deps `
120+
--format json `
121+
-o $resultsFile `
122+
--desc on `
123+
--aliases on `
124+
--progress-spinner off `
125+
--strict
126+
127+
$exitCode = $LASTEXITCODE
128+
} finally {
129+
if (Test-Path $requirementsFile) { Remove-Item $requirementsFile }
130+
}
131+
132+
if ($exitCode -ne 0) {
133+
Write-Host "::warning::Vulnerabilities found in $ProjectPath"
134+
return $true # has vulnerabilities
135+
}
136+
137+
Write-Host "No vulnerabilities found in $ProjectPath"
138+
return $false
139+
}
140+
141+
function Start-PipAudit {
142+
<#
143+
.SYNOPSIS
144+
Orchestrates pip-audit scanning across discovered Python projects.
145+
.OUTPUTS
146+
System.Int32 - 0 for success, 1 when vulnerabilities found and FailOnVulnerability is set.
147+
#>
148+
[CmdletBinding()]
149+
param(
150+
[Parameter(Mandatory)]
151+
[string]$SearchPath,
152+
153+
[Parameter(Mandatory)]
154+
[string]$OutputPath,
155+
156+
[Parameter()]
157+
[switch]$FailOnVulnerability,
158+
159+
[Parameter()]
160+
[string[]]$ExcludePaths = @()
161+
)
162+
163+
$projects = @(Find-PythonProjects -SearchPath $SearchPath -Exclude $ExcludePaths)
164+
165+
if ($projects.Count -eq 0) {
166+
Write-Host 'No Python projects found'
167+
return 0
168+
}
169+
170+
Write-Host "Found $($projects.Count) Python project(s)"
171+
172+
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
173+
174+
$hasVulnerabilities = $false
175+
176+
foreach ($project in $projects) {
177+
if (Invoke-PipAuditForProject -ProjectPath $project -OutputPath $OutputPath) {
178+
$hasVulnerabilities = $true
179+
}
180+
}
181+
182+
Write-Host "Results written to $OutputPath"
183+
184+
if ($hasVulnerabilities -and $FailOnVulnerability) {
185+
Write-Host '::error::pip-audit found vulnerabilities in one or more Python projects'
186+
return 1
187+
}
188+
189+
return 0
190+
}
191+
192+
# Dot-source guard: skip main execution when dot-sourced for testing
193+
if ($MyInvocation.InvocationName -ne '.') {
194+
$result = Start-PipAudit -SearchPath $Path -OutputPath $OutputPath -FailOnVulnerability:$FailOnVulnerability -ExcludePaths $ExcludePaths
195+
if ($result -ne 0) { exit $result }
196+
}

0 commit comments

Comments
 (0)