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
52 changes: 52 additions & 0 deletions .github/rulesets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Repository rulesets

`main-branch-protection.json` is the ruleset this repository's supply-chain policy
expects on the default branch. It is kept here because a ruleset lives in GitHub's
settings, not in the tree — so without a checked-in copy there is nothing to review,
diff, or restore.

## Current state vs. this file

`sscsb verify branch-protection` reports what is actually live. As of 2026-09-12 the
live ruleset (id `14413947`, "Branch protection", active, targeting `~DEFAULT_BRANCH`)
carried only `deletion` and `non_fast_forward`, and `sscsb verify` failed with
`MISSING required pull requests`, `MISSING required signed commits`, and
`MISSING required status checks`. This file adds exactly those three rules.

Note that `sscsb harden branch-protection` cannot write them here: it looks for a
ruleset whose `ref_name.include` names the branch literally, and this one targets the
`~DEFAULT_BRANCH` alias, so the planner reports "no ruleset targets this branch —
skipped" and changes nothing.

| Rule | Why |
|------|-----|
| `pull_request` (0 approvals required) | Nothing reaches `main` without a pull request, so every change has a reviewable record and CI runs against it before merge. 0 approvals is the solo-safe setting — a maintainer cannot approve their own PR, so requiring 1 would deadlock a single-maintainer repo. Raise it to 1 as soon as there is a second maintainer. |
| `required_signatures` | Every commit that lands on `main` is cryptographically attributable. |
| `required_status_checks` | The 14 checks listed must pass before merge. Thirteen were taken from checks that actually ran on a real pull request (`34117a7`), not guessed; `gitleaks` was added when Gitleaks was kept alongside TruffleHog, and it reported green on this branch at `8daf695`. `Behavioral Analysis (Socket.dev)` is deliberately excluded because it is conditional and reports `skipped`; `Test (nightly)` because a nightly-toolchain regression is not a reason to block a merge. |

`allowed_merge_methods` is `squash` and `rebase`, and that is not cosmetic. With
`required_signatures` active, GitHub signs the squashed or rebased commit with its
web-flow key. A merge-commit merge instead lands the PR's own commits on `main`
unchanged, so an unsigned commit from a contributor would be rejected at merge time.
Every commit currently on `main` is single-parent, so squash is already the norm here.

## Applying it

Either import it in the UI — **Settings → Rules → Rulesets → New ruleset → Import a
ruleset** — or update the existing ruleset in place:

```sh
gh api -X PUT repos/grcengineering/OCEAN/rulesets/14413947 \
--input .github/rulesets/main-branch-protection.json
```

Then confirm against the tool rather than the UI:

```sh
sscsb verify branch-protection
```

## Rolling it back

Re-import, or send a body whose `rules` array holds only `deletion` and
`non_fast_forward`. Rulesets are versioned by GitHub and nothing here is destructive.
51 changes: 51 additions & 0 deletions .github/rulesets/main-branch-protection.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "Branch protection",
"target": "branch",
"enforcement": "active",
"bypass_actors": [],
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_signatures" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false,
"allowed_merge_methods": ["squash", "rebase"]
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"do_not_enforce_on_create": false,
"required_status_checks": [
{ "context": "Test (stable)" },
{ "context": "Lint" },
{ "context": "Security Audit" },
{ "context": "Supply Chain (cargo-vet)" },
{ "context": "Supply Chain (cargo-deny)" },
{ "context": "Build (ubuntu-latest)" },
{ "context": "Coverage (70% gate)" },
{ "context": "HTH Parity Validate" },
{ "context": "SAST (Semgrep)" },
{ "context": "opengrep" },
{ "context": "trufflehog" },
{ "context": "gitleaks" },
{ "context": "trivy" },
{ "context": "osv-scanner / osv-scan" }
]
}
}
]
}
28 changes: 11 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,23 +167,15 @@ jobs:
- run: cargo fmt --check

# -------------------------------------------------------------------------
# Secrets detection (TruffleHog)
# Secrets detection lives in secrets-scan.yml, not here.
# That workflow runs the same tool (TruffleHog) on a SUPERSET of these triggers
# (push to main + every pull_request, vs. pull_request targeting main here) with
# a strictly stronger configuration: a newer pinned release and
# `--results=verified,unknown` instead of `--only-verified`, so candidates
# whose verification errors out are reported rather than dropped. The weaker duplicate that used to
# sit here was removed on 2026-09-12. secrets-scan.yml runs TruffleHog and
# Gitleaks side by side; only the duplicate TruffleHog job is gone from here.
# -------------------------------------------------------------------------
secrets:
name: Secret Scanning (TruffleHog)
runs-on: ubuntu-latest
steps:
- uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: TruffleHog scan
uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2
with:
extra_args: --only-verified

# -------------------------------------------------------------------------
# SAST (Semgrep)
Expand Down Expand Up @@ -334,5 +326,7 @@ jobs:
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'
- run: pip install pyyaml
# Hash-locked install: pip rejects any artifact whose SHA-256 is not listed
# in the requirements file (sscsb dependency-pinning).
- run: pip install --require-hashes -r scripts/requirements-parity.txt
- run: python3 scripts/hth_parity.py --validate
58 changes: 58 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# sscsb: CodeQL — deep interprocedural analysis on PRs and the default branch.
#
# Three languages are analysed, and all are real source in this repo:
# rust — the OCEAN crate itself (build-mode: none, CodeQL's buildless Rust extractor)
# actions — the workflows under .github/workflows (every sscsb-bootstrapped repo has these)
# python — scripts/hth_parity.py; GitHub's default setup scanned it, and this advanced
# config replaces default setup, so dropping python would shrink coverage.
#
# CodeQL is kept ALONGSIDE OpenGrep (sast-opengrep.yml), not instead of it: they
# model different vulnerability classes. OpenGrep is pattern/dataflow matching over
# a rule corpus; CodeQL does interprocedural taint tracking over a compiled database
# and finds classes an OpenGrep default ruleset does not express at all.
name: CodeQL
on:
push:
branches: ["main"]
pull_request:
schedule:
- cron: "45 3 * * 2"

permissions:
contents: read

jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: rust
build-mode: none
- language: actions
build-mode: none
- language: python
build-mode: none
steps:
- name: Harden runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Analyze
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
category: "/language:${{ matrix.language }}"
13 changes: 9 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ on:
tags:
- 'v*'

# Least privilege at the top: every write grant is declared by the ONE job that
# needs it, so `build` and `sbom` no longer inherit contents/packages/id-token
# write. `attestations: write` was dropped outright — no job in this workflow
# uses actions/attest* (that lives in release-attest*.yml).
permissions:
contents: write
id-token: write
packages: write
attestations: write
contents: read

env:
CARGO_TERM_COLOR: always
Expand All @@ -22,6 +23,8 @@ jobs:
build:
name: Build (${{ matrix.name }})
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -159,6 +162,8 @@ jobs:
name: Generate SBOM
needs: [build]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,6 @@ id_ed25519
.serena/memories/
# CocoIndex Code (ccc)
/.cocoindex_code/

# sscsb: generated output (SBOMs, scan records, receipts, VEX), not policy
.sscsb/out/
120 changes: 120 additions & 0 deletions .sscsb/POSTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# sscsb posture — what is not green, and why

`sscsb verify` is the source of truth; this file exists so that the items it does not
report as `PASS` have a written reason instead of becoming background noise everyone
learns to scroll past. Re-read it whenever `sscsb verify` changes.

Last reconciled: **2026-09-12**, against `sscsb 0.4.0`.
State at that time: **27 PASS · 1 FAIL · 1 DEGRADED · 3 INFO · 15 disabled**.

---

## FAIL — `branch-protection`

Not fixable from the tree: a ruleset lives in GitHub's settings, and `sscsb harden
branch-protection` cannot write this one because it looks for a ruleset that names the
branch literally while ours targets the `~DEFAULT_BRANCH` alias.

The three missing rules — required pull requests, required signed commits, required
status checks — are written out as an importable ruleset in
[`.github/rulesets/main-branch-protection.json`](../.github/rulesets/main-branch-protection.json),
with the reasoning and the one-line `gh api` command in the README beside it. Applying
it is an owner action; `sscsb verify branch-protection` confirms it afterwards.

---

## DEGRADED — `signing-model`

Two different reasons, and only one of them is a gap.

### 1. Lanes that have no read API (`github-web`, `codespaces`, `cloud-claude`)

Vigilant mode, passkey enrollment, Codespaces GPG verification, and the GitHub App
install are web settings that GitHub exposes no API for. sscsb therefore records a
*dated attestation by the owner* rather than proving them, via
`sscsb signing setup <lane> --confirm`. Nobody else can make that attestation truthfully
— asserting it on the owner's behalf would put an unverified claim into the audit
trail, which is precisely the failure mode this control exists to prevent. It stays
un-attested until the owner runs those commands.

### 2. `agent-claude-code` — deliberately NOT converged

sscsb wants a `GIT_CONFIG_*` env block in the agent's settings so that agent commits
carry a **distinct agent identity** and never fall through to the human's.

This repository's owner runs the opposite policy, and it is deliberate: **one signer,
everywhere.** Every commit — human or agent-assisted — signs as the owner with their
1Password-vaulted SSH key, which is registered as an approved human signer in
[`.sscsb/policy/signers.toml`](policy/signers.toml) (see also commit `a71bd13`). A
separate unverified agent identity was retired on purpose; AI involvement is recorded
through the `AI-Assisted` / `AI-Tool` / `AI-Model` / `AI-Role` commit trailers that the
`ai-trailers` control enforces, not through a second key.

Converging this lane would reintroduce the identity split the owner removed. It is
left un-converged on purpose, and this note is the record of that decision. If the
policy ever changes, run `sscsb signing setup agent-claude-code`.

---

## INFO — informational, not gaps

| Control | Why it is INFO |
|---------|----------------|
| `scorecard` | Reports live OpenSSF Scorecard findings. Each is routed to the sscsb control that gates it. The two that remain unroutable are `CIIBestPractices` (register the project at bestpractices.dev — an owner action no tool can perform) and `CodeReview` (Scorecard counts *approved* changesets; a solo maintainer cannot self-approve, so it is capped at 0 until there is a second reviewer). |
| `secure-repo` | StepSecurity secure-repo is a hosted web service, not an action. Nothing to install. |
| `openvex` | No VEX documents exist because there is nothing to waive: `vuln-scan` currently reports 2 findings and 0 at or above `high`. A VEX document should appear only when a specific advisory is deliberately triaged as not-affected. |

---

## Known sscsb defect this repository runs into

**`sscsb 0.4.0`'s pre-push range scan ignores `[controls.secrets].gitleaks = false`.**

The pre-commit path reads the toggle (`src/hooks.rs:610`, `let want_gl =
cfg.control_opt_bool("secrets", "gitleaks")`). The pre-push range path
(`src/hooks.rs:1329`) does not — it runs gitleaks whenever the binary is on PATH,
whatever the config says. `trufflehog` in the same function is ungated the same way.

(The range path also omits the staged path's explicit `--config .gitleaks.toml`, but
that one is harmless: gitleaks resolves a `.gitleaks.toml` at the scan root on its
own. Measured on this repository — 20 findings with the file absent, 16 with it
present, the four `ade.lock.json` hits being the ones its allowlist covers. So the
repo config is applied either way, and restoring it would not unblock anything.)

Two consequences here:

1. sscsb 0.4.0 runs gitleaks at push whenever it is installed, whatever
`.sscsb/config.toml` says (fixed in p4gs/sscs-bootstrapper#77). Gitleaks is
enabled here anyway, so the toggle bug no longer changes what runs.
2. When the pushed branch does not yet exist on the remote, `remote_sha` is the
zero sha and the scan range collapses to `--log-opts=<local_sha>` — the branch's
ENTIRE history rather than the commits being pushed. On this repository that
reports 16 findings, every one of them a false positive in code that has been on
`main` for months: Go file-path strings (`ocean-ed25519.key`) from the
implementation deleted in `81a7e3f`, `Authorization:` header examples in
`docs/quickstart.md`, a research note, and the masking helper in
`src/harden/mod.rs`. TruffleHog over the identical range reports nothing. Over the range actually being pushed
(`f565d53..HEAD`) BOTH scanners report nothing — the findings are an artifact of
the range, not of the change.

A gate that fails closed on twenty false positives is a gate people learn to push
past with `--no-verify`. Do not do that. Fix it in sscsb: gate the range-scan
gitleaks block on the same `control_opt_bool` the staged path uses, pass the repo's
`.gitleaks.toml` when present, and derive the range from the merge-base with the
default branch when the remote ref does not exist yet.

---

## Deliberate tool choices

- **Secret scanning runs TruffleHog and Gitleaks together.** gitleaks was removed on
2026-09-12 and restored on 2026-10-06 (owner decision). TruffleHog *validates* a
candidate against the issuing provider, so `verified` means a live credential, and
that stays the stronger signal where it applies. But `--results=verified,unknown`
drops anything TruffleHog cannot validate, and gitleaks' entropy/regex matching
catches generic secrets (e.g. a bare `api_key = "..."` literal) and unverifiable
keys in that gap. The two tools are complementary, not redundant.
- **SAST runs three engines on purpose.** CodeQL (interprocedural taint tracking over a
compiled database), OpenGrep, and Semgrep model different vulnerability classes;
CodeQL surfaces findings an OpenGrep default ruleset does not express at all. Like the
two secret scanners, they are complementary rather than redundant.
2 changes: 1 addition & 1 deletion .sscsb/best-practices-badge.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ badge API for this repo, so earning the badge also lifts your Scorecard score.
| Static analysis (SAST) applied | `sast` control (OpenGrep/CodeQL) | ☑ |
| Vulnerability scanning of dependencies | `vuln-scan` (Trivy + OSV-Scanner) | ☑ |
| No unpatched medium+/high vulns | `vuln-scan` gate (`fail_on = high`) | ☑ |
| Secrets not committed; scanned | `secrets` (TruffleHog + Gitleaks) | ☑ |
| Secrets not committed; scanned | `secrets` (TruffleHog, verified+unknown, + Gitleaks) | ☑ |
| Vulnerability reporting process documented | `SECURITY.md` | ☐ confirm |
| Cryptographic signing of releases | `sigstore-signing` / `github-attestations` | ☑ |
| Delivery over TLS / pinned deps | `renovate` + pinned Actions (`actions-audit`) | ☑ |
Expand Down
3 changes: 2 additions & 1 deletion .sscsb/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ enabled = false
enabled = true

# Harden-Runner: StepSecurity Harden-Runner egress/tamper monitoring in every workflow
# egress_policy is NOT an option of this control in sscsb 0.4.0 (it warned on every
# invocation and was ignored); the policy lives in each workflow's harden-runner step.
[controls.harden-runner]
enabled = true
egress_policy = "audit"

# Witness (optional): Richer in-toto attestation capture and policy around build steps
[controls.witness]
Expand Down
Empty file modified .sscsb/hooks/commit-msg
100644 → 100755
Empty file.
Empty file modified .sscsb/hooks/pre-commit
100644 → 100755
Empty file.
Empty file modified .sscsb/hooks/pre-push
100644 → 100755
Empty file.
Loading
Loading