Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/branch-protection/dev.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "dev branch protection",
"target": "branch",
"enforcement": "active",
"bypass_actors": [],
"conditions": {
"ref_name": {
"include": [
"refs/heads/dev"
],
"exclude": []
}
},
"rules": [
{
"type": "deletion"
},
{
"type": "non_fast_forward"
},
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": true,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"do_not_enforce_on_create": false,
"required_status_checks": [
{
"context": "CI Summary",
"integration_id": 15368
},
{
"context": "DCO sign-off",
"integration_id": 15368
},
{
"context": "dependency-review",
"integration_id": 15368
},
{
"context": "Analyze (python)",
"integration_id": 15368
},
{
"context": "Analyze (javascript)",
"integration_id": 15368
}
]
}
}
]
}
61 changes: 61 additions & 0 deletions .github/branch-protection/main.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "main branch protection",
"target": "branch",
"enforcement": "active",
"bypass_actors": [],
"conditions": {
"ref_name": {
"include": [
"refs/heads/main"
],
"exclude": []
}
},
"rules": [
{
"type": "deletion"
},
{
"type": "non_fast_forward"
},
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 2,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": true,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"do_not_enforce_on_create": false,
"required_status_checks": [
{
"context": "CI Summary",
"integration_id": 15368
},
{
"context": "DCO sign-off",
"integration_id": 15368
},
{
"context": "dependency-review",
"integration_id": 15368
},
{
"context": "Analyze (python)",
"integration_id": 15368
},
{
"context": "Analyze (javascript)",
"integration_id": 15368
}
]
}
}
]
}
54 changes: 54 additions & 0 deletions .github/workflows/branch-protection-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Branch Protection Audit

# Proves that dev and main enforce the rulesets declared in
# .github/branch-protection/ instead of trusting documentation (issue #298).
# Pull requests only validate the declared files; the live comparison runs on
# a schedule and on demand, and a drift result fails the run.

on:
pull_request:
branches: [dev, main]
paths:
- ".github/branch-protection/**"
- "scripts/check_branch_protection.py"
- ".github/workflows/branch-protection-audit.yml"
schedule:
- cron: "0 6 * * 1" # weekly, Monday 06:00 UTC
workflow_dispatch:

permissions:
contents: read

jobs:
audit:
name: Branch protection audit
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Python 3.11
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.11"

- name: Validate declared rulesets
run: python scripts/check_branch_protection.py --validate-only

- name: Compare effective rules with declared rulesets
if: github.event_name != 'pull_request'
env:
# A read-only token can read effective branch rules. Bypass actors are
# only visible to a token that can administer rulesets; without
# BRANCH_PROTECTION_AUDIT_TOKEN they are reported as unverified.
GITHUB_TOKEN: ${{ secrets.BRANCH_PROTECTION_AUDIT_TOKEN || github.token }}
run: python scripts/check_branch_protection.py --repo "$GITHUB_REPOSITORY" --evidence branch-protection-evidence.json

- name: Retain evidence
if: always() && github.event_name != 'pull_request'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: branch-protection-evidence
path: branch-protection-evidence.json
if-no-files-found: warn
retention-days: 90
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ on:
branches:
- dev
- main
# Post-merge assurance: re-run the full gate on the merged result, so a green
# PR run against an older base is not the only evidence for dev and main.
push:
branches:
- dev
- main

# Cancel superseded runs on the same PR to save runner minutes
# Cancel superseded runs on the same PR to save runner minutes. Post-merge runs
# are never cancelled, so every commit on dev and main keeps its own result.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ci-${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# Least privilege: jobs only read repo contents
permissions:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches:
- dev
- main
push:
branches:
- dev
- main
schedule:
- cron: "0 3 * * 1" # weekly, Monday 03:00 UTC

Expand Down
28 changes: 23 additions & 5 deletions .github/workflows/update-learn-page.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ on:
push:
branches: [dev]

# Only the final commit step writes; nothing here needs any other scope.
# dev is protected, so the refreshed statistics are proposed through a pull
# request instead of being pushed to dev directly (issue #298). contents:write
# covers the bot branch; pull-requests:write opens or updates the PR.
permissions:
contents: write
pull-requests: write

concurrency:
group: update-learn-page-${{ github.ref }}
Expand All @@ -19,6 +22,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
# The bot branch push must use the same token as the PR so CI starts.
token: ${{ secrets.STATS_BOT_TOKEN || github.token }}

- name: Set up Python 3.11
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
Expand All @@ -37,13 +43,25 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Commit and push
- name: Propose refreshed statistics
if: steps.diff.outputs.changed == 'true'
env:
TARGET_REF: ${{ github.ref_name }}
BASE_REF: ${{ github.ref_name }}
BOT_BRANCH: docs/refresh-learn-page-stats
# Pull requests opened with the default GITHUB_TOKEN do not start
# workflows, so required checks never report. A maintainer-provided
# STATS_BOT_TOKEN (GitHub App or fine-grained token with contents and
# pull-requests write) lets CI run on the bot PR like any other.
GH_TOKEN: ${{ secrets.STATS_BOT_TOKEN || github.token }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -c "$BOT_BRANCH"
git add docs/learn/index.html README.md
git commit -s -m "docs: refresh learn page and README statistics [skip ci]"
git push origin "HEAD:$TARGET_REF"
git commit -s -m "docs: refresh learn page and README statistics"
git push --force origin "HEAD:refs/heads/$BOT_BRANCH"
if [ -z "$(gh pr list --head "$BOT_BRANCH" --base "$BASE_REF" --state open --json number --jq '.[].number')" ]; then
gh pr create --base "$BASE_REF" --head "$BOT_BRANCH" \
--title "docs: refresh learn page and README statistics" \
--body "Automated refresh generated by \`.github/scripts/update_learn_page.py\` from \`$BASE_REF\` at $GITHUB_SHA. Review and merge through the normal protected-branch flow."
fi
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ OpenShield uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- Branch protection declared as versioned GitHub rulesets for `dev` and `main`, with a scheduled drift audit that retains evidence (#298)
- CI and CodeQL post-merge runs on `dev` and `main` (#298)
- Azure Network Layer Assurance API with 20-domain coverage, network-rule classification, and authoritative IP forwarding and direct Internet route checks
- Azure Resource Graph inventory snapshots as the first OpenShield Evidence Graph foundation
- Azure Data Link Layer Assurance API with LLC and MAC coverage plus ExpressRoute Direct MACsec checks
Expand Down
60 changes: 54 additions & 6 deletions docs/ci-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,63 @@ Compose's `local` profile is not production configuration. Its credentials are d
The branch flow is `feature/* → dev → main`. Protection is applied to the two **destinations**; feature branches stay unprotected for fast iteration.

```
feat/* fix/* docs/* ──PR──▶ dev ──PR──▶ main ──▶ production environment
(unprotected) (gate) (stricter gate)
feat/* fix/* docs/* infra/* ──PR──▶ dev ──PR──▶ main ──▶ production environment
(unprotected) (gate) (stricter gate)
```

- **`dev`** — requires all CI checks above + CodeQL, 1 approving review, and "branches up to date before merging".
- **`main`** — everything `dev` requires, plus stricter review (2 approvals / code owners), `enforce_admins`, and the **Enforce dev to main source** check, which blocks any PR into `main` whose source branch is not `dev`. (To permit emergency hotfixes straight to `main`, widen that job's condition to also accept `hotfix/*`.)
- **`production` environment** — required reviewers with "prevent self-review", so a `dev → main` deployment cannot be approved by its own author.
### Declared state (source of truth)

CI runs at **both** merge points (`on: pull_request` targets `dev` and `main`), so the same gates apply on the way into `dev` and again, stricter, on the way into `main`.
Protection is declared as code in GitHub's repository-ruleset format:

| Branch | File | Reviews | Required checks |
|---|---|---|---|
| `dev` | [`.github/branch-protection/dev.json`](../.github/branch-protection/dev.json) | 1 approval, code owner review, approval of the latest push, stale approvals dismissed, conversations resolved | `CI Summary`, `DCO sign-off`, `dependency-review`, `Analyze (python)`, `Analyze (javascript)` — strict (head must be up to date) |
| `main` | [`.github/branch-protection/main.json`](../.github/branch-protection/main.json) | Same as `dev`, but **2 approvals** | Same as `dev`, strict |

Both rulesets block branch deletion and force pushes and declare **no standing bypass actors**. `CI Summary` already fails when any CI job fails, including **Enforce dev to main source**, which blocks any PR into `main` whose source branch is not `dev`. `require_last_push_approval` means the person who pushed the last commit cannot provide the approval that satisfies the rule, so a promotion cannot be self-approved.

`tests/test_check_branch_protection.py` fails if a declared ruleset is weakened (fewer approvals, non-strict checks, a bypass actor) or names a required check that no workflow job produces — a misspelled required check would otherwise block every merge.

### Applying the rulesets (repository administrators)

Only an administrator can change protection. Import each file once, either through **Settings → Rules → Rulesets → New ruleset → Import a ruleset**, or with the API:

```bash
gh api -X POST repos/OWASP/openshield/rulesets --input .github/branch-protection/dev.json
gh api -X POST repos/OWASP/openshield/rulesets --input .github/branch-protection/main.json
```

To change an existing ruleset, edit the JSON file in a reviewed PR first, then apply it with `gh api -X PUT repos/OWASP/openshield/rulesets/<id> --input <file>`. Once the rulesets are active, remove the legacy classic protection so there is one source of truth. The **production** environment should keep required reviewers with **Prevent self-review** enabled.

### Effective state (evidence)

The **Branch Protection Audit** workflow (`.github/workflows/branch-protection-audit.yml`) runs weekly and on demand. It compares the rules GitHub actually enforces (`GET /repos/{repo}/rules/branches/{branch}`) with the declared files, fails on any drift, and uploads a JSON evidence record as the `branch-protection-evidence` artifact (kept 90 days). Run it locally with:

```bash
python scripts/check_branch_protection.py --validate-only
GITHUB_TOKEN=<token> python scripts/check_branch_protection.py --repo OWASP/openshield --evidence evidence.json
```

A read-only token can see enforced rules. Bypass actors are only returned to a token that can administer rulesets; store one as the `BRANCH_PROTECTION_AUDIT_TOKEN` secret, otherwise bypass actors are reported as *unverified* rather than assumed compliant.

As of the 2026-08-21 audit recorded in issue #298, both branches reported `protected: true` but required status-check enforcement was `off` and no rulesets existed. Until an administrator applies the rulesets above and the audit passes, treat the review and check requirements in this section as **declared, not enforced**.

To demonstrate enforcement after applying (acceptance criteria for #298): open a throwaway PR with a deliberately failing test and confirm merge is blocked; push to `dev` after that PR's last CI run and confirm the stale head cannot merge; open a `dev → main` PR and confirm its author cannot satisfy the approval requirement.

### Emergency changes

There is no standing bypass. If a production incident requires merging without the normal gates:

1. Open an issue labelled `priority: critical` describing the incident, the change and why the gates cannot be met.
2. An administrator temporarily switches the affected ruleset's enforcement to **Evaluate** (or adds themselves as a bypass actor), merges the reviewed fix, and restores **Active** immediately.
3. Record the time window, the actor and the merged commit on the issue, then run the Branch Protection Audit workflow and attach its evidence to the issue.
4. The change still receives a retrospective review from a second maintainer.

### Post-merge CI and automated statistics

CI and CodeQL also run on every push to `dev` and `main`, so each merged commit has its own result instead of relying only on a PR run against an older base. Post-merge runs are never cancelled.

The **Update Learn Page and README Stats** workflow no longer pushes to `dev`. When statistics change it force-updates the `docs/refresh-learn-page-stats` branch and opens (or refreshes) a pull request, which goes through the same protected flow. Pull requests opened with the default `GITHUB_TOKEN` do not start workflows, so administrators should provide a `STATS_BOT_TOKEN` secret (GitHub App token or fine-grained token with contents and pull-requests write) and allow GitHub Actions to create pull requests in repository settings.

---

Expand Down
Loading
Loading