Scut, a risk-first security posture platform
  • Go 82.5%
  • Svelte 8.7%
  • TypeScript 6.3%
  • JavaScript 1.2%
  • Shell 0.4%
  • Other 0.9%
Find a file
Marcello Evangelista d5575d7b64 fix(deploy): correct hcloud shared-vCPU server type names (cx23/cx33)
terraform apply failed with 'server type cx22 not found': the current
shared x86 line is cx23/cx33/cx43/cx53 (verified against the live
/v1/server_types catalog), not the cx22/cx32 naming used in the SCM and
monitoring box defaults. Docs and examples follow.
2026-07-17 12:48:53 +02:00
.github chore(ci): retire GitHub Actions/Blacksmith and add build caches 2026-07-05 23:13:43 +02:00
.semgrep/rules chore(config): remove em-dashes from deploy, Docker, and tooling config 2026-07-03 19:29:03 +02:00
.woodpecker feat(deploy): retire the prod_env blob - config in the repo, secrets in Woodpecker 2026-07-17 02:03:50 +02:00
api feat(admin): surface retryable job failures on the health page 2026-07-16 20:42:39 +02:00
branding feat(auth): brand the Zitadel login with Scut's identity (instance label policy) 2026-07-01 13:17:27 +02:00
cmd fix(onboarding): cancel and surface permanently-failed vision extractions 2026-07-17 12:28:16 +02:00
deploy fix(deploy): correct hcloud shared-vCPU server type names (cx23/cx33) 2026-07-17 12:48:53 +02:00
docs fix(deploy): correct hcloud shared-vCPU server type names (cx23/cx33) 2026-07-17 12:48:53 +02:00
internal fix(onboarding): cancel and surface permanently-failed vision extractions 2026-07-17 12:28:16 +02:00
migrations fix(onboarding): cancel and surface permanently-failed vision extractions 2026-07-17 12:28:16 +02:00
scripts feat(deploy): source every secret env var from individual Woodpecker secrets 2026-07-17 02:03:50 +02:00
web feat(admin): surface retryable job failures on the health page 2026-07-16 20:42:39 +02:00
.air.toml chore(dev): live-reload the API + worker with air 2026-06-29 00:14:59 +02:00
.air.worker.toml chore(config): remove em-dashes from deploy, Docker, and tooling config 2026-07-03 19:29:03 +02:00
.dockerignore chore(config): remove em-dashes from deploy, Docker, and tooling config 2026-07-03 19:29:03 +02:00
.env.example feat(scutty): semantic recall via pgvector with a governed embedding pipeline 2026-07-12 13:55:11 +02:00
.gitignore chore: untrack the accidentally committed server binary 2026-07-17 12:28:21 +02:00
.golangci.yml chore: enforce module boundaries and size limits with linters 2026-07-08 21:28:41 +02:00
.lychee.toml chore(config): remove em-dashes from deploy, Docker, and tooling config 2026-07-03 19:29:03 +02:00
.markdownlint-cli2.jsonc docs: Hetzner + Woodpecker runbook and DO/GitHub-Actions cleanup 2026-07-05 23:13:43 +02:00
docker-compose.yml feat(obs): SLOs, burn-rate alerts, and an error-budget dashboard 2026-07-14 22:07:09 +02:00
Dockerfile chore: update Go to 1.26.5 2026-07-09 22:08:40 +02:00
go.mod feat(integrations): Google Workspace connector with identity posture evidence 2026-07-12 14:07:25 +02:00
go.sum feat(integrations): AWS discovery connector with asset inventory and misconfiguration findings 2026-07-12 14:07:25 +02:00
lefthook.yml chore: add gosec and govulncheck to pre-push hooks 2026-07-10 23:44:51 +02:00
Makefile feat(docs): capture and embed real product screenshots in the user manual 2026-07-12 23:44:51 +02:00
mise.toml chore: update Go to 1.26.5 2026-07-09 22:08:40 +02:00
README.md docs: Hetzner + Woodpecker runbook and DO/GitHub-Actions cleanup 2026-07-05 23:13:43 +02:00
SECURITY.md docs: remove non-essential em-dashes (Issue #116) 2026-07-03 19:29:03 +02:00
sqlc.yaml feat(signup): trials with a real expiry clock and gated self-serve signup 2026-07-14 23:29:18 +02:00

Scut

A risk-first security posture platform. Where Vanta and Drata are certification-first, Scut centers on risk: deep onboarding to understand a business, its industry, and its tech stack, then a continuously maintained risk register, threat-intel correlation, and a pragmatic threat-exposure map. Compliance scoring and automated tests are table stakes we also ship. The differentiator is real-time risk grasp.

Status: the platform is feature-complete and stable. In place: onboarding & risk assessment, the risk register (mitigations, controls, residual scoring), threat-intel correlation, governed multi-provider AI, the passive scan engine (ownership-verified), third-party/vendor risk (TPRM), executive reporting & posture trends, the Ask Scutty AI risk analyst, a hardened multi-tenant API, OpenTelemetry observability, integrations (GitHub SCM + SAML SSO), notifications & alerting, NIST CSF compliance, a public API with webhooks, a two-sided security-services marketplace, threat modeling, architecture-diagram ingestion, a consolidated design system, and an in-house penetration test with remediation. See the documentation index and the architecture overview for the full picture.


Architecture overview

Scut is a modular monolith: a single Go binary (cmd/server) plus a worker entrypoint (cmd/worker), organized by domain module. The frontend and backend are separate deployables that communicate only over a versioned HTTP/JSON API described by an OpenAPI contract. That contract is the decoupling boundary. Any module could later be extracted into its own service without a rewrite.

Stack

Layer Choice Notes
Backend Go + chi small, idiomatic net/http router
Database PostgreSQL + Row-Level Security the only infra dependency for the MVP
DB access pgx + sqlc type-safe Go generated from SQL
Migrations goose plain SQL up/down
Jobs river Postgres-backed background workers
API contract OpenAPI 3.0.3 source of truth for FE client + BE types
Frontend SvelteKit 2 + Tailwind 4 bits-ui components, layerchart charts, @auth/sveltekit sessions
LLM Pluggable providers (Anthropic, OpenAI) server-side only; BYO keys, per-org model governance
Identity Zitadel (self-hosted, OIDC) org ID maps to app org_id

Tech stack, explained

  • Go modular monolith. One codebase, one deploy, domain modules with hard interface boundaries: the operational simplicity of a monolith now, with clean seams to extract a service later. chi keeps routing to stdlib net/http idioms with no framework lock-in.
  • PostgreSQL is the whole backend infra. Tenancy (Row-Level Security), background jobs (river), and the LLM usage ledger all live in Postgres: no Redis, no separate queue, no message bus to operate for the MVP. Tenant isolation is enforced in the database by RLS policies, not just in application code.
  • pgx + sqlc. Queries are written as plain SQL and compiled to type-safe Go, so the compiler catches schema drift. goose migrations are plain reversible SQL.
  • river jobs. Durable, Postgres-backed background work (extraction, scanning, threat-intel correlation, batched LLM calls) runs in a separate worker entrypoint that shares the same modules as the API.
  • OpenAPI as the contract. api/openapi.yaml is the single source of truth. make gen derives the Go server types and the SvelteKit client from it, so the frontend and backend can never silently diverge.
  • SvelteKit frontend. A separate deployable that holds no secrets and talks to the backend only through the generated, fully-typed OpenAPI client, never directly to the LLM or the database. Styled with Tailwind 4, bits-ui primitives, and layerchart for posture trend charts.
  • Pluggable LLM layer. A provider-agnostic seam (internal/platform/llm) supports Anthropic and OpenAI, with bring-your-own keys, a per-org model-governance policy, a usage ledger, prompt caching, and right-sizing. Extraction proposes; humans dispose (a review queue, never auto-commit). All LLM calls are server-side only.
  • Zitadel for identity. Self-hosted OIDC owns login, Google sign-in, MFA, and tokens; the app owns domain roles and all tenant data.

Non-negotiable rules

These are enforced in code review and CI:

  1. Tenant isolation. Every tenant-owned table has org_id and an RLS policy; every tenant-scoped query runs under the tenancy context. New tenant-scoped resources ship with a test proving cross-tenant access fails.
  2. Module boundaries. Modules depend on each other's interfaces, never on another module's repository or tables. Only internal/platform touches infra directly.
  3. Secrets live in env/secret-manager: never in code, never sent to the frontend.
  4. LLM extraction proposes; humans dispose. Extracted assets/risks land in a review queue, never auto-committed to the register.
  5. Active scanning only runs against ownership-verified assets.

Identity & tenancy

Zitadel is the authority for identity (users, login, OIDC, MFA, tokens). A Zitadel organization ID maps 1:1 to the app's org_id used by Postgres RLS. The app database owns all domain data and the app roles (owner / admin / analyst / viewer); it trusts Zitadel only for "who are you and which org."

Every tenant-scoped request flows through one chain:

auth (validate token, extract org + subject)
  → tenancy tx (sets app.current_org so RLS applies)
    → RequireRole (app-owned RBAC)
      → handler
        → store + append-only audit

Repository layout

scut/
  cmd/
    server/            # API binary
    worker/            # background worker (same modules, different entrypoint)
    migrate/           # applies the river job schema (after goose)
    seed/              # dev-only mock data
  internal/
    platform/          # cross-cutting infra: config, logging, db, auth, tenancy ctx, jobs, secrets, llm, otel
    <domain modules>   # tenancy, onboarding, assets, risk, scan, threatintel, threatmodel,
                       # vendors, compliance, reporting, posture, exercises, scutty, marketplace,
                       # providers, integrations, notify/notifications, uploads, aikeys, apikeys, ...
  api/                 # openapi.yaml (the FE/BE contract, source of truth)
  migrations/          # goose SQL migrations (tables + RLS policies)
  web/                 # SvelteKit frontend (separate build)
  deploy/postgres/     # initdb bootstrap (scut DB + least-privilege scut_app role)
  docs/                # developer + operator documentation (see docs/README.md)

The full module map (all 27 internal/ packages, one line each, with boundaries) is in docs/architecture.md.


Local development

Prerequisites

  • mise: provisions the pinned Go + Bun toolchain (mise.toml)
  • Docker + Docker Compose
  • make

mise trust && mise install installs Go 1.26 and Bun 1.3.14 at the versions mise.toml pins, so your local toolchain matches CI. (If you'd rather manage them yourself: Go 1.26+ and Bun 1.3+.)

Quickstart

# 1. Clone
git clone https://github.com/frameward/scut.git && cd scut

# 2. Install the pinned toolchain (Go + Bun)
mise trust && mise install

# 3. Configure environment (insecure local defaults; adjust if you like)
cp .env.example .env
cp web/.env.example web/.env

# 4. Bring up Postgres + Zitadel (behind a Traefik proxy)
docker compose up -d --wait

# 5. Configure Zitadel (ONE-TIME, see docs/zitadel-setup.md).
#    Create the scut-web app and a test user, then set the client ID in BOTH:
#      .env       → SCUT_ZITADEL_CLIENT_ID
#      web/.env   → AUTH_ZITADEL_ID   (+ generate AUTH_SECRET)

# 6. Apply database migrations
make migrate

# 7. Install frontend deps and git hooks (hooks: contributors)
cd web && bun install && cd ..
make hooks

# 8. Run the backend and frontend (separate terminals)
make run        # Go API   → http://localhost:8081
make web-dev    # SvelteKit → http://localhost:5173

# 9. Open the app
open http://localhost:5173

Ports

Service URL Port
Frontend (SvelteKit) http://localhost:5173 5173
API (Go) http://localhost:8081 8081
Zitadel console (via Traefik) http://localhost:8080/ui/console 8080
Postgres localhost 5432

See docs/local-env.md for the full stack (the least-privilege scut_app role, the app database, compose lifecycle) and docs/zitadel-setup.md for the one-time identity setup.


Common commands

make help lists every target. The essentials:

Command What it does
make run Run the API server once (loads .env)
make dev Run the API server with live reload on Go changes (air)
make worker Run the background worker once
make dev-worker Run the worker with live reload (air)
make migrate Apply database migrations (goose up)
make migrate-status Show migration status
make gen Regenerate sqlc, Go API types, and the FE client
make test Run the whole Go suite against an isolated scut_test DB (safe alongside the dev stack)
make test-db Create + migrate the isolated scut_test database (idempotent)
make check go vet + golangci-lint (the same lint CI runs)
make hooks Install git hooks (lefthook); run once after cloning
make web-dev Start the SvelteKit dev server
make web-check Type-check the SvelteKit app

Code generation

api/openapi.yaml is the FE/BE contract. make gen regenerates everything derived from a source of truth:

  • sqlc: type-safe Go from SQL (internal/**/store/)
  • oapi-codegen: Go request/response types (internal/api/types.gen.go)
  • openapi-typescript: the SvelteKit client types (web/src/lib/api/schema.d.ts)

Generated files are committed and must not be hand-edited: change the source and rerun make gen.


Testing

make test          # the whole suite, against an isolated scut_test database

make test runs everything (unit tests plus the DB-backed integration and acceptance tests) and it does so against a dedicated scut_test database that make test-db auto-creates and migrates (idempotently). Two properties matter:

  • It is isolated from the dev stack. The background worker (make worker / make dev-worker) polls the scut database; the suite runs on scut_test. Their River job queues never overlap, so a running worker can't grab and reprocess the jobs the tests enqueue. make test is safe to run alongside a live make dev / make dev-worker: there is nothing to stop first. (Running the suite against the same database a worker polls is the classic source of "flaky" enqueue/lifecycle failures; this design removes that class entirely.)
  • It connects as the non-superuser scut_app role. Superusers bypass Row-Level Security, which would turn tenant-isolation assertions into false passes. scut_test is owned by scut_app and every tenant table is FORCE ROW LEVEL SECURITY, so RLS enforces even for the owner, exactly as in production.

CI (or any custom setup) can point the suite at a different database by exporting SCUT_TEST_DATABASE_URL before go test ./...; make test only supplies the local default. The DB-backed tests skip cleanly when no test database URL is available.

We aim for 100% relevant coverage, not 100% coverage. Always tested: tenant isolation, RBAC, risk scoring, extraction→register mapping, scanner normalization, integration adapters. Generated code and trivial DTOs are not tested. There is no coverage-percentage gate; see the contributing guide.


Contributing

Workflow. Spec first, then an ordered plan, then code. Keep commits small and reviewable; one vertical slice at a time. If execution diverges from the plan, surface it rather than improvising silently.

Before you push. Run make hooks once after cloning. lefthook then runs, on every commit, the same checks CI runs (no version drift; golangci-lint is pinned in go.mod):

  • pre-commit: gofmt + golangci-lint on changed Go; svelte-check when web/ changes.
  • pre-push: go test ./... (the unit suite).

CI. Self-hosted Woodpecker runs on every push to main and every PR. The pipeline lints (gofmt + golangci-lint, go mod tidy clean), builds, applies migrations against an ephemeral Postgres, and runs the full -race test suite, including the integration tests that exercise RLS as the non-superuser scut_app role. It also type-checks the SvelteKit app and runs a security sweep: govulncheck, gosec, semgrep, a frontend dependency audit (gated on high/critical), and a TruffleHog secret scan. The Woodpecker pipeline status gates branch protection. See the workflows under .woodpecker/ and the Hetzner + Woodpecker runbook.

Conventions. Commit messages use conventional prefixes (feat, fix, test, ci, docs, ...). Open PRs against main. The module-boundary and non-negotiable rules above are enforced in review.


Full docs live under docs/: the documentation index is the place to start. Highlights:


License

No LICENSE file has been added yet; until one is, treat this repository as internal / all rights reserved.