From ee1e71e07d0176e5df599c483d8ddeb6796b4103 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 14:09:29 +0000 Subject: [PATCH 1/5] docs(standards): propose push protection standard Add a dedicated standard defining a defense-in-depth approach for preventing secrets, keys, and sensitive values from being pushed to any petry-projects repo. Covers GitHub native secret scanning + push protection (primary enforcement), local gitleaks pre-commit hooks, complementary CI secret-scan job, agent hygiene, incident response, and compliance audit checks. Cross-reference the new standard from AGENTS.md and add a "Security & Analysis" block to github-settings.md documenting the required repo-level security_and_analysis settings. --- AGENTS.md | 1 + standards/github-settings.md | 16 ++ standards/push-protection.md | 395 +++++++++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 standards/push-protection.md diff --git a/AGENTS.md b/AGENTS.md index 59a29cd..ea6a1ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Read the relevant standard *before* making changes that touch CI, repo settings, | **Agent configuration** | [`standards/agent-standards.md`](https://github.com/petry-projects/.github/blob/main/standards/agent-standards.md) | CLAUDE.md / AGENTS.md / SKILL.md required structure, frontmatter rules, cross-references | | **Repo settings + labels** | [`standards/github-settings.md`](https://github.com/petry-projects/.github/blob/main/standards/github-settings.md) | Required settings, label set with exact colors, code-quality ruleset, branch protection | | **Dependabot config** | [`standards/dependabot-policy.md`](https://github.com/petry-projects/.github/blob/main/standards/dependabot-policy.md) and [`standards/dependabot/`](https://github.com/petry-projects/.github/tree/main/standards/dependabot) | Per-ecosystem dependabot.yml templates and policy | +| **Push protection** | [`standards/push-protection.md`](https://github.com/petry-projects/.github/blob/main/standards/push-protection.md) | Secret scanning + push protection, local gitleaks hooks, CI secret-scan job, incident response | **When fixing a compliance finding, the rule is: read the standard, then copy the template — do not generate from scratch.** Anything generated from scratch diff --git a/standards/github-settings.md b/standards/github-settings.md index 8b42b35..f7d0586 100644 --- a/standards/github-settings.md +++ b/standards/github-settings.md @@ -33,6 +33,22 @@ SHOULD be audited and brought into compliance. | **Has Wiki** | `false` | Disabled — documentation lives in the repo | | **Has Discussions** | `true` | **Required** — enables Discussions for ideation, feedback, and community engagement (see [Discussions Configuration](#discussions-configuration)) | +### Security & Analysis + +All repositories MUST have the following security features enabled. These are +the enforcement primitives behind the [Push Protection Standard](push-protection.md). + +| Setting | Standard Value | Rationale | +|---------|---------------|-----------| +| **Secret scanning** | `enabled` | Detect leaked credentials in history and new commits | +| **Secret scanning push protection** | `enabled` | Block pushes containing known secret patterns at the server side | +| **Secret scanning AI detection** | `enabled` | Catch generic secrets missed by regex patterns | +| **Secret scanning non-provider patterns** | `enabled` | Private keys, HTTP basic auth, high-entropy strings | +| **Dependabot security updates** | `enabled` | Automated patches for known-vulnerable dependencies | + +> See the full requirements, custom patterns, CI job, incident response flow, +> and compliance audit checks in [`push-protection.md`](push-protection.md). + ### Merge Settings | Setting | Standard Value | Rationale | diff --git a/standards/push-protection.md b/standards/push-protection.md new file mode 100644 index 0000000..4e77ddf --- /dev/null +++ b/standards/push-protection.md @@ -0,0 +1,395 @@ +# Push Protection Standard + +Standard for preventing secrets, API keys, credentials, and other sensitive +values from being accidentally committed or pushed to any repository in the +**petry-projects** organization. + +This standard is **defense in depth**: local hooks catch most leaks before a +commit lands, GitHub push protection blocks the push at the network boundary, +and CI scanning + secret scanning alerts catch anything that slips past the +first two layers. Any one layer failing is a warning; two layers failing is an +incident. + +--- + +## Scope + +This standard applies to **every repository** in `petry-projects`, regardless +of visibility (public or private) or language. It covers: + +- Source code, configuration files, and fixtures +- Workflow files (`.github/workflows/*.yml`) and reusable workflows +- Agent configuration (`CLAUDE.md`, `AGENTS.md`, `SKILL.md`, MCP configs) +- Documentation, issue/PR templates, and discussion posts +- Binary artifacts, screenshots, log files, and notebooks checked into git + +> **Private repos are in scope.** GitHub secret scanning is now free for +> private repos on the free plan, and a leaked credential in a private repo is +> still a credential that must be rotated. + +--- + +## What Counts as a Secret + +The following values MUST NEVER be committed to any repo, even temporarily, and +even in a branch that will be rebased or force-pushed: + +| Category | Examples | +|----------|----------| +| **Cloud provider credentials** | AWS access key / secret, GCP service account JSON, Azure connection string | +| **API tokens** | GitHub PAT / fine-grained token, SonarCloud token, Anthropic API key, OpenAI API key, Slack webhook / bot token, Stripe key | +| **Database credentials** | Postgres / MySQL / Mongo connection strings containing passwords, Redis AUTH strings | +| **Private keys** | SSH private keys, TLS private keys, GPG private keys, GitHub App private keys | +| **OAuth secrets** | Client secrets, refresh tokens, long-lived access tokens | +| **Signing keys** | Code signing certificates, Sigstore identities, npm publish tokens | +| **Internal URLs & identifiers** | Unpublished webhook URLs, internal hostnames, customer IDs, account IDs when not already public | +| **Personal data** | Real email addresses, phone numbers, or names in fixtures that are not explicitly test data | + +Low-entropy placeholder values (`sk-xxxx`, `AKIA...EXAMPLE`, `changeme`) are +permitted in documentation and tests **only when** they are obviously not real +(e.g., repeated characters, `EXAMPLE` suffix, or documented as placeholder). +When in doubt, use the GitHub-documented [dummy values](https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns). + +--- + +## Layer 1 — GitHub Push Protection (Primary Enforcement) + +GitHub's native [secret scanning push protection](https://docs.github.com/en/code-security/secret-scanning/push-protection-for-repositories-and-organizations) +is the **primary enforcement mechanism**. It blocks pushes that contain +detected secrets at the server side, before the commit is accepted, so nothing +ever lands in the repo history in the first place. + +### Required org-level settings + +Configured once at the organization level and inherited by all repos: + +| Setting | Value | Notes | +|---------|-------|-------| +| **Secret scanning** | **Enabled for all repos** | Public and private | +| **Push protection** | **Enabled for all repos** | Blocks pushes containing known secret patterns | +| **Push protection for contributors** | **Enabled** | Applies to forks and contributions | +| **Validity checks** | **Enabled** | Verifies leaked tokens against the provider so rotation can be prioritized | +| **Non-provider patterns** | **Enabled** | Adds generic patterns (private keys, HTTP basic auth, high-entropy strings) | +| **Custom patterns** | **Enabled (see below)** | Org-specific patterns live under Settings → Code security → Secret scanning | +| **Bypass privileges** | **Admin-only, with justification required** | Bypasses MUST include a reason and are audited | + +Apply these via: + +```bash +# Org-level (requires org admin) +gh api -X PATCH "orgs/petry-projects" \ + -f secret_scanning_enabled_for_new_repositories=true \ + -f secret_scanning_push_protection_enabled_for_new_repositories=true \ + -f secret_scanning_push_protection_custom_link_enabled=true \ + -f secret_scanning_push_protection_custom_link="https://github.com/petry-projects/.github/blob/main/standards/push-protection.md#what-to-do-when-push-protection-blocks-your-push" +``` + +### Required repo-level settings + +Every repository MUST have the following security features turned on. The +compliance audit checks these flags via `GET /repos/{owner}/{repo}`: + +| Setting | Path in API response | Required value | +|---------|----------------------|----------------| +| Secret scanning | `security_and_analysis.secret_scanning.status` | `enabled` | +| Secret scanning push protection | `security_and_analysis.secret_scanning_push_protection.status` | `enabled` | +| Secret scanning AI detection | `security_and_analysis.secret_scanning_ai_detection.status` | `enabled` | +| Secret scanning non-provider patterns | `security_and_analysis.secret_scanning_non_provider_patterns.status` | `enabled` | +| Dependabot security updates | `security_and_analysis.dependabot_security_updates.status` | `enabled` | + +Apply per repo via: + +```bash +gh api -X PATCH "repos/petry-projects/" \ + -F security_and_analysis='{ + "secret_scanning": {"status": "enabled"}, + "secret_scanning_push_protection": {"status": "enabled"}, + "secret_scanning_ai_detection": {"status": "enabled"}, + "secret_scanning_non_provider_patterns": {"status": "enabled"} + }' +``` + +`scripts/apply-repo-settings.sh` MUST enforce these values alongside the +existing merge and label settings — see +[Application](#application-to-a-repository) below. + +### Custom secret scanning patterns + +The org MUST configure the following custom patterns in addition to the +provider-supplied ones: + +| Pattern name | Regex (illustrative) | Rationale | +|--------------|----------------------|-----------| +| `petry-internal-webhook` | `https://hooks\.petry-projects\.internal/[A-Za-z0-9/_-]{20,}` | Internal webhook URLs | +| `claude-oauth-token` | `sk-ant-oat01-[A-Za-z0-9_-]{40,}` | Anthropic OAuth tokens | +| `gha-pat-scoped` | `github_pat_[A-Za-z0-9_]{82}` | Fine-grained GitHub PATs (provider pattern supplements) | +| `generic-high-entropy` | High-entropy strings assigned to `*_TOKEN`, `*_SECRET`, `*_KEY` env vars | Catches untyped long strings in YAML and `.env` files | + +Custom patterns are configured at **Org settings → Code security → Secret +scanning → Custom patterns**. Each new pattern MUST be dry-run against all +repos before being enabled to estimate false-positive rate. + +--- + +## Layer 2 — Local Pre-Commit Prevention + +Local prevention catches leaks before they ever reach GitHub, which is both +faster for the developer and leaves no evidence in any remote history. Every +developer workstation and every Claude Code / agent environment SHOULD run +`gitleaks` (or an equivalent) as a pre-commit hook. + +### Recommended local tooling + +| Tool | Purpose | How it runs | +|------|---------|-------------| +| [`gitleaks`](https://github.com/gitleaks/gitleaks) | Fast, regex + entropy secret scanner | Pre-commit hook + CI | +| [`pre-commit`](https://pre-commit.com/) | Hook orchestrator | Manages the hook lifecycle | +| [`git-secrets`](https://github.com/awslabs/git-secrets) | AWS-focused secret scanner | Optional supplement | + +### Standard `.pre-commit-config.yaml` entry + +Repositories that adopt pre-commit SHOULD add this block: + +```yaml +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.24.0 + hooks: + - id: gitleaks + name: gitleaks (secret scan) + description: Detect hardcoded secrets before commit +``` + +Install locally with: + +```bash +pip install pre-commit +pre-commit install +pre-commit run gitleaks --all-files # one-off scan of existing history +``` + +### Agent workstation requirements + +Claude Code and other AI agents operating on petry-projects repos MUST: + +- Refuse to write real credentials to any file, even when asked. Use + placeholder values (``) in documentation and instruct the + user to source the value from an environment variable or secrets manager. +- Refuse to commit files containing strings that look like secrets, even when + explicitly instructed. Ask the user to confirm and route the value through a + secure store instead. +- When generating `.env.example` files, include key names only — never values. + +--- + +## Layer 3 — CI Secret Scanning (Secondary Defense) + +CI scanning is the last line of defense for code that has already made it into +a branch (e.g., historical commits, imported repositories, or pushes from +accounts with bypass privileges). + +### Required CI job + +Every repository's primary `ci.yml` workflow MUST include a `secret-scan` job +that runs `gitleaks` in full-history mode on every pull request and on every +push to `main`. + +```yaml +secret-scan: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout (full history) + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + with: + args: detect --source . --redact --verbose --exit-code 1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +The job MUST: + +- Use `fetch-depth: 0` so the full git history is scanned, not just the + PR diff +- Pass `--redact` so leaked values are NEVER written to workflow logs +- Fail the build (`--exit-code 1`) when any finding at or above severity + `medium` is detected +- Run as a **required check** via the `code-quality` ruleset + (see [`github-settings.md`](github-settings.md#code-quality--required-checks-ruleset-all-repositories)) + +### Coordination with AgentShield + +For agent-configuration files specifically, [`agent-shield.yml`](workflows/agent-shield.yml) +already runs 10 dedicated secret-detection rules across 14 patterns (see +[`agent-standards.md`](agent-standards.md#layer-1-agentshield-action-deep-security-scan)). +The `secret-scan` CI job is complementary — it covers non-agent files and +runs a broader entropy-based scan over the full history. Both MUST be green +for a PR to merge. + +--- + +## Developer Practices + +### Handling real secrets + +- Real credentials live in the [GitHub Actions secret store](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions), + in the developer's local `.env` files (gitignored), or in a dedicated secrets + manager. They never live in source files, documentation, or chat history. +- The standard org-level secrets (`APP_ID`, `APP_PRIVATE_KEY`, + `CLAUDE_CODE_OAUTH_TOKEN`, `SONAR_TOKEN`) are documented in + [`github-settings.md`](github-settings.md#organization-level-secrets-for-standard-ci). + Reference them via `${{ secrets. }}` in workflows. +- When a workflow needs a new secret, add it to the org-level store (if it is + reusable) or the repo-level store (if it is project-specific). Document the + purpose in the repo's `README.md` or `CONTRIBUTING.md`. + +### Required gitignore entries + +Every repository's `.gitignore` MUST include at minimum: + +```gitignore +# Secrets — never commit +.env +.env.* +!.env.example +*.pem +*.key +*.p12 +*.pfx +secrets/ +credentials.json +service-account*.json +``` + +The compliance audit checks for these entries and flags repositories missing +them as a `warning`. + +### Writing tests and fixtures + +- Use the dummy values listed in the [GitHub secret patterns documentation](https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns) + for test fixtures. These are whitelisted by GitHub push protection. +- Generate ephemeral test keys at runtime where possible (e.g., a freshly + created RSA keypair inside a `beforeAll` hook) rather than committing a + fixed test key. +- If a fixture MUST contain a realistic-looking value, prefix the filename + with `fixture-` and add a `.gitleaksignore` entry documenting the + justification. + +### Working in a branch that may contain a leaked secret + +If you suspect you have committed a secret locally but not yet pushed: + +1. **Stop.** Do not push. +2. Run `git log -p -- ` to find the commit(s) containing the secret. +3. If the secret is only in uncommitted working tree: remove it and + `git restore --staged `. +4. If the secret is in local commits not yet pushed: rewrite history with + `git rebase -i ` and amend the offending commit(s) to remove it. +5. Rotate the credential anyway — assume any value you typed into a terminal + has been logged somewhere. + +If you have already pushed: + +1. **Rotate the credential immediately** — assume it is compromised. +2. Follow the [Incident Response](#incident-response) procedure below. +3. Do NOT attempt to rewrite remote history as a first response — the value + is already in forks, caches, and CI logs. Rotation is faster and safer. + +--- + +## What to Do When Push Protection Blocks Your Push + +When `git push` fails with a push protection error, GitHub returns a URL +pointing to the blocked secret. The correct response is: + +1. **Do not bypass.** Bypassing push protection is an admin-only action, + requires a written justification, and is audited org-wide. +2. **Identify whether the value is a real secret or a false positive.** + - **Real secret:** remove it from the commit (see the rewrite procedure + above), rotate the credential, and force-push the rewritten branch. Open + an incident issue per the [Incident Response](#incident-response) + procedure. + - **False positive:** confirm with the org security owner, then add a + `.gitleaksignore` entry (for CI) and request a push protection bypass + with a `used_in_tests` or `false_positive` reason. +3. **Never** commit a modified version of the secret (e.g., adding a space, + splitting across lines, base64-encoding) to work around detection. This + is treated as the same severity as committing the original value. + +--- + +## Incident Response + +When a secret is confirmed leaked — whether caught by push protection bypass, +CI scanning, a secret scanning alert, or an external report — follow this +procedure: + +| Step | Action | Owner | Target | +|------|--------|-------|--------| +| 1 | **Rotate the credential** in the upstream provider (AWS, GitHub, Anthropic, etc.) | First responder | Immediately | +| 2 | **Revoke any derived tokens** (OAuth grants, downstream integrations) | First responder | Immediately | +| 3 | **Open a private security advisory** in the affected repo | First responder | Within 1 hour | +| 4 | **Audit access logs** for the credential to determine blast radius | Org admin | Within 24 hours | +| 5 | **Remove the secret from history** if appropriate (BFG, `git filter-repo`), recognizing that forks and caches may retain copies | Org admin | Within 24 hours, only after rotation | +| 6 | **Post-mortem** — document root cause, why existing layers did not catch it, and what changes prevent recurrence | Org admin | Within 7 days | +| 7 | **Update this standard** with any new patterns or lessons learned | Standards owner | Within 7 days | + +Rotation ALWAYS comes first. History rewriting is a cleanup step, not a +mitigation. + +--- + +## Application to a Repository + +When onboarding a repository to this standard: + +1. **Enable secret scanning + push protection** via the API call in + [Required repo-level settings](#required-repo-level-settings). `scripts/apply-repo-settings.sh` + enforces this on every run. +2. **Verify gitignore** contains the standard entries listed in + [Required gitignore entries](#required-gitignore-entries). +3. **Add the `secret-scan` job** to `ci.yml` per [Layer 3](#layer-3--ci-secret-scanning-secondary-defense). +4. **Add `secret-scan` as a required check** in the `code-quality` ruleset — + update [`github-settings.md`](github-settings.md#code-quality--required-checks-ruleset-all-repositories) + if the ruleset template needs a new entry. +5. **Scan existing history** one time with `gitleaks detect --source .` + before enabling enforcement, to surface any pre-existing secrets. +6. **Rotate anything found** during the initial scan — do not whitelist + existing findings without rotation. + +--- + +## Compliance Audit Checks + +The weekly compliance audit ([`scripts/compliance-audit.sh`](../scripts/compliance-audit.sh)) +MUST verify the following for every repository: + +| Check | Severity | Detail | +|-------|----------|--------| +| `secret_scanning_enabled` | error | `security_and_analysis.secret_scanning.status == "enabled"` | +| `push_protection_enabled` | error | `security_and_analysis.secret_scanning_push_protection.status == "enabled"` | +| `non_provider_patterns_enabled` | warning | `security_and_analysis.secret_scanning_non_provider_patterns.status == "enabled"` | +| `open_secret_alerts` | error | `GET /repos/{owner}/{repo}/secret-scanning/alerts?state=open` returns an empty array | +| `secret_scan_ci_job_present` | error | `.github/workflows/ci.yml` contains a job using `gitleaks` | +| `gitignore_secrets_block` | warning | `.gitignore` contains `.env`, `*.pem`, `*.key` entries | +| `push_protection_bypasses_recent` | warning | No bypasses in the last 30 days without a documented justification | + +Findings are reported as GitHub Issues labeled `security` + `compliance-audit` +per the existing audit flow. + +--- + +## Related Standards + +- [`github-settings.md`](github-settings.md) — repo settings, rulesets, org secrets +- [`agent-standards.md`](agent-standards.md) — AgentShield scanner and agent-config hygiene +- [`ci-standards.md`](ci-standards.md) — workflow templates and required checks +- [`dependabot-policy.md`](dependabot-policy.md) — dependency vulnerability updates From 30b2040cd31371849b68bf37da755f5e27e124f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 21:51:59 +0000 Subject: [PATCH 2/5] fix(push-protection): address Copilot review feedback - Fix misleading comment: pre-commit scans tracked files, not history - SHA-pin actions/checkout, add placeholder note for gitleaks-action SHA - Fix severity wording: --exit-code 1 fails on any finding, not medium+ - Rephrase compliance audit reference as aspirational (not yet implemented) - Add dependabot_security_updates to the API example payload - Mark apply-repo-settings.sh enforcement as a follow-up requirement - Rename "Regex (illustrative)" column to "Pattern (illustrative)" and provide an actual regex for generic-high-entropy --- standards/push-protection.md | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/standards/push-protection.md b/standards/push-protection.md index 4e77ddf..1240e69 100644 --- a/standards/push-protection.md +++ b/standards/push-protection.md @@ -86,8 +86,9 @@ gh api -X PATCH "orgs/petry-projects" \ ### Required repo-level settings -Every repository MUST have the following security features turned on. The -compliance audit checks these flags via `GET /repos/{owner}/{repo}`: +Every repository MUST have the following security features turned on. These +flags are exposed via `GET /repos/{owner}/{repo}` and SHOULD be verified by the +compliance audit (see [Compliance Audit Checks](#compliance-audit-checks)): | Setting | Path in API response | Required value | |---------|----------------------|----------------| @@ -105,12 +106,15 @@ gh api -X PATCH "repos/petry-projects/" \ "secret_scanning": {"status": "enabled"}, "secret_scanning_push_protection": {"status": "enabled"}, "secret_scanning_ai_detection": {"status": "enabled"}, - "secret_scanning_non_provider_patterns": {"status": "enabled"} + "secret_scanning_non_provider_patterns": {"status": "enabled"}, + "dependabot_security_updates": {"status": "enabled"} }' ``` -`scripts/apply-repo-settings.sh` MUST enforce these values alongside the -existing merge and label settings — see +`scripts/apply-repo-settings.sh` does not yet enforce these +`security_and_analysis` values. A follow-up change MUST add that enforcement +alongside the existing merge and label settings; until then, apply these +settings using the API example above. See [Application](#application-to-a-repository) below. ### Custom secret scanning patterns @@ -118,12 +122,12 @@ existing merge and label settings — see The org MUST configure the following custom patterns in addition to the provider-supplied ones: -| Pattern name | Regex (illustrative) | Rationale | -|--------------|----------------------|-----------| +| Pattern name | Pattern (illustrative) | Rationale | +|--------------|------------------------|-----------| | `petry-internal-webhook` | `https://hooks\.petry-projects\.internal/[A-Za-z0-9/_-]{20,}` | Internal webhook URLs | | `claude-oauth-token` | `sk-ant-oat01-[A-Za-z0-9_-]{40,}` | Anthropic OAuth tokens | | `gha-pat-scoped` | `github_pat_[A-Za-z0-9_]{82}` | Fine-grained GitHub PATs (provider pattern supplements) | -| `generic-high-entropy` | High-entropy strings assigned to `*_TOKEN`, `*_SECRET`, `*_KEY` env vars | Catches untyped long strings in YAML and `.env` files | +| `generic-high-entropy` | `(?:_TOKEN\|_SECRET\|_KEY)\s*[:=]\s*["']?[A-Za-z0-9/+=_-]{32,}` | Catches untyped long strings in YAML and `.env` files | Custom patterns are configured at **Org settings → Code security → Secret scanning → Custom patterns**. Each new pattern MUST be dry-run against all @@ -165,7 +169,7 @@ Install locally with: ```bash pip install pre-commit pre-commit install -pre-commit run gitleaks --all-files # one-off scan of existing history +pre-commit run gitleaks --all-files # one-off scan of all tracked files ``` ### Agent workstation requirements @@ -203,25 +207,28 @@ secret-scan: security-events: write steps: - name: Checkout (full history) - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Run gitleaks - uses: gitleaks/gitleaks-action@v2 + uses: gitleaks/gitleaks-action@ # v2 — SHA-pin per Action Pinning Policy with: args: detect --source . --redact --verbose --exit-code 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` +> **Note:** The `gitleaks/gitleaks-action` SHA above is a placeholder. When +> adopting this pattern, look up the current SHA for the `v2` tag and pin to it +> per the [Action Pinning Policy](ci-standards.md#action-pinning-policy). + The job MUST: - Use `fetch-depth: 0` so the full git history is scanned, not just the PR diff - Pass `--redact` so leaked values are NEVER written to workflow logs -- Fail the build (`--exit-code 1`) when any finding at or above severity - `medium` is detected +- Fail the build (`--exit-code 1`) when `gitleaks` reports any finding - Run as a **required check** via the `code-quality` ruleset (see [`github-settings.md`](github-settings.md#code-quality--required-checks-ruleset-all-repositories)) From 6922cf5a9442428137a0f24c1b3dbf9f7af8dba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 21:58:32 +0000 Subject: [PATCH 3/5] feat(push-protection): implement enforcement scripts and required checks - apply-repo-settings.sh: add apply_security_analysis() to enforce secret_scanning, push_protection, ai_detection, non_provider_patterns, and dependabot_security_updates on every repo - compliance-audit.sh: add check_push_protection() with 7 checks: secret_scanning_enabled, push_protection_enabled, non_provider_patterns_enabled, open_secret_alerts, secret_scan_ci_job_present, gitignore_secrets_block, push_protection_bypasses_recent - github-settings.md: add Secret Scan (gitleaks) as the 6th required check in the code-quality ruleset - push-protection.md: pin gitleaks/gitleaks-action to SHA ff98106e4c7b2bc287b24eaf42907196329070c7 (v2), update enforcement wording to reflect implemented state --- scripts/apply-repo-settings.sh | 69 ++++++++++++++++++++ scripts/compliance-audit.sh | 113 ++++++++++++++++++++++++++++++++- standards/github-settings.md | 3 +- standards/push-protection.md | 12 +--- 4 files changed, 186 insertions(+), 11 deletions(-) diff --git a/scripts/apply-repo-settings.sh b/scripts/apply-repo-settings.sh index 06449f7..f35fdf9 100644 --- a/scripts/apply-repo-settings.sh +++ b/scripts/apply-repo-settings.sh @@ -3,6 +3,7 @@ # # Companion script to compliance-audit.sh. Applies the settings defined in: # standards/github-settings.md#repository-settings--standard-defaults +# standards/push-protection.md#required-repo-level-settings # # Usage: # # Apply to a specific repo: @@ -160,6 +161,72 @@ apply_settings() { ok "$ORG/$repo settings updated successfully" } +apply_security_analysis() { + local repo="$1" + info "Applying security & analysis settings to $ORG/$repo ..." + + # Fetch current security_and_analysis settings + local sa + sa=$(gh api "repos/$ORG/$repo" --jq '{ + secret_scanning: .security_and_analysis.secret_scanning.status, + secret_scanning_push_protection: .security_and_analysis.secret_scanning_push_protection.status, + secret_scanning_ai_detection: .security_and_analysis.secret_scanning_ai_detection.status, + secret_scanning_non_provider_patterns: .security_and_analysis.secret_scanning_non_provider_patterns.status, + dependabot_security_updates: .security_and_analysis.dependabot_security_updates.status + }' 2>/dev/null || echo "{}") + + if [ "$sa" = "{}" ]; then + err "Could not fetch security_and_analysis for $ORG/$repo — check token permissions" + return 1 + fi + + # Expected: all features enabled + # standards/push-protection.md#required-repo-level-settings + declare -A SA_EXPECTED=( + [secret_scanning]="enabled" + [secret_scanning_push_protection]="enabled" + [secret_scanning_ai_detection]="enabled" + [secret_scanning_non_provider_patterns]="enabled" + [dependabot_security_updates]="enabled" + ) + + local needs_patch=false + local payload="{" + + for key in "${!SA_EXPECTED[@]}"; do + local actual + actual=$(echo "$sa" | jq -r ".$key // \"null\"") + local expected="${SA_EXPECTED[$key]}" + + if [ "$actual" != "$expected" ]; then + info " $key: $actual → $expected" + needs_patch=true + else + ok " $key: already $actual" + fi + # Always include all keys in the payload to keep the PATCH idempotent + payload+="\"${key}\": {\"status\": \"${expected}\"}," + done + + # Remove trailing comma, close object + payload="${payload%,}}" + + if [ "$needs_patch" = false ]; then + ok "$ORG/$repo security_and_analysis already fully compliant" + return 0 + fi + + if [ "$DRY_RUN" = "true" ]; then + skip "DRY_RUN=true — skipping security_and_analysis PATCH for $ORG/$repo" + return 0 + fi + + gh api -X PATCH "repos/$ORG/$repo" \ + -F "security_and_analysis=$payload" > /dev/null 2>&1 \ + && ok "$ORG/$repo security_and_analysis updated successfully" \ + || err "Failed to update security_and_analysis for $ORG/$repo (token may lack admin scope)" +} + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -186,6 +253,7 @@ if [ "$1" = "--all" ]; then failed=0 for repo in $repos; do apply_settings "$repo" || failed=$((failed + 1)) + apply_security_analysis "$repo" || failed=$((failed + 1)) apply_labels "$repo" done @@ -197,5 +265,6 @@ if [ "$1" = "--all" ]; then ok "All repos processed successfully" else apply_settings "$1" + apply_security_analysis "$1" apply_labels "$1" fi diff --git a/scripts/compliance-audit.sh b/scripts/compliance-audit.sh index 82466e5..c48917e 100755 --- a/scripts/compliance-audit.sh +++ b/scripts/compliance-audit.sh @@ -5,6 +5,7 @@ # standards/ci-standards.md # standards/dependabot-policy.md # standards/github-settings.md +# standards/push-protection.md # # Outputs: # $REPORT_DIR/findings.json — machine-readable findings @@ -304,6 +305,114 @@ check_repo_settings() { } +# --------------------------------------------------------------------------- +# Check: Push protection (secret scanning + gitleaks CI) +# --------------------------------------------------------------------------- +check_push_protection() { + local repo="$1" + + # --- security_and_analysis flags --- + local sa + sa=$(gh_api "repos/$ORG/$repo" --jq '{ + secret_scanning: .security_and_analysis.secret_scanning.status, + push_protection: .security_and_analysis.secret_scanning_push_protection.status, + non_provider_patterns: .security_and_analysis.secret_scanning_non_provider_patterns.status + }' 2>/dev/null || echo "{}") + + if [ "$sa" != "{}" ]; then + local ss_status + ss_status=$(echo "$sa" | jq -r '.secret_scanning // "null"') + if [ "$ss_status" != "enabled" ]; then + add_finding "$repo" "push-protection" "secret_scanning_enabled" "error" \ + "Secret scanning is not enabled (current: \`$ss_status\`)" \ + "standards/push-protection.md#required-repo-level-settings" + fi + + local pp_status + pp_status=$(echo "$sa" | jq -r '.push_protection // "null"') + if [ "$pp_status" != "enabled" ]; then + add_finding "$repo" "push-protection" "push_protection_enabled" "error" \ + "Push protection is not enabled (current: \`$pp_status\`)" \ + "standards/push-protection.md#required-repo-level-settings" + fi + + local np_status + np_status=$(echo "$sa" | jq -r '.non_provider_patterns // "null"') + if [ "$np_status" != "enabled" ]; then + add_finding "$repo" "push-protection" "non_provider_patterns_enabled" "warning" \ + "Secret scanning non-provider patterns not enabled (current: \`$np_status\`)" \ + "standards/push-protection.md#required-repo-level-settings" + fi + fi + + # --- Open secret scanning alerts --- + local alert_count=0 + local alert_raw + if alert_raw=$(gh_api "repos/$ORG/$repo/secret-scanning/alerts?state=open" 2>/dev/null); then + alert_count=$(echo "$alert_raw" | jq 'length' 2>/dev/null || echo "0") + fi + if [ "$alert_count" -gt 0 ]; then + add_finding "$repo" "push-protection" "open_secret_alerts" "error" \ + "$alert_count open secret scanning alert(s) — rotate and remediate immediately" \ + "standards/push-protection.md#incident-response" + fi + + # --- CI secret-scan job using gitleaks --- + local ci_content + ci_content=$(gh_api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" --jq '.content' 2>/dev/null || echo "") + if [ -n "$ci_content" ]; then + local ci_decoded + ci_decoded=$(echo "$ci_content" | base64 -d 2>/dev/null || echo "") + if [ -n "$ci_decoded" ] && ! echo "$ci_decoded" | grep -q 'gitleaks'; then + add_finding "$repo" "push-protection" "secret_scan_ci_job_present" "error" \ + "CI workflow \`ci.yml\` does not contain a \`gitleaks\` secret-scan job" \ + "standards/push-protection.md#layer-3--ci-secret-scanning-secondary-defense" + fi + fi + + # --- .gitignore secrets block --- + local gi_content + gi_content=$(gh_api "repos/$ORG/$repo/contents/.gitignore" --jq '.content' 2>/dev/null || echo "") + if [ -n "$gi_content" ]; then + local gi_decoded + gi_decoded=$(echo "$gi_content" | base64 -d 2>/dev/null || echo "") + local missing_entries=() + for pattern in '.env' '*.pem' '*.key'; do + if ! echo "$gi_decoded" | grep -qF "$pattern"; then + missing_entries+=("$pattern") + fi + done + if [ ${#missing_entries[@]} -gt 0 ]; then + add_finding "$repo" "push-protection" "gitignore_secrets_block" "warning" \ + ".gitignore missing secret-related entries: ${missing_entries[*]}" \ + "standards/push-protection.md#required-gitignore-entries" + fi + else + add_finding "$repo" "push-protection" "gitignore_secrets_block" "warning" \ + "No \`.gitignore\` file found — secrets-related entries are required" \ + "standards/push-protection.md#required-gitignore-entries" + fi + + # --- Recent push protection bypasses (last 30 days) --- + local bypass_count=0 + local bypass_raw + if bypass_raw=$(gh_api "repos/$ORG/$repo/secret-scanning/push-protection-bypasses" 2>/dev/null); then + # Filter to last 30 days + local cutoff + cutoff=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") + if [ -n "$cutoff" ]; then + bypass_count=$(echo "$bypass_raw" | jq --arg cutoff "$cutoff" '[.[] | select(.created_at > $cutoff)] | length' 2>/dev/null || echo "0") + else + bypass_count=$(echo "$bypass_raw" | jq 'length' 2>/dev/null || echo "0") + fi + fi + if [ "$bypass_count" -gt 0 ]; then + add_finding "$repo" "push-protection" "push_protection_bypasses_recent" "warning" \ + "$bypass_count push protection bypass(es) in the last 30 days — verify each has a documented justification" \ + "standards/push-protection.md#what-to-do-when-push-protection-blocks-your-push" + fi +} + # --------------------------------------------------------------------------- # Check: Required labels # --------------------------------------------------------------------------- @@ -765,6 +874,7 @@ create_umbrella_issue() { # Each group: category_keys|display_name|remediation_script local groups=( "settings|Repository Settings|apply-repo-settings.sh" + "push-protection|Push Protection & Secret Scanning|apply-repo-settings.sh (security_and_analysis) + per-repo ci.yml and .gitignore" "labels|Labels|apply_labels() in apply-repo-settings.sh" "rulesets|Repository Rulesets|apply-rulesets.sh" "ci-workflows|Workflows|per-repo workflow additions" @@ -953,7 +1063,7 @@ HEREDOC HEREDOC - for category in ci-workflows action-pinning dependabot settings labels rulesets standards; do + for category in ci-workflows action-pinning dependabot settings push-protection labels rulesets standards; do local cat_count cat_count=$(jq --arg cat "$category" '[.[] | select(.category == $cat)] | length' "$FINDINGS_FILE") if [ "$cat_count" -gt 0 ]; then @@ -1019,6 +1129,7 @@ main() { check_action_pinning "$repo" check_dependabot_config "$repo" check_repo_settings "$repo" + check_push_protection "$repo" check_labels "$repo" check_rulesets "$repo" check_codeowners "$repo" diff --git a/standards/github-settings.md b/standards/github-settings.md index f7d0586..363eef0 100644 --- a/standards/github-settings.md +++ b/standards/github-settings.md @@ -142,7 +142,7 @@ rules are deprecated — migrate existing classic rules to rulesets. ### `code-quality` — Required Checks Ruleset (All Repositories) -Every repository MUST have all five quality checks configured and required. +Every repository MUST have all six quality checks configured and required. The specific check names and ecosystem configurations vary by repo, but the categories are universal. @@ -155,6 +155,7 @@ categories are universal. | **Claude Code** | All repos | `claude` | AI code review on every PR | | **CI Pipeline** | All repos | Repo-specific (e.g., `build-and-test`, `TypeScript`, `Go`) | Lint, format, typecheck, test | | **Coverage** | All repos | `coverage` or embedded in CI job | Must meet repo-defined thresholds | +| **Secret Scan** | All repos | `Secret scan (gitleaks)` | Full-history gitleaks scan — see [Push Protection Standard](push-protection.md#layer-3--ci-secret-scanning-secondary-defense) | #### Ecosystem-Specific Configuration diff --git a/standards/push-protection.md b/standards/push-protection.md index 1240e69..9340de5 100644 --- a/standards/push-protection.md +++ b/standards/push-protection.md @@ -111,10 +111,8 @@ gh api -X PATCH "repos/petry-projects/" \ }' ``` -`scripts/apply-repo-settings.sh` does not yet enforce these -`security_and_analysis` values. A follow-up change MUST add that enforcement -alongside the existing merge and label settings; until then, apply these -settings using the API example above. See +`scripts/apply-repo-settings.sh` enforces these values alongside the +existing merge and label settings — see [Application](#application-to-a-repository) below. ### Custom secret scanning patterns @@ -212,17 +210,13 @@ secret-scan: fetch-depth: 0 - name: Run gitleaks - uses: gitleaks/gitleaks-action@ # v2 — SHA-pin per Action Pinning Policy + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 with: args: detect --source . --redact --verbose --exit-code 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` -> **Note:** The `gitleaks/gitleaks-action` SHA above is a placeholder. When -> adopting this pattern, look up the current SHA for the `v2` tag and pin to it -> per the [Action Pinning Policy](ci-standards.md#action-pinning-policy). - The job MUST: - Use `fetch-depth: 0` so the full git history is scanned, not just the From d814c99c99789fc9832b88c3bc75341200d06666 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 23:16:13 +0000 Subject: [PATCH 4/5] refactor: address /simplify review findings - Deduplicate API calls: fetch repo JSON once in the per-repo loop and pass it to both apply_settings/apply_security_analysis (apply script) and check_repo_settings/check_push_protection (audit script), halving API usage per repo - Fix audit drift bug: check_push_protection now audits all 5 security_and_analysis settings (was missing ai_detection and dependabot_security_updates) - Build JSON payload with jq instead of string concatenation - Fix error handling: apply_security_analysis returns 1 on PATCH failure - Remove fragile hardcoded check count ("six") from github-settings.md - Fix ShellCheck SC2168: remove `local` outside functions in main block --- scripts/apply-repo-settings.sh | 62 +++++++++++++++++++++++----------- scripts/compliance-audit.sh | 36 +++++++++++++++++--- standards/github-settings.md | 6 ++-- 3 files changed, 76 insertions(+), 28 deletions(-) diff --git a/scripts/apply-repo-settings.sh b/scripts/apply-repo-settings.sh index f35fdf9..b02c19d 100644 --- a/scripts/apply-repo-settings.sh +++ b/scripts/apply-repo-settings.sh @@ -77,11 +77,12 @@ apply_labels() { apply_settings() { local repo="$1" + local repo_json="$2" info "Applying standard settings to $ORG/$repo ..." - # Fetch current settings + # Extract current settings from the pre-fetched repo JSON local current - current=$(gh api "repos/$ORG/$repo" --jq '{ + current=$(echo "$repo_json" | jq '{ allow_auto_merge: .allow_auto_merge, delete_branch_on_merge: .delete_branch_on_merge, allow_squash_merge: .allow_squash_merge, @@ -93,8 +94,8 @@ apply_settings() { squash_merge_commit_message: .squash_merge_commit_message }' 2>/dev/null || echo "{}") - if [ "$current" = "{}" ]; then - err "Could not fetch settings for $ORG/$repo — check token permissions and repo name" + if [ "$current" = "{}" ] || [ "$current" = "null" ]; then + err "Could not parse settings for $ORG/$repo" return 1 fi @@ -163,11 +164,12 @@ apply_settings() { apply_security_analysis() { local repo="$1" + local repo_json="$2" info "Applying security & analysis settings to $ORG/$repo ..." - # Fetch current security_and_analysis settings + # Extract current security_and_analysis from the pre-fetched repo JSON local sa - sa=$(gh api "repos/$ORG/$repo" --jq '{ + sa=$(echo "$repo_json" | jq '{ secret_scanning: .security_and_analysis.secret_scanning.status, secret_scanning_push_protection: .security_and_analysis.secret_scanning_push_protection.status, secret_scanning_ai_detection: .security_and_analysis.secret_scanning_ai_detection.status, @@ -191,7 +193,6 @@ apply_security_analysis() { ) local needs_patch=false - local payload="{" for key in "${!SA_EXPECTED[@]}"; do local actual @@ -204,13 +205,8 @@ apply_security_analysis() { else ok " $key: already $actual" fi - # Always include all keys in the payload to keep the PATCH idempotent - payload+="\"${key}\": {\"status\": \"${expected}\"}," done - # Remove trailing comma, close object - payload="${payload%,}}" - if [ "$needs_patch" = false ]; then ok "$ORG/$repo security_and_analysis already fully compliant" return 0 @@ -221,10 +217,22 @@ apply_security_analysis() { return 0 fi - gh api -X PATCH "repos/$ORG/$repo" \ - -F "security_and_analysis=$payload" > /dev/null 2>&1 \ - && ok "$ORG/$repo security_and_analysis updated successfully" \ - || err "Failed to update security_and_analysis for $ORG/$repo (token may lack admin scope)" + # Build payload with jq to avoid string-concatenation pitfalls + local payload + payload=$(jq -n '{ + secret_scanning: {status: "enabled"}, + secret_scanning_push_protection: {status: "enabled"}, + secret_scanning_ai_detection: {status: "enabled"}, + secret_scanning_non_provider_patterns: {status: "enabled"}, + dependabot_security_updates: {status: "enabled"} + }') + + if ! gh api -X PATCH "repos/$ORG/$repo" \ + -F "security_and_analysis=$payload" > /dev/null 2>&1; then + err "Failed to update security_and_analysis for $ORG/$repo (token may lack admin scope)" + return 1 + fi + ok "$ORG/$repo security_and_analysis updated successfully" } # --------------------------------------------------------------------------- @@ -252,8 +260,16 @@ if [ "$1" = "--all" ]; then failed=0 for repo in $repos; do - apply_settings "$repo" || failed=$((failed + 1)) - apply_security_analysis "$repo" || failed=$((failed + 1)) + # Fetch full repo JSON once and share across functions + repo_json=$(gh api "repos/$ORG/$repo" 2>/dev/null || echo "{}") + if [ "$repo_json" = "{}" ]; then + err "Could not fetch settings for $ORG/$repo — check token permissions and repo name" + failed=$((failed + 1)) + continue + fi + + apply_settings "$repo" "$repo_json" || failed=$((failed + 1)) + apply_security_analysis "$repo" "$repo_json" || failed=$((failed + 1)) apply_labels "$repo" done @@ -264,7 +280,13 @@ if [ "$1" = "--all" ]; then ok "All repos processed successfully" else - apply_settings "$1" - apply_security_analysis "$1" + repo_json=$(gh api "repos/$ORG/$1" 2>/dev/null || echo "{}") + if [ "$repo_json" = "{}" ]; then + err "Could not fetch settings for $ORG/$1 — check token permissions and repo name" + exit 1 + fi + + apply_settings "$1" "$repo_json" + apply_security_analysis "$1" "$repo_json" apply_labels "$1" fi diff --git a/scripts/compliance-audit.sh b/scripts/compliance-audit.sh index c48917e..746ff14 100755 --- a/scripts/compliance-audit.sh +++ b/scripts/compliance-audit.sh @@ -269,9 +269,10 @@ check_dependabot_config() { # --------------------------------------------------------------------------- check_repo_settings() { local repo="$1" + local repo_json="$2" local settings - settings=$(gh_api "repos/$ORG/$repo" --jq '{ + settings=$(echo "$repo_json" | jq '{ allow_auto_merge: .allow_auto_merge, delete_branch_on_merge: .delete_branch_on_merge, has_wiki: .has_wiki, @@ -310,13 +311,18 @@ check_repo_settings() { # --------------------------------------------------------------------------- check_push_protection() { local repo="$1" + local repo_json="$2" # --- security_and_analysis flags --- + # Uses the pre-fetched repo JSON to avoid a duplicate API call. + # Checks all 5 settings that apply-repo-settings.sh enforces. local sa - sa=$(gh_api "repos/$ORG/$repo" --jq '{ + sa=$(echo "$repo_json" | jq '{ secret_scanning: .security_and_analysis.secret_scanning.status, push_protection: .security_and_analysis.secret_scanning_push_protection.status, - non_provider_patterns: .security_and_analysis.secret_scanning_non_provider_patterns.status + ai_detection: .security_and_analysis.secret_scanning_ai_detection.status, + non_provider_patterns: .security_and_analysis.secret_scanning_non_provider_patterns.status, + dependabot_security_updates: .security_and_analysis.dependabot_security_updates.status }' 2>/dev/null || echo "{}") if [ "$sa" != "{}" ]; then @@ -336,6 +342,14 @@ check_push_protection() { "standards/push-protection.md#required-repo-level-settings" fi + local ai_status + ai_status=$(echo "$sa" | jq -r '.ai_detection // "null"') + if [ "$ai_status" != "enabled" ]; then + add_finding "$repo" "push-protection" "ai_detection_enabled" "warning" \ + "Secret scanning AI detection not enabled (current: \`$ai_status\`)" \ + "standards/push-protection.md#required-repo-level-settings" + fi + local np_status np_status=$(echo "$sa" | jq -r '.non_provider_patterns // "null"') if [ "$np_status" != "enabled" ]; then @@ -343,6 +357,14 @@ check_push_protection() { "Secret scanning non-provider patterns not enabled (current: \`$np_status\`)" \ "standards/push-protection.md#required-repo-level-settings" fi + + local ds_status + ds_status=$(echo "$sa" | jq -r '.dependabot_security_updates // "null"') + if [ "$ds_status" != "enabled" ]; then + add_finding "$repo" "push-protection" "dependabot_security_updates_enabled" "warning" \ + "Dependabot security updates not enabled (current: \`$ds_status\`)" \ + "standards/push-protection.md#required-repo-level-settings" + fi fi # --- Open secret scanning alerts --- @@ -1125,11 +1147,15 @@ main() { info "Detected ecosystems: ${ECOSYSTEMS[*]}" fi + # Fetch full repo JSON once and share with settings/push-protection checks + local repo_json + repo_json=$(gh_api "repos/$ORG/$repo" 2>/dev/null || echo "{}") + check_required_workflows "$repo" check_action_pinning "$repo" check_dependabot_config "$repo" - check_repo_settings "$repo" - check_push_protection "$repo" + check_repo_settings "$repo" "$repo_json" + check_push_protection "$repo" "$repo_json" check_labels "$repo" check_rulesets "$repo" check_codeowners "$repo" diff --git a/standards/github-settings.md b/standards/github-settings.md index 363eef0..9e7565a 100644 --- a/standards/github-settings.md +++ b/standards/github-settings.md @@ -142,9 +142,9 @@ rules are deprecated — migrate existing classic rules to rulesets. ### `code-quality` — Required Checks Ruleset (All Repositories) -Every repository MUST have all six quality checks configured and required. -The specific check names and ecosystem configurations vary by repo, but the -categories are universal. +Every repository MUST have the following quality checks configured and +required. The specific check names and ecosystem configurations vary by repo, +but the categories are universal. #### Required Check Categories From d7bbbfa270117ac157c0186c8229e93b0282431f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 23:30:06 +0000 Subject: [PATCH 5/5] Address CodeRabbit review: fix JSON PATCH, fail-closed audits, tighter heuristics - apply-repo-settings.sh: wrap security_and_analysis in full request body and send via --input instead of -F (which cannot send nested JSON objects) - compliance-audit.sh: fail closed when repo metadata fetch returns {}; add explicit warning findings when secret-scanning alerts or bypass queries fail rather than silently treating failures as zero findings; tighten gitleaks CI check to match actual 'uses: gitleaks-action@' steps, not any gitleaks mention - apply-rulesets.sh: detect gitleaks-action in ci.yml and add "Secret scan (gitleaks)" to the code-quality required checks so newly provisioned repos include it automatically - push-protection.md: fix repo PATCH example to use --input heredoc; add ai_detection_enabled and dependabot_security_updates_enabled rows to the compliance audit checklist https://claude.ai/code/session_01EoMazC6Mn4fjcUvkik7Epm --- scripts/apply-repo-settings.sh | 17 ++++++++++------- scripts/apply-rulesets.sh | 14 +++++++++++--- scripts/compliance-audit.sh | 17 ++++++++++++++++- standards/push-protection.md | 13 +++++++++---- 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/scripts/apply-repo-settings.sh b/scripts/apply-repo-settings.sh index b02c19d..c162945 100644 --- a/scripts/apply-repo-settings.sh +++ b/scripts/apply-repo-settings.sh @@ -217,18 +217,21 @@ apply_security_analysis() { return 0 fi - # Build payload with jq to avoid string-concatenation pitfalls + # Build the full request body and send it as JSON via --input. + # -F cannot send nested objects; --input passes the body directly. local payload payload=$(jq -n '{ - secret_scanning: {status: "enabled"}, - secret_scanning_push_protection: {status: "enabled"}, - secret_scanning_ai_detection: {status: "enabled"}, - secret_scanning_non_provider_patterns: {status: "enabled"}, - dependabot_security_updates: {status: "enabled"} + security_and_analysis: { + secret_scanning: {status: "enabled"}, + secret_scanning_push_protection: {status: "enabled"}, + secret_scanning_ai_detection: {status: "enabled"}, + secret_scanning_non_provider_patterns: {status: "enabled"}, + dependabot_security_updates: {status: "enabled"} + } }') if ! gh api -X PATCH "repos/$ORG/$repo" \ - -F "security_and_analysis=$payload" > /dev/null 2>&1; then + --input - <<<"$payload" > /dev/null 2>&1; then err "Failed to update security_and_analysis for $ORG/$repo (token may lack admin scope)" return 1 fi diff --git a/scripts/apply-rulesets.sh b/scripts/apply-rulesets.sh index 8f0f000..bcb1b3c 100755 --- a/scripts/apply-rulesets.sh +++ b/scripts/apply-rulesets.sh @@ -106,11 +106,14 @@ detect_required_checks() { if echo "$workflows" | grep -qx "ci.yml"; then local ci_wf_name ci_wf_name=$(workflow_name "ci.yml") + # Fetch ci.yml content once; used for first-job detection and secret-scan check below + local ci_content ci_decoded + ci_content=$(gh api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" \ + --jq '.content' 2>/dev/null || echo "") + ci_decoded=$(echo "$ci_content" | base64 -d 2>/dev/null || echo "") # Fetch the first job name from ci.yml local ci_job_name - ci_job_name=$(gh api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" \ - --jq '.content' 2>/dev/null \ - | base64 -d 2>/dev/null \ + ci_job_name=$(echo "$ci_decoded" \ | awk ' /^jobs:/ { in_jobs=1; found=0; next } in_jobs && /^ [a-zA-Z0-9_-]+:/ && !found { @@ -129,6 +132,11 @@ detect_required_checks() { else checks+=("$ci_job_name") fi + + # --- Secret scan (gitleaks) — included when ci.yml contains the gitleaks action --- + if echo "$ci_decoded" | grep -qE 'uses:[[:space:]]*(gitleaks/gitleaks-action|zricethezav/gitleaks-action)@'; then + checks+=("Secret scan (gitleaks)") + fi fi # Output as newline-separated list (guard against empty array with set -u) diff --git a/scripts/compliance-audit.sh b/scripts/compliance-audit.sh index 746ff14..427f254 100755 --- a/scripts/compliance-audit.sh +++ b/scripts/compliance-audit.sh @@ -372,6 +372,10 @@ check_push_protection() { local alert_raw if alert_raw=$(gh_api "repos/$ORG/$repo/secret-scanning/alerts?state=open" 2>/dev/null); then alert_count=$(echo "$alert_raw" | jq 'length' 2>/dev/null || echo "0") + else + add_finding "$repo" "push-protection" "open_secret_alerts_unverified" "warning" \ + "Could not query open secret scanning alerts; verify token scopes and feature availability" \ + "standards/push-protection.md#incident-response" fi if [ "$alert_count" -gt 0 ]; then add_finding "$repo" "push-protection" "open_secret_alerts" "error" \ @@ -385,7 +389,7 @@ check_push_protection() { if [ -n "$ci_content" ]; then local ci_decoded ci_decoded=$(echo "$ci_content" | base64 -d 2>/dev/null || echo "") - if [ -n "$ci_decoded" ] && ! echo "$ci_decoded" | grep -q 'gitleaks'; then + if [ -n "$ci_decoded" ] && ! echo "$ci_decoded" | grep -qE 'uses:[[:space:]]*(gitleaks/gitleaks-action|zricethezav/gitleaks-action)@'; then add_finding "$repo" "push-protection" "secret_scan_ci_job_present" "error" \ "CI workflow \`ci.yml\` does not contain a \`gitleaks\` secret-scan job" \ "standards/push-protection.md#layer-3--ci-secret-scanning-secondary-defense" @@ -427,6 +431,10 @@ check_push_protection() { else bypass_count=$(echo "$bypass_raw" | jq 'length' 2>/dev/null || echo "0") fi + else + add_finding "$repo" "push-protection" "push_protection_bypasses_unverified" "warning" \ + "Could not query push protection bypasses; verify token scopes and feature availability" \ + "standards/push-protection.md#what-to-do-when-push-protection-blocks-your-push" fi if [ "$bypass_count" -gt 0 ]; then add_finding "$repo" "push-protection" "push_protection_bypasses_recent" "warning" \ @@ -1150,6 +1158,13 @@ main() { # Fetch full repo JSON once and share with settings/push-protection checks local repo_json repo_json=$(gh_api "repos/$ORG/$repo" 2>/dev/null || echo "{}") + if [ "$repo_json" = "{}" ]; then + add_finding "$repo" "settings" "repo_metadata_unavailable" "error" \ + "Could not fetch repository metadata; settings and push-protection checks were skipped" \ + "standards/github-settings.md#repository-settings--standard-defaults" + log_end + continue + fi check_required_workflows "$repo" check_action_pinning "$repo" diff --git a/standards/push-protection.md b/standards/push-protection.md index 9340de5..1f4b8ec 100644 --- a/standards/push-protection.md +++ b/standards/push-protection.md @@ -101,14 +101,17 @@ compliance audit (see [Compliance Audit Checks](#compliance-audit-checks)): Apply per repo via: ```bash -gh api -X PATCH "repos/petry-projects/" \ - -F security_and_analysis='{ +gh api -X PATCH "repos/petry-projects/" --input - <<'JSON' +{ + "security_and_analysis": { "secret_scanning": {"status": "enabled"}, "secret_scanning_push_protection": {"status": "enabled"}, "secret_scanning_ai_detection": {"status": "enabled"}, "secret_scanning_non_provider_patterns": {"status": "enabled"}, "dependabot_security_updates": {"status": "enabled"} - }' + } +} +JSON ``` `scripts/apply-repo-settings.sh` enforces these values alongside the @@ -378,8 +381,10 @@ MUST verify the following for every repository: | `secret_scanning_enabled` | error | `security_and_analysis.secret_scanning.status == "enabled"` | | `push_protection_enabled` | error | `security_and_analysis.secret_scanning_push_protection.status == "enabled"` | | `non_provider_patterns_enabled` | warning | `security_and_analysis.secret_scanning_non_provider_patterns.status == "enabled"` | +| `ai_detection_enabled` | error | `security_and_analysis.secret_scanning_ai_detection.status == "enabled"` | +| `dependabot_security_updates_enabled` | error | `security_and_analysis.dependabot_security_updates.status == "enabled"` | | `open_secret_alerts` | error | `GET /repos/{owner}/{repo}/secret-scanning/alerts?state=open` returns an empty array | -| `secret_scan_ci_job_present` | error | `.github/workflows/ci.yml` contains a job using `gitleaks` | +| `secret_scan_ci_job_present` | error | `.github/workflows/ci.yml` contains a job using `gitleaks/gitleaks-action` | | `gitignore_secrets_block` | warning | `.gitignore` contains `.env`, `*.pem`, `*.key` entries | | `push_protection_bypasses_recent` | warning | No bypasses in the last 30 days without a documented justification |