diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..52fa2e3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + cooldown: + default-days: 7 + schedule: + interval: "weekly" + day: "monday" + commit-message: + prefix: "ci" + open-pull-requests-limit: 5 + groups: + all: + applies-to: version-updates + patterns: + - "*" + update-types: + - "major" + - "minor" + - "patch" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..73f06b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + security-scan: + name: Security scan + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # Code Scanning: upload SARIF from OSV (codeql-action/upload-sarif) + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run security scan + uses: ./ + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + zizmor_persona: pedantic + enable_scorecard: 'false' # scorecard.yml handles this with the required permissions + # osv-scanner scans for dependency vulnerabilities + # python audit is off (not a Python project) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..3899c3f --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,67 @@ +name: Release Please + +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + environment: release + permissions: + contents: write # Create releases, tags, and release branches + pull-requests: write # Open and update pin README pull requests + steps: + - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + id: release + with: + release-type: simple + + # Move major version tag (e.g. v1) after a release is cut + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ steps.release.outputs.release_created }} + with: + persist-credentials: false + - name: Tag major version + if: ${{ steps.release.outputs.release_created }} + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + RELEASE_MAJOR: ${{ steps.release.outputs.major }} + RELEASE_TAG_NAME: ${{ steps.release.outputs.tag_name }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git tag -fa "v${RELEASE_MAJOR}" \ + -m "Release v${RELEASE_TAG_NAME}" + git push origin "v${RELEASE_MAJOR}" --force + + - name: Pin README to release SHA + if: ${{ steps.release.outputs.release_created }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release.outputs.sha }} + RELEASE_TAG_NAME: ${{ steps.release.outputs.tag_name }} + run: | + sed -i -E \ + "s|developmentseed/security-action@[^ ]+( # v[0-9][^ ]*)?|developmentseed/security-action@${RELEASE_SHA} # ${RELEASE_TAG_NAME}|g" \ + README.md + git add README.md + git diff --cached --quiet && echo "README unchanged, skipping commit" && exit 0 + BRANCH="chore/pin-readme-${RELEASE_TAG_NAME}" + git checkout -b "$BRANCH" + git commit -m "chore: pin README to ${RELEASE_TAG_NAME}" + git push origin "$BRANCH" + gh pr create \ + --title "chore: pin README to ${RELEASE_TAG_NAME}" \ + --body "Automated: pin README SHA references to release ${RELEASE_TAG_NAME}." \ + --base main \ + --head "$BRANCH" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..e8be46d --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,36 @@ +name: Scorecard analysis workflow + +on: + push: + branches: + - main + schedule: + # Weekly on Saturdays. + - cron: "30 1 * * 6" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + actions: read # Required by Scorecard to evaluate workflow security posture + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write # Upload Scorecard SARIF to Code Scanning + id-token: write # GitHub OIDC token for publish_results + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: ./ + with: + enable_scorecard: 'true' + enable_zizmor: 'false' + enable_osv: 'false' diff --git a/README.md b/README.md index 3f5622f..f371bb2 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Development Seed composite GitHub Action that gives any repo default security sc | [zizmor](https://github.com/zizmorcore/zizmor) | ON | GitHub Actions workflow security | | [osv-scanner](https://github.com/google/osv-scanner) | ON | Dependency vulnerabilities | | [bandit + pip-audit](https://github.com/developmentseed/action-python-security-auditing) | OFF | Python-specific security (opt-in) | +| [OSSF Scorecard](https://github.com/ossf/scorecard) | OFF | Repository security posture (opt-in, dedicated workflow recommended) | ## Quick start @@ -40,6 +41,10 @@ That's it. zizmor and osv-scanner run automatically. Results appear in the repos | `contents: read` | Required by all scanners to read the repository | | `security-events: write` | Upload SARIF results to Code Scanning | | `pull-requests: write` | Only needed when `python_audit_comment_on` is not `never` | +| `actions: read` | Required by Scorecard to evaluate workflow security posture | +| `id-token: write` | Scorecard OIDC token for publishing results to OSSF dashboard | + +> The last two permissions are only needed when `enable_scorecard: 'true'`. ## All inputs @@ -50,6 +55,7 @@ That's it. zizmor and osv-scanner run automatically. Results appear in the repos | `enable_zizmor` | `'true'` | Run zizmor workflow linter (opt-out with `'false'`) | | `enable_osv` | `'true'` | Run osv-scanner (opt-out with `'false'`) | | `enable_python_audit` | `'false'` | Run bandit + pip-audit (opt-in with `'true'`) | +| `enable_scorecard` | `'false'` | Run OSSF Scorecard (opt-in with `'true'`) | ### zizmor @@ -84,6 +90,13 @@ That's it. zizmor and osv-scanner run automatically. Results appear in the repos | `python_audit_working_directory` | `'.'` | Working directory | | `python_audit_debug` | `'false'` | Enable debug logging | +### Scorecard + +| Input | Default | Description | +|-------|---------|-------------| +| `enable_scorecard` | `'false'` | Run OSSF Scorecard analysis (opt-in with `'true'`) | +| `scorecard_publish_results` | `'true'` | Publish results to OSSF dashboard (default-branch only) | + ### Shared | Input | Default | Description | @@ -121,6 +134,50 @@ That's it. zizmor and osv-scanner run automatically. Results appear in the repos python_audit_pip_audit_block_on: 'all' ``` +### OSSF Scorecard + +Scorecard requires `id-token: write` and `actions: read` permissions that are broader than what a standard CI job should hold. The recommended pattern is to run it in its **own dedicated workflow** triggered on push to `main` and on a weekly schedule, rather than adding those permissions to your main CI job. + +```yaml +# .github/workflows/scorecard.yml +name: Scorecard + +on: + push: + branches: [main] + schedule: + - cron: "30 1 * * 6" # weekly on Saturdays + +permissions: + contents: read + actions: read # required by Scorecard + +jobs: + scorecard: + runs-on: ubuntu-latest + permissions: + security-events: write # upload SARIF to Code Scanning + id-token: write # required by Scorecard + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: developmentseed/security-action@v1 + with: + enable_scorecard: 'true' + enable_zizmor: 'false' # already runs in CI + enable_osv: 'false' # already runs in CI +``` + +Then disable Scorecard in your main CI workflow to avoid the permission requirement there: + +```yaml +# .github/workflows/ci.yml +- uses: developmentseed/security-action@v1 + with: + enable_scorecard: 'false' +``` + ### Custom osv-scanner scope ```yaml @@ -134,4 +191,4 @@ That's it. zizmor and osv-scanner run automatically. Results appear in the repos ## How it works -The composite action routes your configuration to three independent sub-actions — [zizmor-action](https://github.com/zizmorcore/zizmor-action), [osv-scanner-action](https://github.com/google/osv-scanner-action), and [action-python-security-auditing](https://github.com/developmentseed/action-python-security-auditing) — each running with `continue-on-error: true` so all scanners complete regardless of individual failures. A final aggregation step checks each enabled scanner's outcome and fails the job if any enabled scanner found issues (subject to each scanner's own fail flag). +The composite action routes your configuration to four independent sub-actions — [zizmor-action](https://github.com/zizmorcore/zizmor-action), [osv-scanner-action](https://github.com/google/osv-scanner-action), [action-python-security-auditing](https://github.com/developmentseed/action-python-security-auditing), and optionally [scorecard-action](https://github.com/ossf/scorecard-action) — each running with `continue-on-error: true` so all scanners complete regardless of individual failures. A final aggregation step checks each enabled scanner's outcome and fails the job if any enabled scanner found issues (subject to each scanner's own fail flag). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..433cb26 --- /dev/null +++ b/action.yml @@ -0,0 +1,240 @@ +name: Security Scan +description: > + Org-wide composite security scanning: zizmor (workflow linter), + osv-scanner (dependency vulnerabilities), optional Python audit + (bandit + pip-audit), and optional OSSF Scorecard. Adopt with a single `uses:` line. + +branding: + icon: shield + color: blue + +inputs: + # ── Toggles ──────────────────────────────────────────────────────────────── + enable_zizmor: + description: Run zizmor GitHub Actions workflow linter (opt-out with 'false') + default: 'true' + enable_osv: + description: Run osv-scanner dependency vulnerability scanner (opt-out with 'false') + default: 'true' + enable_python_audit: + description: Run bandit + pip-audit Python security audit (opt-in with 'true') + default: 'false' + + # ── zizmor ───────────────────────────────────────────────────────────────── + zizmor_persona: + description: Auditing persona — regular / pedantic / auditor + default: 'regular' + zizmor_min_severity: + description: Minimum severity to report — low / medium / high; empty = all + default: '' + zizmor_min_confidence: + description: Minimum confidence to report — low / medium / high; empty = all + default: '' + zizmor_online_audits: + description: Enable zizmor online audit checks + default: 'true' + zizmor_config: + description: Path to custom zizmor configuration file; empty = none + default: '' + + # ── osv-scanner ──────────────────────────────────────────────────────────── + osv_scan_args: + description: osv-scanner CLI args, one per line + default: |- + --recursive + --allow-no-lockfiles + ./ + osv_fail_on_vuln: + description: Fail the job when osv-scanner finds vulnerabilities + default: 'true' + osv_upload_sarif: + description: Upload osv-scanner results to GitHub Code Scanning + default: 'true' + osv_results_file_name: + description: SARIF output filename for osv-scanner results + default: 'results.sarif' + + # ── python audit ─────────────────────────────────────────────────────────── + python_audit_tools: + description: Comma-separated tools to run — bandit, pip-audit + default: 'bandit,pip-audit' + python_audit_bandit_scan_dirs: + description: Comma-separated directories for bandit + default: '.' + python_audit_bandit_severity_threshold: + description: Minimum bandit severity that blocks — high / medium / low + default: 'high' + python_audit_pip_audit_block_on: + description: When pip-audit blocks — fixable / all / none + default: 'fixable' + python_audit_package_manager: + description: Dependency resolution for pip-audit — uv / pip / poetry / pipenv / requirements + default: 'requirements' + python_audit_requirements_file: + description: Requirements file path (when package_manager=requirements) + default: 'requirements.txt' + python_audit_comment_on: + description: When to post a PR comment — never / blocking / always + default: 'never' + python_audit_working_directory: + description: Working directory for python audit + default: '.' + python_audit_debug: + description: Enable debug logging for python audit + default: 'false' + + # ── scorecard ────────────────────────────────────────────────────────────── + enable_scorecard: + description: > + Run OSSF Scorecard analysis (opt-in with 'true'). + Requires id-token:write and actions:read permissions on the calling job. + default: 'false' + scorecard_publish_results: + description: > + Publish Scorecard results to the OSSF dashboard. + Only effective on default-branch pushes; ignored on PRs. + default: 'true' + + # ── Shared ───────────────────────────────────────────────────────────────── + github_token: + description: GitHub token for SARIF upload and PR comments + default: ${{ github.token }} + +runs: + using: composite + steps: + # ── zizmor ────────────────────────────────────────────────────────────── + - name: Run zizmor + id: zizmor + if: ${{ inputs.enable_zizmor != 'false' }} + continue-on-error: true + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + with: + persona: ${{ inputs.zizmor_persona }} + min-severity: ${{ inputs.zizmor_min_severity }} + min-confidence: ${{ inputs.zizmor_min_confidence }} + online-audits: ${{ inputs.zizmor_online_audits }} + config: ${{ inputs.zizmor_config }} + token: ${{ inputs.github_token }} + + # ── osv-scanner ────────────────────────────────────────────────────────── + - name: Build osv-scanner args + id: osv-args + if: ${{ inputs.enable_osv != 'false' }} + shell: bash + env: + OSV_SCAN_ARGS: ${{ inputs.osv_scan_args }} + OSV_UPLOAD_SARIF: ${{ inputs.osv_upload_sarif }} + OSV_RESULTS_FILE: ${{ inputs.osv_results_file_name }} + run: | + if [[ "$OSV_UPLOAD_SARIF" == 'true' ]]; then + OSV_SCAN_ARGS="${OSV_SCAN_ARGS}"$'\n--format=sarif'$'\n'"--output=${OSV_RESULTS_FILE}" + fi + { + echo 'args<<__OSV_EOF__' + echo "$OSV_SCAN_ARGS" + echo '__OSV_EOF__' + } >> "$GITHUB_OUTPUT" + + - name: Run osv-scanner + id: osv + if: ${{ inputs.enable_osv != 'false' }} + continue-on-error: true + uses: google/osv-scanner-action/osv-scanner-action@c51854704019a247608d928f370c98740469d4b5 # v2.3.5 + with: + scan-args: ${{ steps.osv-args.outputs.args }} + + - name: Check osv-scanner SARIF output + id: osv-sarif + if: ${{ always() && inputs.enable_osv != 'false' && inputs.osv_upload_sarif == 'true' }} + shell: bash + env: + SARIF_FILE: ${{ inputs.osv_results_file_name }} + run: | + if [[ -f "$SARIF_FILE" ]]; then + echo 'upload=true' >> "$GITHUB_OUTPUT" + else + echo 'upload=false' >> "$GITHUB_OUTPUT" + fi + + - name: Upload osv-scanner SARIF to Code Scanning + if: ${{ always() && inputs.enable_osv != 'false' && inputs.osv_upload_sarif == 'true' && steps.osv-sarif.outputs.upload == 'true' }} + uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3.35.1 + with: + sarif_file: ${{ inputs.osv_results_file_name }} + category: osv-scanner + + # ── python audit ───────────────────────────────────────────────────────── + - name: Run python security audit + id: python-audit + if: ${{ inputs.enable_python_audit == 'true' }} + continue-on-error: true + uses: developmentseed/action-python-security-auditing@8ebea22ea75dfba2244ed9883c2aa6cb4df8d9a9 # v0.6.0 + with: + tools: ${{ inputs.python_audit_tools }} + bandit_scan_dirs: ${{ inputs.python_audit_bandit_scan_dirs }} + bandit_severity_threshold: ${{ inputs.python_audit_bandit_severity_threshold }} + pip_audit_block_on: ${{ inputs.python_audit_pip_audit_block_on }} + package_manager: ${{ inputs.python_audit_package_manager }} + requirements_file: ${{ inputs.python_audit_requirements_file }} + comment_on: ${{ inputs.python_audit_comment_on }} + github_token: ${{ inputs.github_token }} + working_directory: ${{ inputs.python_audit_working_directory }} + debug: ${{ inputs.python_audit_debug }} + + # ── scorecard ──────────────────────────────────────────────────────────── + - name: Run Scorecard analysis + id: scorecard + if: ${{ inputs.enable_scorecard == 'true' }} + continue-on-error: true + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: scorecard.sarif + results_format: sarif + publish_results: ${{ inputs.scorecard_publish_results }} + + - name: Upload Scorecard SARIF to Code Scanning + if: ${{ always() && inputs.enable_scorecard == 'true' }} + uses: github/codeql-action/upload-sarif@5c8a8a642e79153f5d047b10ec1cba1d1cc65699 # v3.35.1 + with: + sarif_file: scorecard.sarif + category: scorecard + + # ── Aggregate results ───────────────────────────────────────────────────── + - name: Aggregate results + if: always() + shell: bash + env: + ENABLE_ZIZMOR: ${{ inputs.enable_zizmor }} + ZIZMOR_OUTCOME: ${{ steps.zizmor.outcome }} + ENABLE_OSV: ${{ inputs.enable_osv }} + OSV_FAIL_ON_VULN: ${{ inputs.osv_fail_on_vuln }} + OSV_OUTCOME: ${{ steps.osv.outcome }} + ENABLE_PYTHON_AUDIT: ${{ inputs.enable_python_audit }} + PYTHON_AUDIT_OUTCOME: ${{ steps.python-audit.outcome }} + ENABLE_SCORECARD: ${{ inputs.enable_scorecard }} + SCORECARD_OUTCOME: ${{ steps.scorecard.outcome }} + run: | + failed=0 + + if [[ "$ENABLE_ZIZMOR" != 'false' && "$ZIZMOR_OUTCOME" == 'failure' ]]; then + echo "::error::zizmor found workflow security issues" + failed=1 + fi + + if [[ "$ENABLE_OSV" != 'false' && "$OSV_FAIL_ON_VULN" == 'true' && "$OSV_OUTCOME" == 'failure' ]]; then + echo "::error::osv-scanner found vulnerabilities" + failed=1 + fi + + if [[ "$ENABLE_PYTHON_AUDIT" == 'true' && "$PYTHON_AUDIT_OUTCOME" == 'failure' ]]; then + echo "::error::python security audit found blocking issues" + failed=1 + fi + + if [[ "$ENABLE_SCORECARD" == 'true' && "$SCORECARD_OUTCOME" == 'failure' ]]; then + echo "::error::Scorecard analysis failed" + failed=1 + fi + + exit $failed