|
1 | 1 | name: Reusable Bandit Security Check with Regression Detection |
2 | 2 |
|
3 | | -# This reusable workflow is triggered by other workflows using 'workflow_call' |
4 | 3 | on: |
5 | 4 | workflow_call: |
6 | 5 | inputs: |
7 | | - target_branch_to_compare: |
8 | | - description: "Target branch to compare against (e.g., main)" |
| 6 | + ref: |
| 7 | + description: "Git ref to checkout and test. Leave empty for default checkout." |
| 8 | + required: false |
| 9 | + type: string |
| 10 | + default: "" |
| 11 | + target_branch: |
| 12 | + description: "Target branch to compare against for regression detection (e.g., main)" |
9 | 13 | required: true |
10 | 14 | type: string |
| 15 | + python-version: |
| 16 | + description: "Python version to use for Bandit." |
| 17 | + required: false |
| 18 | + type: string |
| 19 | + default: "3.10" |
11 | 20 | runs_on: |
| 21 | + description: "Runner label for the jobs." |
12 | 22 | required: false |
13 | 23 | type: string |
14 | 24 | default: '["ubuntu-latest"]' |
| 25 | + artifact_name: |
| 26 | + description: "Base name for the security scan artifacts." |
| 27 | + required: false |
| 28 | + type: string |
| 29 | + default: "bandit-results" |
| 30 | + severity_level: |
| 31 | + description: "Minimum severity level to report (-l, -ll, or -lll). Default -lll (high only)." |
| 32 | + required: false |
| 33 | + type: string |
| 34 | + default: "-lll" |
15 | 35 | outputs: |
| 36 | + pr_issues_count: |
| 37 | + description: "Number of issues found on PR branch" |
| 38 | + value: ${{ jobs.run-bandit-pr.outputs.issues_count }} |
| 39 | + target_issues_count: |
| 40 | + description: "Number of issues found on target branch" |
| 41 | + value: ${{ jobs.run-bandit-target.outputs.issues_count }} |
| 42 | + new_issues_count: |
| 43 | + description: "Number of new issues introduced in PR" |
| 44 | + value: ${{ jobs.compare-results.outputs.new_issues_count }} |
| 45 | + resolved_issues_count: |
| 46 | + description: "Number of issues resolved in PR" |
| 47 | + value: ${{ jobs.compare-results.outputs.resolved_issues_count }} |
| 48 | + has_regressions: |
| 49 | + description: "Whether new security issues were introduced" |
| 50 | + value: ${{ jobs.compare-results.outputs.has_regressions }} |
16 | 51 | bandit_issues_json: |
17 | 52 | description: "JSON output of Bandit issues on PR branch" |
18 | | - value: ${{ jobs.run-bandit.outputs.bandit_issues_json }} |
| 53 | + value: ${{ jobs.run-bandit-pr.outputs.bandit_issues_json }} |
19 | 54 |
|
20 | 55 | jobs: |
21 | 56 | # Job 1: Run Bandit on the PR branch |
22 | | - run-bandit: |
23 | | - name: Run Bandit on PR Branch & Extract Results |
| 57 | + run-bandit-pr: |
| 58 | + name: Run Bandit on PR Branch |
24 | 59 | runs-on: ${{ fromJSON(inputs.runs_on) }} |
25 | 60 | outputs: |
26 | | - bandit_issues_json: ${{ steps.extract-pr.outputs.BANDIT_JSON }} |
| 61 | + bandit_issues_json: ${{ steps.extract-results.outputs.bandit_json }} |
| 62 | + issues_count: ${{ steps.extract-results.outputs.issues_count }} |
27 | 63 | steps: |
28 | | - # Step 1: Checkout the current pull request code |
29 | 64 | - name: Checkout PR Branch |
30 | | - uses: actions/checkout@v4.1.1 |
| 65 | + uses: actions/checkout@v4.2.2 |
31 | 66 | with: |
| 67 | + submodules: "recursive" |
| 68 | + ref: ${{ inputs.ref || github.ref }} |
32 | 69 | persist-credentials: false |
33 | 70 |
|
34 | | - # Step 2: Set up Python 3.10 environment |
35 | 71 | - name: Set up Python |
36 | | - uses: actions/setup-python@v5 |
| 72 | + uses: actions/setup-python@v5.3.0 |
37 | 73 | with: |
38 | | - python-version: "3.10" |
| 74 | + python-version: "${{ inputs.python-version }}" |
39 | 75 |
|
40 | | - # Step 3: Install Bandit (Python security scanner) |
41 | 76 | - name: Install Bandit |
42 | | - run: pip install bandit |
| 77 | + run: | |
| 78 | + python -m pip install --upgrade pip |
| 79 | + pip install bandit |
| 80 | +
|
| 81 | + - name: Run Bandit Security Scan |
| 82 | + run: | |
| 83 | + bandit -r . ${{ inputs.severity_level }} -f json -o bandit_output.json || true |
43 | 84 |
|
44 | | - # Step 4: Run Bandit and output results to a file |
45 | | - - name: Run Bandit on PR Branch |
| 85 | + - name: Extract Results |
| 86 | + id: extract-results |
46 | 87 | run: | |
47 | | - bandit -r . -lll -f json -o pr_bandit_output.json || true |
| 88 | + if [ -f bandit_output.json ]; then |
| 89 | + ISSUES_JSON=$(cat bandit_output.json | jq -c '.results') |
| 90 | + ISSUES_COUNT=$(cat bandit_output.json | jq '.results | length') |
| 91 | + else |
| 92 | + ISSUES_JSON="[]" |
| 93 | + ISSUES_COUNT=0 |
| 94 | + fi |
| 95 | + echo "bandit_json=$ISSUES_JSON" >> "$GITHUB_OUTPUT" |
| 96 | + echo "issues_count=$ISSUES_COUNT" >> "$GITHUB_OUTPUT" |
| 97 | + echo "Found $ISSUES_COUNT security issues on PR branch" |
48 | 98 |
|
49 | | - # Step 5: Upload the results as a GitHub Actions artifact (for debugging or reporting) |
50 | | - - name: Upload PR Artifact |
| 99 | + - name: Upload PR Branch Artifacts |
51 | 100 | uses: actions/upload-artifact@v4 |
52 | 101 | with: |
53 | | - name: pr_bandit_output |
54 | | - path: pr_bandit_output.json |
55 | | - |
56 | | - # Step 6: Extract the raw issue list from the Bandit JSON output |
57 | | - - name: Extract PR Bandit JSON |
58 | | - id: extract-pr |
59 | | - run: | |
60 | | - CONTENT=$(cat pr_bandit_output.json | jq -c '.results') |
61 | | - echo "BANDIT_JSON=$CONTENT" >> $GITHUB_OUTPUT |
| 102 | + name: ${{ inputs.artifact_name }}-pr |
| 103 | + path: bandit_output.json |
| 104 | + retention-days: 3 |
| 105 | + if-no-files-found: ignore |
62 | 106 |
|
63 | 107 | # Job 2: Run Bandit on the target branch for comparison |
64 | | - run-bandit-on-target: |
| 108 | + run-bandit-target: |
65 | 109 | name: Run Bandit on Target Branch |
66 | 110 | runs-on: ${{ fromJSON(inputs.runs_on) }} |
67 | 111 | outputs: |
68 | | - bandit_target_json: ${{ steps.extract-target.outputs.TARGET_JSON }} |
| 112 | + bandit_issues_json: ${{ steps.extract-results.outputs.bandit_json }} |
| 113 | + issues_count: ${{ steps.extract-results.outputs.issues_count }} |
69 | 114 | steps: |
70 | | - # Step 1: Checkout the base branch (e.g., main) |
71 | 115 | - name: Checkout Target Branch |
72 | | - uses: actions/checkout@v4 |
| 116 | + uses: actions/checkout@v4.2.2 |
73 | 117 | with: |
74 | | - ref: ${{ inputs.target_branch_to_compare }} |
| 118 | + submodules: "recursive" |
| 119 | + ref: ${{ inputs.target_branch }} |
75 | 120 | persist-credentials: false |
76 | 121 |
|
77 | | - # Step 2: Set up Python environment |
78 | 122 | - name: Set up Python |
79 | | - uses: actions/setup-python@v5 |
| 123 | + uses: actions/setup-python@v5.3.0 |
80 | 124 | with: |
81 | | - python-version: "3.10" |
| 125 | + python-version: "${{ inputs.python-version }}" |
82 | 126 |
|
83 | | - # Step 3: Install Bandit |
84 | 127 | - name: Install Bandit |
85 | | - run: pip install bandit |
86 | | - |
87 | | - # Step 4: Run Bandit and save output |
88 | | - - name: Run Bandit on Target Branch |
89 | 128 | run: | |
90 | | - bandit -r . -lll -f json -o target_bandit_output.json || true |
| 129 | + python -m pip install --upgrade pip |
| 130 | + pip install bandit |
91 | 131 |
|
92 | | - # Step 5: Upload results from the target branch |
93 | | - - name: Upload Target Artifact |
94 | | - uses: actions/upload-artifact@v4 |
95 | | - with: |
96 | | - name: target_bandit_output |
97 | | - path: target_bandit_output.json |
| 132 | + - name: Run Bandit Security Scan |
| 133 | + run: | |
| 134 | + bandit -r . ${{ inputs.severity_level }} -f json -o bandit_output.json || true |
98 | 135 |
|
99 | | - # Step 6: Extract raw issue list from the Bandit output |
100 | | - - name: Extract Target Bandit JSON |
101 | | - id: extract-target |
| 136 | + - name: Extract Results |
| 137 | + id: extract-results |
102 | 138 | run: | |
103 | | - CONTENT=$(cat target_bandit_output.json | jq -c '.results') |
104 | | - echo "TARGET_JSON=$CONTENT" >> $GITHUB_OUTPUT |
| 139 | + if [ -f bandit_output.json ]; then |
| 140 | + ISSUES_JSON=$(cat bandit_output.json | jq -c '.results') |
| 141 | + ISSUES_COUNT=$(cat bandit_output.json | jq '.results | length') |
| 142 | + else |
| 143 | + ISSUES_JSON="[]" |
| 144 | + ISSUES_COUNT=0 |
| 145 | + fi |
| 146 | + echo "bandit_json=$ISSUES_JSON" >> "$GITHUB_OUTPUT" |
| 147 | + echo "issues_count=$ISSUES_COUNT" >> "$GITHUB_OUTPUT" |
| 148 | + echo "Found $ISSUES_COUNT security issues on target branch" |
105 | 149 |
|
106 | | - # Job 3: Compare the PR results against the target to detect regressions |
107 | | - compare-bandit: |
108 | | - name: Compare Bandit Issues (Regression Analysis) |
| 150 | + - name: Upload Target Branch Artifacts |
| 151 | + uses: actions/upload-artifact@v4 |
| 152 | + with: |
| 153 | + name: ${{ inputs.artifact_name }}-target |
| 154 | + path: bandit_output.json |
| 155 | + retention-days: 3 |
| 156 | + if-no-files-found: ignore |
| 157 | + |
| 158 | + # Job 3: Compare results and detect regressions |
| 159 | + compare-results: |
| 160 | + name: Compare Results (Regression Detection) |
109 | 161 | runs-on: ${{ fromJSON(inputs.runs_on) }} |
110 | | - needs: [run-bandit, run-bandit-on-target] |
| 162 | + needs: [run-bandit-pr, run-bandit-target] |
| 163 | + outputs: |
| 164 | + new_issues_count: ${{ steps.compare.outputs.new_issues_count }} |
| 165 | + resolved_issues_count: ${{ steps.compare.outputs.resolved_issues_count }} |
| 166 | + has_regressions: ${{ steps.compare.outputs.has_regressions }} |
111 | 167 | steps: |
112 | | - - name: Compare JSON |
| 168 | + - name: Compare Bandit Results |
| 169 | + id: compare |
113 | 170 | run: | |
114 | 171 | echo "Comparing Bandit results between PR and target branch..." |
115 | 172 |
|
116 | | - echo "${{ needs.run-bandit.outputs.bandit_issues_json }}" > pr.json |
117 | | - echo "${{ needs.run-bandit-on-target.outputs.bandit_target_json }}" > target.json |
| 173 | + # Write issues to files for comparison |
| 174 | + echo '${{ needs.run-bandit-pr.outputs.bandit_issues_json }}' > pr_issues.json |
| 175 | + echo '${{ needs.run-bandit-target.outputs.bandit_issues_json }}' > target_issues.json |
118 | 176 |
|
119 | | - # Compare both JSON lists to find issues present in PR but not in target |
120 | | - NEW_ISSUES=$(jq -n --argfile pr pr.json --argfile base target.json ' |
121 | | - $pr - $base | length') |
| 177 | + # Calculate new issues (in PR but not in target) |
| 178 | + NEW_ISSUES_COUNT=$(jq -n --argfile pr pr_issues.json --argfile base target_issues.json ' |
| 179 | + ($pr - $base) | length') |
122 | 180 |
|
123 | | - echo "New security issues introduced: $NEW_ISSUES" |
| 181 | + # Calculate resolved issues (in target but not in PR) |
| 182 | + RESOLVED_ISSUES_COUNT=$(jq -n --argfile pr pr_issues.json --argfile base target_issues.json ' |
| 183 | + ($base - $pr) | length') |
| 184 | +
|
| 185 | + echo "new_issues_count=$NEW_ISSUES_COUNT" >> "$GITHUB_OUTPUT" |
| 186 | + echo "resolved_issues_count=$RESOLVED_ISSUES_COUNT" >> "$GITHUB_OUTPUT" |
| 187 | +
|
| 188 | + echo "PR Issues: ${{ needs.run-bandit-pr.outputs.issues_count }}" |
| 189 | + echo "Target Issues: ${{ needs.run-bandit-target.outputs.issues_count }}" |
| 190 | + echo "New Issues Introduced: $NEW_ISSUES_COUNT" |
| 191 | + echo "Issues Resolved: $RESOLVED_ISSUES_COUNT" |
| 192 | +
|
| 193 | + if [ "$NEW_ISSUES_COUNT" -gt 0 ]; then |
| 194 | + echo "has_regressions=true" >> "$GITHUB_OUTPUT" |
| 195 | + echo "::error::$NEW_ISSUES_COUNT new security issue(s) introduced in this PR" |
| 196 | +
|
| 197 | + # Show details of new issues |
| 198 | + echo "New issues details:" |
| 199 | + jq -n --argfile pr pr_issues.json --argfile base target_issues.json ' |
| 200 | + $pr - $base' | jq -r '.[] | " - \(.test_id): \(.issue_text) (\(.filename):\(.line_number))"' |
124 | 201 |
|
125 | | - if [ "$NEW_ISSUES" -gt 0 ]; then |
126 | | - echo "::error::New Bandit issues introduced in PR branch." |
127 | 202 | exit 1 |
128 | 203 | else |
| 204 | + echo "has_regressions=false" >> "$GITHUB_OUTPUT" |
129 | 205 | echo "No new security issues introduced." |
| 206 | + if [ "$RESOLVED_ISSUES_COUNT" -gt 0 ]; then |
| 207 | + echo "::notice::$RESOLVED_ISSUES_COUNT security issue(s) were resolved in this PR" |
| 208 | + fi |
130 | 209 | fi |
| 210 | +
|
| 211 | + - name: Upload Comparison Artifacts |
| 212 | + if: always() |
| 213 | + uses: actions/upload-artifact@v4 |
| 214 | + with: |
| 215 | + name: ${{ inputs.artifact_name }}-comparison |
| 216 | + path: | |
| 217 | + pr_issues.json |
| 218 | + target_issues.json |
| 219 | + retention-days: 3 |
| 220 | + if-no-files-found: ignore |
0 commit comments