diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f5b031c..2da9e0d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,6 +26,7 @@ Closes # ## Checklist +- [ ] Every commit includes a DCO `Signed-off-by` trailer (`git commit -s`; see `docs/dco.md`) - [ ] My code follows the rule template in CONTRIBUTING.md - [ ] I added or updated the matching CLI playbook - [ ] I added or updated all four compliance framework mappings diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 0000000..ac25e31 --- /dev/null +++ b/.github/workflows/dco.yml @@ -0,0 +1,24 @@ +name: Developer Certificate of Origin + +on: + pull_request: + branches: [dev, main] + +permissions: + contents: read + +jobs: + signoff: + name: DCO sign-off + runs-on: ubuntu-latest + steps: + - name: Checkout pull request history + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - name: Verify every pull request commit + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python scripts/check_dco.py "$BASE_SHA" "$HEAD_SHA" diff --git a/docs/access-continuity.md b/docs/access-continuity.md new file mode 100644 index 0000000..f366598 --- /dev/null +++ b/docs/access-continuity.md @@ -0,0 +1,37 @@ +# Project Access Continuity + +OpenShield's public component ownership is recorded in `.github/CODEOWNERS`. +OpenSSF continuity requires more than public names: organization owners must +verify that the project can continue if any one person becomes unavailable. + +## Required capability matrix + +At least two currently available people must independently be able to perform +each critical capability, or use a tested organization-controlled recovery +process: + +| Capability | Primary confirmed | Backup confirmed | Last tested | +|---|---|---|---| +| Triage and close issues | Owner record | Owner record | Date required | +| Review and merge approved changes | Owner record | Owner record | Date required | +| Publish and verify a release | Owner record | Owner record | Date required | +| Recover GitHub organization access | Owner record | Owner record | Date required | +| Manage production deployment access | Owner record | Owner record | Date required | +| Manage domain/DNS access, if applicable | Owner record | Owner record | Date required | +| Rotate security-reporting access | Owner record | Owner record | Date required | + +Names and recovery details may remain in a private owner-controlled record when +publishing them would increase risk. The public OpenSSF justification should +state the date of verification and that two independent holders were confirmed, +without exposing secrets. + +## Review process + +- Review the matrix at least every six months and before each major release. +- Remove access promptly when a role ends and confirm the backup remains valid. +- Test recovery without sharing credentials between individuals. +- Store recovery material outside any single maintainer's personal account. +- Record the review in issue #205 or another auditable owner-approved record. + +The continuity and bus-factor criteria must remain pending until an organization +owner completes and records this verification. Documentation alone is not proof. diff --git a/docs/dco.md b/docs/dco.md new file mode 100644 index 0000000..9876244 --- /dev/null +++ b/docs/dco.md @@ -0,0 +1,33 @@ +# Developer Certificate of Origin + +OpenShield uses the [Developer Certificate of Origin 1.1](https://developercertificate.org/) +as its contribution authorization mechanism. A `Signed-off-by` trailer states +that the contributor is legally entitled to submit the work under the project's +license and agrees to the DCO certification. + +Add the trailer automatically when committing: + +```bash +git commit -s -m "feat: describe the change" +``` + +The name and email in the trailer should identify the contributor and should +match the commit author unless a documented contribution workflow requires a +different authorized signer. Every non-merge commit introduced by a pull +request is checked; a sign-off only in the pull request description is +insufficient. + +Merge commits (e.g. from running `git merge origin/dev` to bring your branch +up to date) are exempt — they carry Git's own default message, not your +authorship, so there is nothing for you to sign off on. Only commits you +authored yourself need the trailer. + +To repair the latest local commit before review: + +```bash +git commit --amend --signoff --no-edit +git push --force-with-lease +``` + +For multiple commits, use an interactive rebase and add a sign-off to each +commit. Do not add another person's sign-off without their authorization. diff --git a/scripts/check_dco.py b/scripts/check_dco.py new file mode 100644 index 0000000..1e5eb8a --- /dev/null +++ b/scripts/check_dco.py @@ -0,0 +1,68 @@ +"""Require a Developer Certificate of Origin sign-off on each PR commit.""" + +import re +import subprocess +import sys +from typing import Iterable + + +SIGNOFF = re.compile(r"^Signed-off-by:\s+.+\s+<[^<>@\s]+@[^<>\s]+>$", re.IGNORECASE | re.MULTILINE) + + +def has_signoff(message: str) -> bool: + """Return whether a commit message contains a well-formed DCO trailer.""" + return SIGNOFF.search(message) is not None + + +def commits_between(base: str, head: str) -> list[str]: + """Return commits introduced between the pull request base and head. + + Excludes merge commits: a `git merge origin/dev` inside a long-running PR + branch produces a commit with Git's default merge message and no + Signed-off-by trailer, through no fault of the author's own commits. + GitHub's own DCO app skips merge commits for the same reason. + """ + output = subprocess.check_output( + ["git", "rev-list", "--reverse", "--no-merges", f"{base}..{head}"], + text=True, + ) + return [item for item in output.splitlines() if item] + + +def commit_message(commit: str) -> str: + """Read one commit message without interpreting its contents as a command.""" + return subprocess.check_output( + ["git", "show", "--no-patch", "--format=%B", commit], + text=True, + ) + + +def unsigned_commits(commits: Iterable[str]) -> list[str]: + """Return commit IDs that lack a valid Signed-off-by trailer.""" + return [commit for commit in commits if not has_signoff(commit_message(commit))] + + +def main() -> int: + if len(sys.argv) != 3: + print("Usage: check_dco.py ", file=sys.stderr) + return 2 + + commits = commits_between(sys.argv[1], sys.argv[2]) + if not commits: + print("No pull request commits found.", file=sys.stderr) + return 1 + + missing = unsigned_commits(commits) + if missing: + print("The following commits lack a valid DCO Signed-off-by trailer:", file=sys.stderr) + for commit in missing: + print(f" {commit}", file=sys.stderr) + print("Recreate or amend them with: git commit -s", file=sys.stderr) + return 1 + + print(f"DCO sign-off verified for {len(commits)} commit(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_dco_check.py b/tests/test_dco_check.py new file mode 100644 index 0000000..c062c29 --- /dev/null +++ b/tests/test_dco_check.py @@ -0,0 +1,36 @@ +"""Tests for the dependency-free DCO enforcement helper.""" + +from unittest.mock import patch + +from scripts.check_dco import commits_between, has_signoff, unsigned_commits + + +def test_has_signoff_accepts_standard_dco_trailer(): + assert has_signoff("feat: change\n\nSigned-off-by: Tanvir Farhad \n") + + +def test_has_signoff_rejects_missing_or_malformed_trailer(): + assert not has_signoff("feat: unsigned change") + assert not has_signoff("Signed-off-by: anonymous") + assert not has_signoff("Signed-off-by: Name ") + + +def test_unsigned_commits_checks_each_commit(): + messages = { + "a": "fix: one\n\nSigned-off-by: A User ", + "b": "fix: two", + } + with patch("scripts.check_dco.commit_message", side_effect=messages.get): + assert unsigned_commits(["a", "b"]) == ["b"] + + +def test_commits_between_excludes_merge_commits(): + """A `git merge origin/dev` inside a PR branch has no Signed-off-by + trailer and isn't the author's own commit — it must never be checked, + or a legitimate PR gets blocked for merging the base branch in.""" + with patch("scripts.check_dco.subprocess.check_output", return_value="abc123\ndef456\n") as mock_run: + result = commits_between("base-sha", "head-sha") + + assert result == ["abc123", "def456"] + called_args = mock_run.call_args.args[0] + assert "--no-merges" in called_args