diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 00000000..d373897a --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,73 @@ +name: Security + +on: + push: + branches: [develop] + pull_request: + branches: [develop] + schedule: + # Weekly Monday 06:06 UTC — catches new advisories between PRs. + - cron: "6 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + gitleaks: + name: Secret scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 + with: + fetch-depth: 0 # full history for leak detection + # Direct CLI invocation instead of gitleaks/gitleaks-action, which + # requires a paid license key for organization repos. + - name: Install gitleaks + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz -C /usr/local/bin gitleaks + - name: Run gitleaks + run: gitleaks detect --redact --verbose + + cargo-audit: + name: Rust advisory audit + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + crate: + - cli + - torrential + - desktop/src-tauri + - libraries/droplet + - libraries/droplet_types + - libraries/libarchive + - libraries/native_model + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 + - uses: dtolnay/rust-toolchain@7c8d7d138f5c09cef361f8214cf96882cd029cdb # dtolnay/rust-toolchain@nightly + - uses: taiki-e/install-action@4b8992af23a2be34fd58f8d9b50d3f4939ad1891 # taiki-e/install-action@cargo-audit + - name: Audit ${{ matrix.crate }} + # Some vendored crates gitignore their Cargo.lock; generate one on the + # fly so `cargo audit` has a manifest to scan. + run: | + if [ ! -f Cargo.lock ]; then cargo generate-lockfile; fi + cargo audit --file Cargo.lock + working-directory: ${{ matrix.crate }} + + golangci: + name: Go lint (backend) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 + with: + fetch-depth: 0 # needed for --new-from-rev against origin/develop + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # actions/setup-go@v5 + with: + go-version-file: backend/go.work + - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # golangci/golangci-lint-action@v8 + with: + working-directory: backend + # Rollout mode: only report issues on changed lines. Remove once the + # existing debt (2 issues in core/database.go) is cleaned up. + args: --new-from-rev=origin/develop ./core/... diff --git a/.github/workflows/semgrep-scheduled.yml b/.github/workflows/semgrep-scheduled.yml new file mode 100644 index 00000000..138e20d0 --- /dev/null +++ b/.github/workflows/semgrep-scheduled.yml @@ -0,0 +1,27 @@ +name: Semgrep (scheduled) + +# Deep security scan on a weekly schedule — deliberately not wired into git +# hooks or push/PR CI, where its runtime would be too disruptive. +on: + schedule: + - cron: "30 5 * * 1" # weekly Monday 05:30 UTC + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + semgrep: + runs-on: ubuntu-latest + container: + image: semgrep/semgrep + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 + - name: Scan + run: semgrep scan --config p/default --sarif --output semgrep.sarif + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@6d786de4d6f3531a740e445b53a42b622bbbace8 # github/codeql-action/upload-sarif@v3 + with: + sarif_file: semgrep.sarif diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 99511123..b5e28838 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -29,13 +29,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # pnpm/action-setup@v4 - name: Setup Node.js environment - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # actions/setup-node@v4 with: node-version: lts/* cache: "pnpm" @@ -52,13 +52,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # pnpm/action-setup@v4 - name: Setup Node.js environment - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # actions/setup-node@v4 with: node-version: lts/* cache: "pnpm" @@ -69,3 +69,32 @@ jobs: - name: Lint working-directory: server run: pnpm run lint + + knip: + name: Dependency & export report (non-blocking) + runs-on: ubuntu-latest + continue-on-error: true # report-only rollout phase; remove once triaged + steps: + - name: Check out the repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # pnpm/action-setup@v4 + + - name: Setup Node.js environment + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # actions/setup-node@v4 + with: + node-version: lts/* + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Knip report + run: pnpm exec knip --reporter json > knip-report.json || true + + - name: Upload knip report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4 + with: + name: knip-report + path: knip-report.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..30581eb7 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "jsonRecursiveSort": true, + "jsonSortOrder": "{\"/.*/\": \"lexical\"}", + "plugins": ["prettier-plugin-sort-json"] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..12d76b3b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# Drop + +Self-hosted game distribution platform. Polyglot monorepo: + +- `server/` — Nuxt 3 + Vue 3 app (TypeScript, Prisma, Tailwind, buf/protobuf) +- `backend/` — Go backend (go.work, module under `core/`) +- Rust crates: `cli/` (downpour), `torrential/`, `desktop/src-tauri/`, `libraries/{droplet,droplet_types,libarchive,native_model}` +- pnpm workspace: root + `server/`, `libraries/base/`, `sites/*`, `desktop/` + +## Quality gates — what runs when + +| Layer | When | What | +| ------------------- | ---------- | ------------------------------------------------------------------------------------- | +| Claude Code hooks | every edit | format-on-edit (advisory) | +| lefthook pre-commit | commit | prettier + eslint --fix on staged files, ast-grep scan, gitleaks | +| lefthook pre-push | push | server typecheck, clippy (changed crates), golangci-lint, knip report | +| GitHub Actions | PR/push | typecheck/lint/clippy + gitleaks history scan + cargo-audit ×7 crates + golangci-lint | +| GitHub Actions | weekly | semgrep deep scan → Code Scanning | + +Hooks are early feedback; **CI is the authority**. Never disable a hook to make a +failure go away — read the output and fix it. + +### Escape hatches + +```sh +git commit --no-verify # skip pre-commit once +git push --no-verify # skip pre-push once +LEFTHOOK=0 git commit # same via env var +``` + +## Commands + +```sh +pnpm exec lefthook run pre-commit --all-files # dry-run all pre-commit checks +pnpm exec lefthook run pre-push --all-files # dry-run all pre-push checks +pnpm exec ast-grep scan [paths] # structural lint (sgconfig.yml) +pnpm exec ast-grep scan --json # machine-readable output +pnpm exec knip --reporter compact # unused deps/exports/files report +cd backend && golangci-lint run ./core/... # Go lint (.golangci.yml in backend/) +gitleaks protect --staged # secret scan staged changes +``` + +Native binaries NOT installable via pnpm: + +- `gitleaks` — `brew install gitleaks` +- `cargo-audit` — `cargo install cargo-audit` +- `golangci-lint` — `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest` + (must be built with the same Go version as `backend/go.work` or typechecking fails) + +## Rollout status (flip these when clean) + +- **knip**: report-only (`continue-on-error` in CI, `|| true` in hook). Baseline: + 117 unused files / 31 unused exports / 4 unused deps. Remove the escape hatches + to enforce. +- **golangci-lint**: `--new-from-rev=origin/develop` (new issues only). Baseline: + 2 legacy issues in `core/database.go`. Remove flag when count is 0. +- **ast-grep** rules: `severity: warning`. Promote per-rule to `error` after cleanup. + +## Conventions + +- Rust: nightly toolchain (matches CI); clippy with `-D warnings`. +- Formatting: Prettier config at repo root (shared by all JS/TS/Vue packages). +- Go: format with gofmt/goimports; keep modules inside `backend/` consistent with `go.work`. diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 00000000..5992b48e --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,27 @@ +# golangci-lint v2 configuration. +# Run: cd backend && golangci-lint run +# CI: golangci-lint-action@v8 with --new-from-rev=origin/develop (rollout mode). +version: "2" + +run: + timeout: 5m + +linters: + default: none + enable: + - gosec # security-focused linter + - errcheck + - govet + - staticcheck + - ineffassign + - unused + exclusions: + rules: + - path: _test\.go + linters: + - gosec # test fixtures routinely trip G201/G404 etc. + +formatters: + enable: + - gofmt + - goimports diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..c0c77c37 --- /dev/null +++ b/knip.json @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/knip/schema.json", + "workspaces": { + ".": { + "entry": ["lefthook.yml"], + "ignoreDependencies": [ + "lefthook", + "knip", + "@ast-grep/cli", + "prettier", + "prettier-plugin-sort-json" + ] + }, + "desktop": {}, + "libraries/base": {}, + "server": { + "entry": [ + "app.vue", + "error.vue", + "nuxt.config.ts", + "plugins/**/*.{ts,js}", + "middleware/**/*.{ts,js}", + "layouts/**/*.vue" + ], + "ignore": [".nuxt/**", ".output/**", ".data/**", "build/**"], + "project": ["**/*.{ts,js,mjs,vue}"] + }, + "sites/docs": {}, + "sites/promo": {} + } +} diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 00000000..c53c7937 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,74 @@ +# Lefthook git hooks for the Drop monorepo. +# Docs: https://lefthook.dev +# Escape hatches: git commit/push --no-verify, or LEFTHOOK=0 +# +# Commands with a glob that matches no staged files are skipped entirely, +# so e.g. a Markdown-only commit only runs prettier + gitleaks. + +pre-commit: + parallel: true + commands: + # Format only staged files, then re-stage them. + prettier: + glob: "*.{js,cjs,mjs,ts,tsx,vue,json,jsonc,css,scss,html,yaml,yml,md}" + exclude: + - "pnpm-lock.yaml" + - "**/generated/**" + run: pnpm exec prettier --write --ignore-unknown {staged_files} + stage_fixed: true + + # Scoped to server/ — the only package with an eslint flat config today. + # root: makes {staged_files} relative to server/, so the config resolves. + eslint: + root: server/ + glob: "server/**/*.{js,cjs,mjs,ts,tsx,vue}" + run: pnpm exec eslint --fix {staged_files} + stage_fixed: true + + # Structural lint rules (sgconfig.yml). Warnings only during rollout. + ast-grep: + glob: "*.{ts,tsx,vue,go}" + run: pnpm exec ast-grep scan {staged_files} + + # Secret scan on the diff about to be committed. Warns if the binary is + # missing locally — CI enforces this authoritatively (security.yml). + gitleaks: + run: | + if command -v gitleaks >/dev/null 2>&1; then + gitleaks protect --staged --redact --verbose + else + echo "::warning:: gitleaks not installed locally; CI enforces. Install: brew install gitleaks" + exit 0 + fi + +pre-push: + parallel: true + commands: + # Whole-project typecheck; gated on any server TS/Vue change. + typecheck: + root: server/ + glob: "server/**/*.{ts,tsx,vue}" + run: pnpm run typecheck + + # Per-crate clippy over changed crates only (-D warnings, matching droplet-ci). + # desktop/src-tauri is excluded locally (needs Tauri system libs); CI covers it. + clippy: + glob: "*.rs" + run: ./scripts/clippy-changed.sh + + # New-issues-only during rollout: backend/ has never been linted before. + # Compares against origin/develop; remove --new-from-rev once clean. + golangci-lint: + root: backend/ + glob: "backend/**/*.go" + run: | + base="$(git rev-parse --verify origin/develop 2>/dev/null || echo "")" + if [ -n "$base" ]; then + golangci-lint run --new-from-rev="$base" ./... + else + golangci-lint run ./... + fi + + # Report-only by design (rollout phase): prints findings, never fails. + knip: + run: pnpm exec knip --reporter compact || true diff --git a/rules/no-console-log.yml b/rules/no-console-log.yml new file mode 100644 index 00000000..2dc0e2a3 --- /dev/null +++ b/rules/no-console-log.yml @@ -0,0 +1,12 @@ +id: no-console-log +message: Use the project logger (pino) instead of console.log +severity: warning # promote to error once codebase is clean +language: TypeScript +rule: + pattern: console.log($$$ARGS) +files: + - server/** +ignores: + - "**/*.d.ts" + - "**/*.spec.ts" + - "**/*.test.ts" diff --git a/rules/no-fmt-println.yml b/rules/no-fmt-println.yml new file mode 100644 index 00000000..3f0f53b7 --- /dev/null +++ b/rules/no-fmt-println.yml @@ -0,0 +1,6 @@ +id: no-fmt-println +message: Use a structured logger instead of fmt.Println +severity: warning +language: Go +rule: + pattern: fmt.Println($$$ARGS) diff --git a/scripts/clippy-changed.sh b/scripts/clippy-changed.sh new file mode 100755 index 00000000..55c65450 --- /dev/null +++ b/scripts/clippy-changed.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Run `cargo clippy -D warnings` only over crates that contain changed .rs files. +# Called by lefthook pre-push. desktop/src-tauri is intentionally excluded: +# it needs Tauri system libraries that contributors on minimal setups won't have. +# CI covers it in droplet-ci.yml / torrential-ci.yml. +set -uo pipefail + +# Resolve the repo root regardless of where the hook runs. +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" || exit 1 + +# :colon: curls are handled by set -e semantics below. +crates=( + cli + torrential + desktop/src-tauri + libraries/droplet + libraries/droplet_types + libraries/libarchive + libraries/native_model +) + +# Accumulate changed .rs files relative to repo root. +changed_rs="$(git diff --name-only HEAD -- '*.rs' 2>/dev/null; git diff --cached --name-only -- '*.rs' 2>/dev/null)" +# Falls back to previous-branch commit when @{push} isn't resolvable. +if [ -z "$changed_rs" ]; then + upstream="$(git rev-parse --verify '@{push}' 2>/dev/null || git rev-parse --verify 'HEAD~1' 2>/dev/null || echo "")" + if [ -n "$upstream" ]; then + changed_rs="$(git diff --name-only "$upstream..HEAD" -- '*.rs' 2>/dev/null)" + fi +fi + +if [ -z "$changed_rs" ]; then + echo "clippy-changed: no .rs changes; skipping" + exit 0 +fi + +status=0 +for dir in "${crates[@]}"; do + if printf '%s\n' "$changed_rs" | grep -q "^${dir}/"; then + echo "==> cargo clippy (${dir})" + (cd "$dir" && cargo clippy --all-targets --all-features -- -D warnings) || status=1 + fi +done + +exit "$status" \ No newline at end of file diff --git a/sgconfig.yml b/sgconfig.yml new file mode 100644 index 00000000..4e9f32a3 --- /dev/null +++ b/sgconfig.yml @@ -0,0 +1,5 @@ +# ast-grep configuration. Rules live in ./rules. +# Scan: pnpm exec ast-grep scan [paths] +# Machine-readable: pnpm exec ast-grep scan --json +ruleDirs: + - rules