A minimal but real Spring Boot player-wallet API, wired to a production-shaped CI/CD security pipeline. Built as a learning sandbox and interview portfolio piece for an Application Security Engineer role.
The whole repo is organised around one idea:
The PR is the security control point, and branch protection is what makes it binding. The application code embodies the secure patterns; the pipeline enforces them.
Most "AppSec portfolios" are either an app with no pipeline, or a pipeline with no app to scan. This has both, so the story is end-to-end: a developer opens a PR → scanners run → the gate blocks on critical/high → merge builds and signs the artifact you actually deploy → posture is graded continuously.
The app is small on purpose. The interesting part is that every endpoint is the secure counterpart to a classic vulnerability, so you can point at real code when you talk.
A tiny slice of a lottery-style platform: register a player, log in for a JWT, read your wallet.
| Endpoint | Notes |
|---|---|
POST /api/auth/register |
Input-validated; password stored as a BCrypt hash |
POST /api/auth/login |
Generic error (no user enumeration); issues a signed, algorithm-pinned JWT |
GET /api/wallet/me |
Your wallet, resolved from the verified token — no id in the path |
GET /api/wallet/{walletId} |
IDOR/BOLA-safe: scoped to the owner at the data layer → foreign id returns 404 |
GET /actuator/health |
The only public actuator endpoint |
Demo users are seeded at startup (dev profile only): alice / bob, password correct-horse-battery.
- Broken access control / IDOR (CWE-639) →
WalletRepository.findByIdAndOwnerId(...)scopes authz at the data layer. - SQL injection (CWE-89) → derived/bound queries only; no string concatenation.
- Weak password hashing (CWE-916) →
BCryptPasswordEncoder(12). - JWT signature bypass / alg confusion (CWE-347) → signature always verified, algorithm pinned,
iss/aud/expvalidated. - Hardcoded secrets (CWE-798) → signing secret comes from
JWT_SECRET; nothing sensitive in source. - Information exposure (CWE-209) → global handler returns generic errors; stack traces never leak.
Requires JDK 21 and Maven 3.6.3+ (built and tested against 3.8.7). CI and Docker use their own Maven (3.9), which is independent of your local version.
# 1) build, run tests (incl. the IDOR-block test), generate an SBOM
mvn -B verify
# 2) run
export JWT_SECRET="$(openssl rand -base64 48)" # optional; ephemeral key if unset
mvn spring-boot:run
# 3) try it
curl -s localhost:8080/actuator/health
TOKEN=$(curl -s localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"correct-horse-battery"}' | jq -r .accessToken)
curl -s localhost:8080/api/wallet/me -H "Authorization: Bearer $TOKEN"Run with Docker (distroless, non-root image):
docker build -t genesis .
docker run -p 8080:8080 -e JWT_SECRET="$(openssl rand -base64 48)" genesis developer PULL REQUEST (the gate) MERGE → main continuous
───────── ──────────────────────────────── ───────────────────── ─────────────
(local) pr-security.yml build-sign-scan.yml scorecard.yml
├─ gitleaks (secrets) →SARIF ├─ build image (repo posture)
├─ Semgrep OSS (SAST) →SARIF ├─ Trivy scan the IMAGE dast.yml
├─ Trivy fs (SCA) →SARIF ├─ Syft SBOM (ZAP, scheduled,
├─ Maven build + tests + SBOM ├─ cosign keyless sign informational)
└─ security-gate (fails on any) └─ SLSA provenance attest
│
branch protection = required check + CODEOWNERS + up-to-date + no direct push
| File | Purpose |
|---|---|
.github/workflows/pr-security.yml |
The PR gate: gitleaks + Semgrep + Trivy + build/test, aggregated security-gate job that fails on any critical/high. |
.github/workflows/build-sign-scan.yml |
Trunk pipeline: scans the built image, SBOM, keyless cosign signing, provenance attestation, push to GHCR. |
.github/workflows/dast.yml |
OWASP ZAP baseline against a running instance (scheduled, informational). |
.github/workflows/scorecard.yml |
OSSF Scorecard supply-chain grading. |
.github/CODEOWNERS |
Required review on risky paths and on the pipeline's own config. |
.gitleaks.toml / .trivyignore.yaml |
Governed allowlists (reason + expiry required). |
scripts/setup-branch-protection.sh |
Makes the gate binding on main. |
Dockerfile |
Hardened multi-stage → distroless, non-root runtime. |
Details, design rationale, the Checkmarx drop-in, and deploy-time verification are in docs/PIPELINE.md.
- Push this repo (public repo recommended so cosign/Scorecard work without extra config).
- Replace
@your-github-usernamein.github/CODEOWNERSwith your handle. ./scripts/setup-branch-protection.sh <owner>/genesis-secure-pipeline(needs theghCLI).- Settings → Code security → enable secret scanning + push protection.
- Settings → Rules → enable a merge queue (so the gate re-runs against post-merge state).
- Open a PR and watch the checks run. Then try the exercise below.
The best way to understand each control is to watch it fail. See docs/DEMO-break-the-gate.md — small, reversible changes that each trip a specific gate (introduce SQLi → Semgrep goes red; downgrade a dependency → Trivy goes red; commit a fake key → gitleaks goes red; break the IDOR scope → the integration test goes red).
Honest scope notes (talking points, not hidden)
- Semgrep OSS stands in for Checkmarx so the repo runs with no paid secrets;
docs/PIPELINE.mdshows the one-job swap. The JD names Checkmarx — the integration pattern (incremental on PR, full nightly, delta-gate on new findings, SARIF to the Security tab) is identical. - Actions use version tags, not SHAs. SHA-pinning is the recommended hardening step; Dependabot (
github-actionsecosystem) is configured to maintain those pins. Left as tags here for readability — a deliberate, documented trade-off. - DAST is informational. A token-gated JSON API gives a baseline crawl little to work with; meaningful DAST would need authenticated ZAP scripting. Wired up and running, but honestly scoped.