4.0.0 #42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Protects Mac Health Check from common vulnerabilities with Semgrep, Gitleaks, zsh syntax validation, and ShellCheck. Semgrep scans the full repository, including Mac-Health-Check.zsh, external-checks/, and Resources/, and uploads findings to GitHub code scanning. Gitleaks scans the full git history for potential secrets. Tracked zsh files are validated with zsh -n, and tracked sh/bash helpers, including external-checks/ and Resources/, are linted with ShellCheck. ShellCheck treats SC1090/SC1091 as informational due to common dynamic sourcing patterns in shell projects. | |
| name: Security Scan | |
| on: | |
| push: | |
| branches: | |
| - main | |
| - development | |
| pull_request: | |
| branches: | |
| - main | |
| - development | |
| schedule: | |
| - cron: '43 2 * * 3' | |
| workflow_dispatch: | |
| permissions: read-all | |
| concurrency: | |
| group: security-scan-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| semgrep: | |
| name: Semgrep | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| security-events: write | |
| # Uncomment for private/internal repos if GitHub code scanning upload requires it. | |
| # actions: read | |
| env: | |
| SEMGREP_RULES: >- | |
| --config p/r2c-security-audit | |
| --config p/ci | |
| --config p/secrets | |
| steps: | |
| - name: Check Out Repository | |
| # Fetch full history so diff-aware scans and code scanning uploads have the right context. | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install Semgrep CLI | |
| # Use the CLI directly so the blocking scan and SARIF export come from the same execution path. | |
| run: python3 -m pip install --upgrade semgrep | |
| - name: Run Semgrep CLI (Blocking + SARIF) | |
| id: semgrep_sarif | |
| # Always attempt SARIF generation so findings can land in the Security tab even when blocking scan fails. | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| set -o pipefail | |
| baseline_args=() | |
| if [[ "${GITHUB_EVENT_NAME}" == "pull_request" && -n "${{ github.event.pull_request.base.sha }}" ]]; then | |
| baseline_args+=(--baseline-commit "${{ github.event.pull_request.base.sha }}") | |
| fi | |
| semgrep scan \ | |
| --error \ | |
| --sarif \ | |
| --output semgrep.sarif \ | |
| ${SEMGREP_RULES} \ | |
| "${baseline_args[@]}" | |
| - name: Normalize SARIF For GitHub | |
| id: normalize_sarif | |
| if: hashFiles('semgrep.sarif') != '' | |
| shell: bash | |
| run: | | |
| python3 - <<'PY' | |
| import json | |
| from pathlib import Path | |
| sarif_path = Path("semgrep.sarif") | |
| data = json.loads(sarif_path.read_text()) | |
| severity_map = { | |
| "critical": "9.0", | |
| "high": "8.9", | |
| "medium": "6.9", | |
| "low": "3.9", | |
| "info": "0.1", | |
| "warning": "5.0", | |
| "error": "8.0", | |
| } | |
| for run in data.get("runs", []): | |
| for rule in run.get("tool", {}).get("driver", {}).get("rules", []): | |
| properties = rule.setdefault("properties", {}) | |
| value = properties.get("security-severity") | |
| if isinstance(value, str) and not value.replace(".", "", 1).isdigit(): | |
| properties["security-severity"] = severity_map.get(value.lower(), "5.0") | |
| for result in run.get("results", []): | |
| properties = result.setdefault("properties", {}) | |
| value = properties.get("security-severity") | |
| if isinstance(value, str) and not value.replace(".", "", 1).isdigit(): | |
| properties["security-severity"] = severity_map.get(value.lower(), "5.0") | |
| sarif_path.write_text(json.dumps(data)) | |
| PY | |
| - name: Upload Semgrep Findings To Code Scanning | |
| id: upload_sarif | |
| if: hashFiles('semgrep.sarif') != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) | |
| # Repo Actions permissions must allow SARIF uploads or this step will fail even with security-events: write. | |
| uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 | |
| with: | |
| sarif_file: semgrep.sarif | |
| category: semgrep | |
| - name: Finalize Semgrep Result | |
| if: success() || failure() | |
| shell: bash | |
| run: | | |
| sarif_outcome="${{ steps.semgrep_sarif.outcome }}" | |
| upload_outcome="${{ steps.upload_sarif.outcome }}" | |
| trusted_upload_context="${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}" | |
| if [[ "${sarif_outcome}" == "success" ]]; then | |
| if [[ "${trusted_upload_context}" == "true" && "${upload_outcome}" != "success" ]]; then | |
| { | |
| echo "### Semgrep" | |
| echo | |
| echo "❌ Semgrep scan passed, but SARIF upload needs attention." | |
| echo | |
| echo "Coverage: full repository scan, including Mac-Health-Check.zsh, external-checks/, and Resources/." | |
| echo "SARIF upload did not complete in a trusted context. Check repository Actions permissions and upload step logs." | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| exit 1 | |
| fi | |
| { | |
| echo "### Semgrep" | |
| echo | |
| echo "✅ No issues found" | |
| echo | |
| echo "Coverage: full repository scan, including Mac-Health-Check.zsh, external-checks/, and Resources/." | |
| if [[ "${trusted_upload_context}" == "true" ]]; then | |
| echo "SARIF uploaded to GitHub code scanning successfully." | |
| else | |
| echo "SARIF upload skipped for fork pull requests because code scanning write permissions are not available." | |
| fi | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| exit 0 | |
| fi | |
| { | |
| echo "### Semgrep" | |
| echo | |
| echo "❌ Findings detected or Semgrep execution needs attention." | |
| echo | |
| echo "Coverage: full repository scan, including Mac-Health-Check.zsh, external-checks/, and Resources/." | |
| if [[ "${upload_outcome}" == "success" ]]; then | |
| echo "Findings were uploaded to GitHub code scanning." | |
| elif [[ "${trusted_upload_context}" != "true" ]]; then | |
| echo "SARIF upload was skipped for a fork pull request." | |
| else | |
| echo "SARIF upload did not complete. Check repository Actions permissions and Semgrep output." | |
| fi | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| exit 1 | |
| gitleaks: | |
| name: Gitleaks | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Check Out Repository | |
| # Fetch full history so Gitleaks can scan past commits on protected branches. | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 | |
| with: | |
| fetch-depth: 0 | |
| - name: Determine Gitleaks Comment Mode | |
| id: gitleaks_mode | |
| shell: bash | |
| run: | | |
| if [[ "${GITHUB_EVENT_NAME}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name || '' }}" != "${GITHUB_REPOSITORY}" ]]; then | |
| echo "enable_comments=false" >> "${GITHUB_OUTPUT}" | |
| else | |
| echo "enable_comments=true" >> "${GITHUB_OUTPUT}" | |
| fi | |
| - name: Run Gitleaks (Blocking) | |
| id: gitleaks_scan | |
| continue-on-error: true | |
| uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| GITLEAKS_ENABLE_COMMENTS: ${{ steps.gitleaks_mode.outputs.enable_comments }} | |
| GITLEAKS_ENABLE_SUMMARY: "true" | |
| # Future tuning examples: | |
| # GITLEAKS_CONFIG: .gitleaks.toml | |
| # Use `.gitleaksignore` or allowlists for accepted false positives. | |
| # Org-owned repos also need GITLEAKS_LICENSE in repository or organization secrets. | |
| - name: Finalize Gitleaks Result | |
| if: success() || failure() | |
| shell: bash | |
| run: | | |
| scan_outcome="${{ steps.gitleaks_scan.outcome }}" | |
| if [[ "${scan_outcome}" == "success" ]]; then | |
| { | |
| echo "### Gitleaks" | |
| echo | |
| echo "✅ No leaks found" | |
| echo | |
| echo "Coverage: full git history for repository content, including external-checks/ and Resources/." | |
| if [[ "${{ steps.gitleaks_mode.outputs.enable_comments }}" == "false" ]]; then | |
| echo "PR comments were disabled for this fork pull request." | |
| fi | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| exit 0 | |
| fi | |
| { | |
| echo "### Gitleaks" | |
| echo | |
| echo "❌ Potential secrets detected or Gitleaks execution failed." | |
| echo | |
| echo "Coverage: full git history for repository content, including external-checks/ and Resources/." | |
| if [[ "${{ steps.gitleaks_mode.outputs.enable_comments }}" == "false" ]]; then | |
| echo "PR comments were disabled for this fork pull request." | |
| fi | |
| echo "Tune accepted false positives with \`.gitleaks.toml\` or \`.gitleaksignore\` if needed." | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| exit 1 | |
| zsh_syntax: | |
| name: Zsh Syntax | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check Out Repository | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install Zsh | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install --yes zsh | |
| - name: Collect Zsh Targets | |
| id: collect_targets | |
| shell: bash | |
| run: | | |
| python3 - <<'PY' | |
| import re | |
| import subprocess | |
| from pathlib import Path | |
| shebang_pattern = re.compile(r"^#!.*(?:/| )zsh(?:\s|$)") | |
| output_path = Path("zsh-targets.txt") | |
| targets = [] | |
| files = subprocess.check_output( | |
| ["git", "ls-files", "*.zsh", "*.sh", "*.bash"], | |
| text=True, | |
| ).splitlines() | |
| for file_name in files: | |
| file_path = Path(file_name) | |
| first_line = file_path.open(encoding="utf-8", errors="ignore").readline().strip() | |
| if file_name.endswith(".zsh") or shebang_pattern.search(first_line): | |
| targets.append(file_name) | |
| output_path.write_text("".join(f"{target}\n" for target in targets)) | |
| PY | |
| count="$(grep -c '.' zsh-targets.txt || true)" | |
| echo "count=${count}" >> "${GITHUB_OUTPUT}" | |
| - name: Run zsh -n | |
| id: zsh_syntax | |
| if: steps.collect_targets.outputs.count != '0' | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| set +e | |
| status=0 | |
| while IFS= read -r file; do | |
| [[ -z "${file}" ]] && continue | |
| if ! zsh -n "${file}"; then | |
| status=1 | |
| fi | |
| done < zsh-targets.txt | |
| set -e | |
| echo "status=${status}" >> "${GITHUB_OUTPUT}" | |
| - name: Finalize Zsh Syntax Result | |
| if: success() || failure() | |
| shell: bash | |
| run: | | |
| target_count="${{ steps.collect_targets.outputs.count }}" | |
| syntax_status="${{ steps.zsh_syntax.outputs.status }}" | |
| if [[ "${target_count}" == "0" ]]; then | |
| { | |
| echo "### Zsh Syntax" | |
| echo | |
| echo "✅ No tracked zsh files matched the scan patterns." | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| exit 0 | |
| fi | |
| { | |
| echo "### Zsh Syntax" | |
| echo | |
| echo "- Files scanned: ${target_count}" | |
| echo "- Coverage: tracked zsh files and zsh-shebang helpers, including Mac-Health-Check.zsh plus zsh-based content in external-checks/ and Resources/." | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| if [[ "${syntax_status}" == "0" ]]; then | |
| echo "" >> "${GITHUB_STEP_SUMMARY}" | |
| echo "✅ All zsh files passed \`zsh -n\`" >> "${GITHUB_STEP_SUMMARY}" | |
| exit 0 | |
| fi | |
| echo "" >> "${GITHUB_STEP_SUMMARY}" | |
| echo "❌ zsh syntax errors found" >> "${GITHUB_STEP_SUMMARY}" | |
| exit 1 | |
| shellcheck: | |
| name: ShellCheck | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check Out Repository | |
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 | |
| with: | |
| fetch-depth: 0 | |
| - name: Collect Shell Targets | |
| id: collect_targets | |
| shell: bash | |
| run: | | |
| python3 - <<'PY' | |
| import re | |
| import subprocess | |
| from pathlib import Path | |
| shebang_pattern = re.compile(r"^#!.*(?:/| )(?:bash|dash|ksh|sh)(?:\s|$)") | |
| output_path = Path("shellcheck-targets.txt") | |
| targets = [] | |
| files = subprocess.check_output( | |
| ["git", "ls-files", "*.sh", "*.bash"], | |
| text=True, | |
| ).splitlines() | |
| for file_name in files: | |
| file_path = Path(file_name) | |
| first_line = file_path.open(encoding="utf-8", errors="ignore").readline().strip() | |
| if shebang_pattern.search(first_line): | |
| targets.append(file_name) | |
| output_path.write_text("".join(f"{target}\n" for target in targets)) | |
| PY | |
| count="$(grep -c '.' shellcheck-targets.txt || true)" | |
| echo "count=${count}" >> "${GITHUB_OUTPUT}" | |
| - name: Install ShellCheck | |
| if: steps.collect_targets.outputs.count != '0' | |
| # Native ShellCheck keeps permissions minimal while still producing strong annotations. | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install --yes shellcheck | |
| - name: Run Blocking ShellCheck Pass | |
| id: shellcheck_blocking | |
| if: steps.collect_targets.outputs.count != '0' | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| mapfile -t shell_files < shellcheck-targets.txt | |
| set +e | |
| shellcheck --severity=style --format=gcc -e SC1090,SC1091 "${shell_files[@]}" > shellcheck-blocking.gcc | |
| status=$? | |
| set -e | |
| python3 - <<'PY' | |
| from pathlib import Path | |
| import re | |
| def escape_workflow_command(value): | |
| return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") | |
| pattern = re.compile(r"^(.*?):(\d+):(\d+):\s+([a-z]+):\s+(.*)$") | |
| for line in Path("shellcheck-blocking.gcc").read_text().splitlines(): | |
| match = pattern.match(line) | |
| if not match: | |
| print(line) | |
| continue | |
| file_path, line_no, col_no, level, message = match.groups() | |
| annotation = "error" if level == "error" else "warning" | |
| print(f"::{annotation} file={file_path},line={line_no},col={col_no}::{escape_workflow_command(message)}") | |
| PY | |
| issue_count="$(grep -c '.' shellcheck-blocking.gcc || true)" | |
| echo "status=${status}" >> "${GITHUB_OUTPUT}" | |
| echo "issue_count=${issue_count}" >> "${GITHUB_OUTPUT}" | |
| - name: Run Informational ShellCheck Pass | |
| id: shellcheck_info | |
| if: steps.collect_targets.outputs.count != '0' | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| mapfile -t shell_files < shellcheck-targets.txt | |
| set +e | |
| shellcheck --severity=style --format=gcc -i SC1090,SC1091 "${shell_files[@]}" > shellcheck-info.gcc | |
| status=$? | |
| set -e | |
| python3 - <<'PY' | |
| from pathlib import Path | |
| import re | |
| def escape_workflow_command(value): | |
| return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") | |
| pattern = re.compile(r"^(.*?):(\d+):(\d+):\s+([a-z]+):\s+(.*)$") | |
| for line in Path("shellcheck-info.gcc").read_text().splitlines(): | |
| match = pattern.match(line) | |
| if not match: | |
| print(line) | |
| continue | |
| file_path, line_no, col_no, _, message = match.groups() | |
| print(f"::notice file={file_path},line={line_no},col={col_no}::{escape_workflow_command(message)}") | |
| PY | |
| issue_count="$(grep -c '.' shellcheck-info.gcc || true)" | |
| echo "status=${status}" >> "${GITHUB_OUTPUT}" | |
| echo "issue_count=${issue_count}" >> "${GITHUB_OUTPUT}" | |
| - name: Finalize ShellCheck Result | |
| if: success() || failure() | |
| shell: bash | |
| run: | | |
| target_count="${{ steps.collect_targets.outputs.count }}" | |
| blocking_status="${{ steps.shellcheck_blocking.outputs.status }}" | |
| blocking_issues="${{ steps.shellcheck_blocking.outputs.issue_count }}" | |
| info_issues="${{ steps.shellcheck_info.outputs.issue_count }}" | |
| if [[ "${target_count}" == "0" ]]; then | |
| { | |
| echo "### ShellCheck" | |
| echo | |
| echo "✅ No tracked sh/bash files matched the scan patterns." | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| exit 0 | |
| fi | |
| { | |
| echo "### ShellCheck" | |
| echo | |
| echo "- Files scanned: ${target_count}" | |
| echo "- Blocking findings: ${blocking_issues:-0}" | |
| echo "- Informational findings (SC1090/SC1091): ${info_issues:-0}" | |
| echo "- Coverage: tracked sh/bash-shebang helpers, including bash/sh content in external-checks/ and Resources/." | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| if [[ "${blocking_status}" == "0" ]]; then | |
| echo "" >> "${GITHUB_STEP_SUMMARY}" | |
| echo "✅ No blocking ShellCheck issues found" >> "${GITHUB_STEP_SUMMARY}" | |
| exit 0 | |
| fi | |
| echo "" >> "${GITHUB_STEP_SUMMARY}" | |
| echo "❌ Blocking ShellCheck issues found" >> "${GITHUB_STEP_SUMMARY}" | |
| exit 1 | |
| summary: | |
| name: Security Scan Summary | |
| runs-on: ubuntu-latest | |
| if: always() | |
| needs: | |
| - semgrep | |
| - gitleaks | |
| - zsh_syntax | |
| - shellcheck | |
| steps: | |
| - name: Security Scan Complete | |
| if: success() || failure() | |
| shell: bash | |
| run: | | |
| semgrep_result="${{ needs.semgrep.result }}" | |
| gitleaks_result="${{ needs.gitleaks.result }}" | |
| zsh_syntax_result="${{ needs.zsh_syntax.result }}" | |
| shellcheck_result="${{ needs.shellcheck.result }}" | |
| badge() { | |
| case "$1" in | |
| success) printf "✅" ;; | |
| skipped) printf "⏭️" ;; | |
| cancelled) printf "🚫" ;; | |
| *) printf "❌" ;; | |
| esac | |
| } | |
| semgrep_badge="$(badge "${semgrep_result}")" | |
| gitleaks_badge="$(badge "${gitleaks_result}")" | |
| zsh_syntax_badge="$(badge "${zsh_syntax_result}")" | |
| shellcheck_badge="$(badge "${shellcheck_result}")" | |
| { | |
| echo "## Security Scan Summary" | |
| echo | |
| echo "Semgrep ${semgrep_badge} | Gitleaks ${gitleaks_badge} | Zsh Syntax ${zsh_syntax_badge} | ShellCheck ${shellcheck_badge}" | |
| echo | |
| echo "Coverage: Mac-Health-Check.zsh, tracked zsh and zsh-shebang helpers in external-checks/ and Resources/, tracked sh/bash-shebang helpers in external-checks/ and Resources/, plus full-repository Semgrep and Gitleaks scans." | |
| echo | |
| if [[ "${semgrep_result}" == "success" && "${gitleaks_result}" == "success" && "${zsh_syntax_result}" == "success" && "${shellcheck_result}" == "success" ]]; then | |
| echo "Mac Health Check security scan passed." | |
| else | |
| echo "Mac Health Check security scan requires attention." | |
| fi | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| echo "Semgrep ${semgrep_badge} | Gitleaks ${gitleaks_badge} | Zsh Syntax ${zsh_syntax_badge} | ShellCheck ${shellcheck_badge}" | |
| echo "Coverage: Mac-Health-Check.zsh, tracked zsh and zsh-shebang helpers in external-checks/ and Resources/, tracked sh/bash-shebang helpers in external-checks/ and Resources/, plus full-repository Semgrep and Gitleaks scans." | |
| if [[ "${semgrep_result}" == "success" && "${gitleaks_result}" == "success" && "${zsh_syntax_result}" == "success" && "${shellcheck_result}" == "success" ]]; then | |
| echo "Mac Health Check security scan passed." | |
| exit 0 | |
| fi | |
| echo "Mac Health Check security scan requires attention." | |
| exit 1 |