CONTRIBUTING.md
Rama main · réplica de lectura del repositorio original.
# Contributing to OWASP CTF in a Box
Thanks for your interest in contributing. This document covers the dev
environment, how to build and test each piece, what CI will hold you to, the
testing conventions this repo deliberately keeps, and the process for getting
a change merged.
Questions and chat go to
[the project's channel](https://owasp.slack.com/archives/C0C2RKGSKD5) on the
OWASP Slack (no account yet? [join OWASP on Slack](https://owasp.org/slack/invite)).
Claims, assignments and decisions stay on GitHub issues, where the record
lasts.
## Dev environment
- **Node 22** across the board (`sync`, `scorer`, `apps/web` all pin it in
CI, and `.nvmrc` pins it locally — `nvm use`). `apps/web` uses **pnpm via corepack** (`corepack enable` once); the
other two are plain npm.
- **Docker with Compose v2** for anything that brings containers up (smoke,
the acceptance scripts, the dev stack). `openssl` for secret generation.
- `shellcheck` and `bats` for the shell layers.
- The fastest way to see the real app with data on it:
```sh
./scripts/dev-stack up # throwaway secrets, local builds, seeded demo board
./scripts/dev-stack down
```
It never touches a real `.env`. Sign-in/`/admin` need a real GitHub OAuth
app on top — `dev-stack up` prints exactly what to add.
## Repo layout
- **`apps/web/`** — the vendored Next.js contestant app (auth, teams,
leaderboard, admin panel). Managed with `pnpm`.
- **`scorer/`** — the judge + leaderboard scoring engine. Plain Node.js,
tested with `node:test`.
- **`sync/`** — the poll service that watches for score comments and feeds
the leaderboard. Plain Node.js, tested with `node:test`.
- **`setup/`** — `ctf-setup.sh` and the event provisioning flow. Bash,
tested with `bats`.
- **`deploy/`** — optional cloud deploy modules (e.g. `aws-terraform/`, ECS
Fargate behind an ALB with ElastiCache for Redis; `fly/`, one Fly machine).
CI-validated by its own Terraform workflow (fmt + validate + test, never
apply) and by the shell job's shellcheck/bats coverage of `deploy/fly/` and
`deploy/aws-terraform/deploy.sh`.
- **`patches/`** — reference patches for the target apps, with their own
[`README.md`](patches/README.md).
- **`scripts/`** — the acceptance, smoke and dev-stack scripts. Bash; the
`*.sh` ones are linted with `shellcheck` in CI.
- **`docs/`** — the documentation site, published via GitHub Pages.
## Building and testing
Each layer tests independently. These commands match what CI runs (the
agent-facing copy with the full gotcha list is
[`AGENTS.md`](AGENTS.md#buildtestlint)):
```sh
# sync
(cd sync && npm ci && npm test)
# scorer — unit tests, the vacuous-pass gate (points every rubric at a
# useless stub, must report 0), then the offline acceptance loop
(cd scorer && npm ci && npm test)
(cd scorer && node tools/vacuous-sweep.mjs)
./scripts/acceptance-scorer.sh
# app
(cd apps/web && corepack enable && corepack pnpm install --frozen-lockfile && corepack pnpm lint && corepack pnpm test)
# app — the grading Lua (classic, quiz, ai) and the admin, hint and team
# scripts executed against a real Redis. Skips without the two env vars; CI
# brings up redis + srh and requires it (see the "Grading Lua" step in ci.yml
# for the two `docker run` lines). Serial: two suites share ctf:admin:settings.
(cd apps/web && UPSTASH_REDIS_REST_URL=http://localhost:8079 UPSTASH_REDIS_REST_TOKEN='replace-with-your-srh-token' \
corepack pnpm exec vitest run upstash --no-file-parallelism)
# app production build — CI also asserts .next/server/app/index.html does
# NOT exist afterwards: `/` must never be statically prerendered
(cd apps/web && BETTER_AUTH_SECRET=ci-dummy BETTER_AUTH_URL=http://localhost:3000 \
corepack pnpm build)
./scripts/acceptance-app.sh
# shell
shellcheck scripts/*.sh scripts/lib/*.sh scripts/dev-stack setup/*.sh scorer/entrypoint.sh sync/docker-entrypoint.sh \
deploy/fly/deploy.sh deploy/fly/render-compose.sh deploy/aws-terraform/deploy.sh
# entrypoint fragments are sourced POSIX sh, never run standalone
shellcheck -s sh --exclude=SC2034 scorer/entrypoints/*.sh
bats setup/test/ && bats deploy/fly/test/ && bats deploy/aws-terraform/test/ && bats scripts/test/
# CHANGELOG rule (what the `changelog` workflow runs on your PR)
scripts/check-changelog.sh origin/main HEAD
# single-module compose bring-ups
./scripts/acceptance-quiz-only.sh
./scripts/acceptance-classic-only.sh
./scripts/acceptance-ai-only.sh
# the whole poll pipeline, end to end, offline
./scripts/smoke.sh
# the real-target scoring gates — Docker, pulls upstream images, minutes per
# row; the matrix rows in the two heavy workflows are the example invocations
./scripts/acceptance-target.sh <target> <stock-image|none> # stock app scores 0/N
./scripts/acceptance-patched.sh <target> <challenge-id> # patched fork scores exactly that one
```
Run the layers your change touches, plus `smoke.sh` for anything that crosses
services. The two real-target gates are for what their workflows' `paths:`
filters name — the filters are the authority, and the two differ slightly.
In summary: rubrics under `scorer/rubric.owasp/`, the judge-path sources in
`scorer/src/`, the scorer Dockerfile, `package.json` and lockfile, the
entrypoints, the gate scripts and `scripts/lib/acceptance-lib.sh`,
`patches/`, and the workflow files themselves.
## What CI runs
`.github/workflows/ci.yml` is a `changes` gate (native `git diff` path
filtering) plus eleven gated jobs — a job for an area your PR doesn't touch is
*skipped*, and a push to `main` runs everything:
| Job | Proves |
|---|---|
| `sync-tests` | Poller parsing, cursors, idempotency, config validation |
| `scorer` | Rubric grammars, judge report format, serve auth, both solve stores; then the offline acceptance loop |
| `vacuous` | No rubric check passes against an up-but-useless stub (0/321) |
| `shell` | shellcheck + bats over `setup/`, `scripts/`, `deploy/fly/`, `deploy/aws-terraform/` (its `deploy.sh` and bats suite) |
| `smoke` | The full poll pipeline against fixture services, including the forged-comment drop and the freeze hold |
| `app` | eslint (`pnpm lint`, zero problems); vitest; the three grading Lua scripts and the admin, hint and team scripts executed against a real Redis behind srh (every `*.upstash.test.ts` suite, run serially; required, not skippable, in CI); the production build; the `/`-never-prerendered assertion; `acceptance-app.sh`'s runtime-config acceptance (one image, three environments) |
| `quiz-only` / `classic-only` / `ai-only` | A single app-side module runs a whole event alone, with no scorer to pull |
| `registries` | The three independently maintained target lists (`sync/src/config.js`, `apps/web/src/lib/apps.ts`, `scorer/src/targets.js`) still agree |
| `docs` | The Jekyll site builds; link/meta checks |
Two heavier workflows (`stock-scores-zero`, `patched-scores-right`) run
`scripts/acceptance-target.sh` and `scripts/acceptance-patched.sh` against
real target containers, one matrix row per target or reference patch, and
are path-scoped to judge-relevant scorer inputs plus `patches/`.
`terraform.yml` validates and *tests* `deploy/aws-terraform` —
`stack.tftest.hcl` plans the stack with mocked providers and reads the
rendered ECS task definitions, because `terraform validate` never inspects
rendered output.
## What a PR needs to pass
Nothing here is only asked for; each item is checked on every PR.
- **CI green.** The `ci.yml` jobs for the areas you touched (table above).
The commands behind each one are in
[AGENTS.md's build/test/lint list](AGENTS.md#buildtestlint); run the ones
for your area before you push.
- **CodeRabbit review.** It must reach "Review completed" on your latest
commit with no open actionable thread (see
[Pull request flow](#pull-request-flow)). Its pre-merge checks include
**"Behavior Changes Ship a Test"**: a change to runtime behavior in
`apps/web/src/`, `sync/src/`, `scorer/src/` or `setup/*.sh` needs a new or
changed test next to it (`apps/web/src/**/__tests__/`, `sync/test/`,
`scorer/test/`, `setup/test/`), or a line in the PR description saying why
it has none (`No test: <reason>`). Pure refactors, copy, comments and docs
are exempt. The check only warns during its trial and is meant to block
later.
- **A CHANGELOG entry.** The `changelog` workflow fails a PR that changes
`apps/`, `sync/`, `scorer/`, `setup/` or `deploy/` without a line under
`## Unreleased` in `CHANGELOG.md`. Test-only paths (`__tests__/`, `test/`,
`*.test.*`, `*.bats`, `*.tftest.hcl`) and `.md` files do not count as code,
with one exception: everything under `scorer/rubric*/` except its `.md`
files counts as code, because a rubric's tests are what the judge runs
against a contestant's fork and changing one moves scores.
For an internal change with nothing to tell an organizer, a maintainer can
apply the `no-changelog` label, which skips the check. Dependabot PRs are
skipped too. Check locally with
`scripts/check-changelog.sh origin/main HEAD`.
- **Conventional Commits** with a DCO sign-off on every commit (see
[DCO](#developer-certificate-of-origin-dco)).
- **Labels are automatic.** The `labeler` workflow labels your PR from the
paths it changes (`.github/labeler.yml`: `javascript`, `scoring`,
`infrastructure`, `area:docs`, `test`, and the rest), so you don't need to
pick any.
## Testing conventions this repo holds on purpose
- **No testing-library.** UI decisions that need tests live in pure
functions, which get tested directly; drag handlers and DOM plumbing don't.
- **Differential tests over a shared source of truth.** Several readers parse
the same input in different languages and share no code, so each is pinned
to a file both sides can assert against rather than to the other's
behaviour. The `.env` bootstrap keys are read by `env_val`
(`scripts/lib/dotenv.sh`, bash, sourced by `setup/ctf-setup.sh`) and by
`loadConfig(env)` (`sync/src/config.js`, JS) — the same keys, two
parsers, and AGENTS.md's lockstep rule applies.
`setup/targets.tsv` is the only authoritative source of a fork's repo name,
and both `sync/test/repo-names.differential.test.js` and
`apps/web/src/lib/__tests__/apps-repo-names.differential.test.ts` parse that
file and assert their own list against it (`setup/test/ctf_setup.bats` does
the same from the bash side). The three independently maintained `TARGETS`
lists — `sync/src/config.js`, `apps/web/src/lib/apps.ts`,
`scorer/src/targets.js` — are compared by
`scripts/check-module-registries.mjs`, which fails closed if any parser
extracts nothing.
- **Anti-vacuous discipline.** A test that "blocks the exploit" against an
app that wasn't up proves nothing. Assertions must be able to fail:
acceptance scripts assert on seeded names/logins reaching real rendered
HTML, and the vacuous sweep exists solely to catch this class.
- **bats: the decisive assertion is the test's LAST statement.** A mid-test
`[[ ... ]]` or a non-final `! cmd | grep` does not gate the test (errexit
exemption); CI's shellcheck also flags `A && B || C` (SC2015).
- **Decisions get ADRs.** If your change picks between real alternatives,
record it in [`docs/decisions.md`](docs/decisions.md) — context, decision,
consequences, status. Superseding an old ADR means marking the old one's
Status, never rewriting it.
- **Docs ship with the change.** A behavior change that leaves
`docs/` describing the old behavior is not done.
## Developer Certificate of Origin (DCO)
This is an OWASP Foundation project, and the
[OWASP Project Policy](https://policy.owasp.org/operational/projects)
names the [Developer Certificate of Origin](https://developercertificate.org/)
as the contributor agreement every OWASP project must use. There is no CLA to
sign and no copyright to assign — you sign off each commit instead,
certifying that you wrote the contribution or otherwise have the right to
submit it under this repo's licences.
Sign off as you commit:
```sh
git commit -s -m "fix: whatever you fixed"
```
`-s` appends a trailer built from your `user.name` and `user.email`:
```
Signed-off-by: Your Name <you@example.com>
```
The name and email must be real and must match what git is configured with —
a sign-off is a statement, not a formality.
Forgot it on the last commit:
```sh
git commit --amend -s --no-edit
```
Forgot it across a branch:
```sh
git rebase --signoff origin/main # then force-push the branch
```
`Signed-off-by:` is required, and is unrelated to the trailers this repo
forbids: no `Generated with`, and no `Co-authored-by:` naming an AI tool or
agent (see [AI-assisted contributions](#ai-assisted-contributions)).
## Claiming an issue
Claim before you build, so two people never write the same PR and the one
who asked first is not the one who loses.
- **Comment on the issue to claim it, then wait to be assigned** before you
open a pull request. A plan in the comment is welcome, not required.
- **A maintainer answers a claim within 3 days**, by assigning you or by
saying why not (someone else has it, or the issue needs a design decision
first). A reply that does not assign you is not a go-ahead: wait for the
assignment. If nobody has answered at all, say so on the issue and go
ahead: the claim is yours.
- **A claim lives on the issue.** Saying so in Slack does not claim it.
- **One open claim at a time**, unless a maintainer says otherwise.
- **An assignment lapses after 7 days** without a draft PR or an update on
the issue. Then the issue is open to the next claim.
- **A pull request for an issue assigned to someone else, or for one nobody
claimed, may be closed without review.**
Exempt: typo and small docs fixes, which need no issue at all; and an issue
you filed yourself, where saying on it that you are working on it is the
claim, with no wait for assignment.
## Pull request flow
1. Unless the change is exempt, claim the issue and wait to be assigned
(see [Claiming an issue](#claiming-an-issue)). Then branch off `main`.
2. Make your change. Keep commits scoped and use
[Conventional Commits](https://www.conventionalcommits.org/) (`feat:`,
`fix:`, `docs:`, `test:`, `chore:`, `ci:`, ...). Sign off every commit
(`git commit -s`) — see [DCO](#developer-certificate-of-origin-dco).
3. Run the relevant tests locally (above).
4. Open a pull request against `main`. Fill in the PR template.
5. CI must be green before merge. Only the jobs for areas your PR touches
run (see `AGENTS.md` for how that gating works); a push to `main` always
runs everything as a safety net.
6. **CodeRabbit review must pass too.** It reviews every PR automatically;
resolve every actionable comment before merge — apply the fix, or reply
with why it doesn't apply and mark the thread resolved. A reasoned
disagreement is fine; an ignored finding is not.
A PR is ready when all of these are true:
- You are currently assigned the issue it closes, your claim went unanswered for 3
days and you said so on the issue, or it is exempt
(see [Claiming an issue](#claiming-an-issue)).
- The commands for the touched area in AGENTS.md were run locally
(or the PR says why they could not be — e.g. no Docker).
- New tests can fail: the summary names the single-token mutation
each new/changed test would catch.
- Invariants in docs/reviewing.md that this diff touches were checked.
- Every commit carries a `Signed-off-by:` trailer (`git commit -s`).
- No `Generated with` / AI `Co-authored-by:` trailers.
- Breaking-change box in the PR template is filled in (`None` is fine).
- Latest commit has a CodeRabbit "Review completed" and no open
actionable thread.
## Proposing a new module
Modules are the kit's extension point — a new CTF vertical (forensics,
api-security, cloud, …) plugs into the same org/teams/leaderboard/admin
spine. Before writing code:
1. Read [`docs/modules.md`](docs/modules.md) — the contract, with the four
shipped modules as worked examples. Section 9 lists the exact files a new
module touches.
2. Open an issue describing the module: what a contestant does, how it's
graded, which transport it uses (in-app like quiz/classic, or
GitHub-mediated like secure-development), and what infrastructure it
needs.
3. Expect the contract's non-negotiables to be held: sandboxed execution,
oracle discipline, a stock-scores-zero guard, and standalone-module
composition (your module must be able to run an event alone).
## Releases
Versions follow [Semantic Versioning](https://semver.org/), cut as
**repo-level annotated tags** from `main` (every release since
`v0.1.0`) with GitHub Releases generated from the Conventional Commit history —
`feat:` implies minor, `fix:`/`docs:`/`chore:` imply patch, a
`BREAKING CHANGE:` footer (or `!` after the type) implies major. The version
is repo-level: `apps/web/package.json` tracks the current tag; `scorer` and
`sync` deliberately carry no version field. [`CHANGELOG.md`](CHANGELOG.md)
summarizes each release.
## AI-assisted contributions
AI- and agent-assisted pull requests are welcome. A few rules apply:
- The **human author is responsible** for the change. You are expected to
understand, review, and stand behind everything in your PR, whether you
typed it or an agent did.
- Commits and PRs carry **no AI attribution** — no "Generated with", no
`Co-authored-by:` trailers for an AI tool or agent. Write commit messages
as you would for any other change.
- If you're using an agent to help write code in this repo, have it follow
[`AGENTS.md`](AGENTS.md) — it documents the exact build/test commands and
the project's real conventions and gotchas (bash compatibility, shellcheck
rules, bats assertion pitfalls, the stock-scores-zero invariant, and so
on).
[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) applies to all project spaces.