Skip to content

osctrl-frontend: React admin SPA at frontend/ (round 3 of 3)#815

Merged
javuto merged 3 commits into
jmpsec:mainfrom
alvarofraguas:pr/round-3-frontend
May 16, 2026
Merged

osctrl-frontend: React admin SPA at frontend/ (round 3 of 3)#815
javuto merged 3 commits into
jmpsec:mainfrom
alvarofraguas:pr/round-3-frontend

Conversation

@alvarofraguas
Copy link
Copy Markdown
Contributor

Summary

Round 3 of 3. Lands the React + TypeScript + Vite SPA under a new frontend/ directory at the repo root. The SPA fully covers what the legacy osctrl-admin templates do — every page surface is replicated. Both UIs can run side-by-side during a migration window (dev compose serves the SPA on :8088 while the legacy admin stays on :8443); the legacy admin is not touched by this PR.

⚠️ Stacked on #813 + #814. When those merge in order, this branch will be re-targeted at the new main HEAD with no conflicts.

End-to-end tested against a Kali docker deployment.

What's in frontend/

frontend/
├── package.json + package-lock.json
├── vite.config.ts                  ← Vite 7, /api → :8081 dev proxy
├── tsconfig.json                   ← TS 5 strict
├── public/favicon.svg              ← osctrl Geometric tower mark
├── scripts/copy-monaco.mjs         ← Self-host Monaco runtime under CSP
├── monaco-runtime.sha256           ← Supply-chain pin for Monaco
└── src/
    ├── main.tsx                    ← Router + Query bootstrap
    ├── routes/                     ← TanStack Router (file-based)
    ├── components/                 ← primitives / atoms / chrome / data / forms
    ├── features/                   ← One folder per page surface
    ├── api/                        ← Typed clients per resource
    ├── lib/                        ← cn, time, design-tokens
    └── styles/                     ← tokens.css + Tailwind base

Tech stack

  • React 19 + TypeScript 5 (strict)
  • Vite 7 + @tailwindcss/vite + Tailwind CSS v4
  • TanStack Router (typed file-based routing), TanStack Query 5, TanStack Table 8
  • react-hook-form 7 + zod 3
  • Radix UI primitives (à la carte), lucide-react (icons)
  • Monaco editor (lazy-loaded for osquery / config editors)
  • Vitest + @testing-library/react + jsdom

Bundle: ~780 KB JS / ~52 KB CSS pre-compression → ~214 KB JS + ~9 KB CSS after gzip. Monaco is code-split into its own chunk so pages that don't use it don't pay the editor cost.

Pages

Every page surface the legacy admin offers is covered:

  • Login (env picker via pre-auth /login/environments)
  • Dashboard (cross-env KPIs, agent versions, active queries, recently seen, failed enrolls)
  • Nodes table (paginated, sortable, searchable, quick-filters) — 4×24h activity heatmap per row
  • Node detail (system info, status logs, result logs, distributed queries, carves, activity tab)
  • Queries (list, run form with target selector + Monaco editor, results with virtual-scroll + CSV export, saved queries CRUD)
  • Carves (list, run form, detail with archive download)
  • Tags (env-scoped + global)
  • Users (list, permissions modal, token modal)
  • Profile (password change, token refresh)
  • Environments (list, create, edit, Monaco-based config editor with DiffView)
  • Enroll page (per-OS one-liners + downloads)
  • Audit log (paginated, filtered)
  • Settings (per-service, typed inputs)

Design system

Locked tokens, captured in frontend/src/styles/tokens.css and frontend/src/lib/design-tokens.ts (kept in sync):

  • Dark default, full light parity — data-theme="dark|light" on <html>
  • Signal-teal accent (#2bc4be dark / #0a8a85 light), one accent active per screen
  • Semantic status colors that always carry an icon or label (a11y)
  • Inter (body) + Space Grotesk (display / KPIs) + IBM Plex Mono (UUIDs / timestamps / cells)
  • Tabular nums throughout, no row-jitter on refresh
  • Density modes (comfortable / compact / dense) via CSS custom properties

Auth flow

  • HttpOnly osctrl_token cookie set by the API on login (no token in localStorage)
  • Double-submit CSRF (osctrl_csrf cookie + X-CSRF-Token header) for mutating requests
  • 401 on any endpoint → redirect to /login/$env?next=...
  • CLI / Bearer clients unaffected (no cookie present → no CSRF needed)

Deployment

Three patterns, all reference each other for consistency:

  1. nginx (recommended) — deploy/nginx/frontend.conf.example shows the production pattern: root + try_files for the SPA, /api/* to osctrl-api, baseline security headers (HSTS / CSP / X-CTO / XFO / Referrer-Policy / Permissions-Policy), immutable cache for hashed assets, no-cache for index.html.
  2. Dockerdeploy/docker/dockerfiles/Dockerfile-osctrl-frontend: multi-stage (node:20 builds dist/, nginx:alpine serves it + reverse-proxies /api/*). Single image, single binary's worth of operational surface.
  3. Static hosting + CDN — upload frontend/dist/ to S3/Cloudfront/etc., configure CORS on osctrl-api.

The dev compose stack adds an osctrl-frontend service that builds the same multi-stage image on :8088 alongside the legacy admin on :8443 so operators can compare the two on the same data.

Make targets

Target Effect
make frontend-install npm ci
make frontend-dev Vite dev server on :5173, proxies /api:8081
make frontend-test vitest + tsc
make frontend-build Produces frontend/dist/
make frontend install + build (CI / Docker shorthand)

CI

.github/workflows/frontend-build.yml:

  • Pinned action SHAs (matches osctrl convention)
  • Typecheck (npm run checktsc --noEmit)
  • Tests (npm test → vitest)
  • Build (npm run build → vite)
  • dangerouslySetInnerHTML gate: build fails if it appears anywhere under src/. Every node-originating field must be JSX-escaped — this gate prevents a future contributor from silently regressing the XSS surface.
  • Uploads frontend/dist/ as a 7-day artifact

Test plan

  • npx tsc --noEmit — clean
  • npx vitest run — 19 test files, 92 tests pass
  • npm run build — produces frontend/dist/ cleanly
  • Backend untouched: go build ./..., go vet ./..., all 14 Go packages' tests pass
  • End-to-end smoke against a Kali docker deployment (login → nodes table → run a query → see results → carve a file → log out)

Why a separate frontend/ directory

  • The SPA's build / test / lint loop is fundamentally npm-based; it doesn't want to sit inside cmd/* next to the Go binaries.
  • A separate top-level folder makes it obvious to drive-by contributors what the directory is and what tooling expectations apply.
  • Per-folder CI: the workflow defaults to working-directory: frontend and can evolve independently of the Go workflows.

@javuto javuto added ✨ enhancement New feature or request ⭐️ frontend Frontend related issues labels May 13, 2026
alvarofraguas pushed a commit to alvarofraguas/osctrl that referenced this pull request May 13, 2026
Consolidated follow-up that lands on top of the three stacked PRs to
address lint, a real bug uncovered by lint, a stronger JWT-secret
contract, and a few deployment-correctness items.

== Lint cleanup (golangci-lint on PR jmpsec#815) ==

- pkg/auditlog/audit.go, pkg/dbutil/buckets.go: drop the redundant
  `.Dialector` selector on `*gorm.DB` (QF1008). `Dialector` is an
  embedded interface so the promoted `Name()` works directly.
- cmd/api/handlers/utils.go: remove the unused `postgresQueryLogs`
  function (unused). Pre-existing dead code that surfaced once the
  package was touched by other PRs in the stack.
- cmd/admin/handlers/json-nodes.go: annotate the two legacy admin
  callers of `Nodes.SearchByEnvPage` / `Nodes.GetByEnvPage` with
  `//nolint:staticcheck // SA1019: intentional legacy admin caller;
  new SPA uses GetByEnvPaged`. The deprecation tag is correct — the
  legacy admin will migrate to `GetByEnvPaged` when it adopts the
  SPA's pagination shape; until then these calls are gated by the
  package-layer `SortableColumns` allowlist and are safe.

== Real bug uncovered by ineffassign ==

cmd/api/handlers/environments.go: in the `"create"` action, the
tag-creation failure path set `msgReturn = fmt.Sprintf("error
generating tag %s ", err.Error())` and then `return`-ed without ever
writing to the response. Result: the API returned the request body's
buffered HTTP 200 (or no body at all) on a real failure, masking the
error from the client. Replaced with a proper `apiErrorResponse(w,
"error generating tag", http.StatusInternalServerError, err)`.

== JWT secret contract: decouple user-manager construction from
   token-signing config ==

Round 1 added `MinJWTSecretBytes = 32` to `users.CreateUserManager`,
which `log.Fatal`s when the JWT secret is shorter than 32 bytes. This
was correct for the API and admin services (they sign tokens) but
caught the CLI (`cmd/cli`) by surprise — the CLI doesn't mint JWTs at
all, it just manages user/permission rows directly, and was passing
`appName` ("osctrl", 6 bytes) as a placeholder. Every CLI invocation
with `--db` would have aborted with "JWT Secret too short" once
Round 1 lands.

Fix: split the constructor.

  // DB-only constructor — never validates JWT.
  func CreateUserManager(backend *gorm.DB) *UserManager

  // Attach JWT signing config; validates the secret here.
  func (u *UserManager) WithJWT(*config.YAMLConfigurationJWT) *UserManager

Token-issuing callers chain:

  apiUsers   = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)
  adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)

The CLI calls only `CreateUserManager(db.Conn)`. `CreateToken` now
returns an error if invoked on a manager without `WithJWT` —
defense-in-depth so a future caller can't accidentally sign tokens
with a nil config.

== TLS handler: per-endpoint body caps ==

cmd/tls/handlers/post.go: wrap every osquery-facing `io.ReadAll` in
`http.MaxBytesReader` with an endpoint-appropriate ceiling. Without
this, a misbehaving or hostile node can submit an arbitrarily large
body and force the server to buffer it before parsing. Caps are
chosen against real osquery payload sizes:

  enroll:           64 KiB
  config:           64 KiB
  log:             100 MiB   (osquery log batches)
  query read:       16 KiB
  query write:     100 MiB
  carve init:        8 KiB
  carve block:      16 MiB
  quick-enroll:      8 KiB
  flags / cert:      8 KiB
  verify / script:   8 KiB
  osquery config:    2 MiB

A single `readBody(w, r, max)` helper applies the cap and reads, so
the call sites stay one-line.

== Carve compression: out-of-bounds panic guard ==

pkg/carves/utils.go: `CheckCompressionRaw` previously dereferenced
`data[:4]` to compare against the zstd header. A truncated or empty
block (which an authenticated CarveLevel client can submit) would
panic with index-out-of-range. Guarded with a length check.

== Frontend (PR jmpsec#815 scope) ==

- frontend/index.html: removed the inline `<script>` that bootstraps
  the theme attribute and moved it to
  `frontend/public/theme-bootstrap.js`. The inline form violated the
  CSP `script-src 'self' blob:` deployed on the SPA (45 CSP errors per
  page load in the prior audit).
- frontend/package.json (+ package-lock.json): add an `overrides`
  block pinning `dompurify@^3.4.3` to remediate the transitive
  vulnerability advisory.
- frontend/src/features/dashboard/DashboardPage.tsx: wire the
  time-series chart to real `getEnvActivity` data instead of the
  placeholder constants. Added a 7d / 24h interactive toggle and an
  `aggregateBuckets` helper that collapses the 15-min server buckets
  into N display bins.

== Dev stack ==

deploy/docker/conf/osquery/entrypoint.sh: pin
`--host_identifier=specified --specified_identifier=$(hostname)` so
the three dev osquery containers enroll as distinct nodes instead of
colliding on the host kernel UUID. The full Kali docker stack now
enrolls four unique nodes (the Kali host + three containers).

== Toolchain ==

Bump Go from 1.26.1 to 1.26.3 across go.mod, the four
deploy/docker/dockerfiles/Dockerfile-dev-*, deploy/lib.sh,
.env.example, the .github/actions/{build,test}/binaries action
manifests, and the five .github/workflows/*.yml. 1.26.3 carries the
stdlib CVE fixes flagged by the local govulncheck run.

Verified locally:
- go build ./...  (clean on Go 1.26.3)
- go test  ./...  (all packages green, including new CreateUserManager
  / WithJWT tests)
- frontend: vitest 92/92 green; npm run build clean; npm audit
  --omit=dev → 0 vulnerabilities
- Live smoke on the Kali dev stack: container rebuild + recreate;
  osctrl-cli runs `env show`, `node-actions secret`, `show-flags`
  without the old JWT-too-short fatal; osctrl-api signs a real login
  token through the SPA; the four enrolled osquery nodes remain in
  the DB.
alvarofraguas pushed a commit to alvarofraguas/osctrl that referenced this pull request May 14, 2026
Consolidated follow-up that lands on top of the three stacked PRs to
address lint, a real bug uncovered by lint, a stronger JWT-secret
contract, and a few deployment-correctness items.

== Lint cleanup (golangci-lint on PR jmpsec#815) ==

- pkg/auditlog/audit.go, pkg/dbutil/buckets.go: drop the redundant
  `.Dialector` selector on `*gorm.DB` (QF1008). `Dialector` is an
  embedded interface so the promoted `Name()` works directly.
- cmd/api/handlers/utils.go: remove the unused `postgresQueryLogs`
  function (unused). Pre-existing dead code that surfaced once the
  package was touched by other PRs in the stack.
- cmd/admin/handlers/json-nodes.go: annotate the two legacy admin
  callers of `Nodes.SearchByEnvPage` / `Nodes.GetByEnvPage` with
  `//nolint:staticcheck // SA1019: intentional legacy admin caller;
  new SPA uses GetByEnvPaged`. The deprecation tag is correct — the
  legacy admin will migrate to `GetByEnvPaged` when it adopts the
  SPA's pagination shape; until then these calls are gated by the
  package-layer `SortableColumns` allowlist and are safe.

== Real bug uncovered by ineffassign ==

cmd/api/handlers/environments.go: in the `"create"` action, the
tag-creation failure path set `msgReturn = fmt.Sprintf("error
generating tag %s ", err.Error())` and then `return`-ed without ever
writing to the response. Result: the API returned the request body's
buffered HTTP 200 (or no body at all) on a real failure, masking the
error from the client. Replaced with a proper `apiErrorResponse(w,
"error generating tag", http.StatusInternalServerError, err)`.

== JWT secret contract: decouple user-manager construction from
   token-signing config ==

Round 1 added `MinJWTSecretBytes = 32` to `users.CreateUserManager`,
which `log.Fatal`s when the JWT secret is shorter than 32 bytes. This
was correct for the API and admin services (they sign tokens) but
caught the CLI (`cmd/cli`) by surprise — the CLI doesn't mint JWTs at
all, it just manages user/permission rows directly, and was passing
`appName` ("osctrl", 6 bytes) as a placeholder. Every CLI invocation
with `--db` would have aborted with "JWT Secret too short" once
Round 1 lands.

Fix: split the constructor.

  // DB-only constructor — never validates JWT.
  func CreateUserManager(backend *gorm.DB) *UserManager

  // Attach JWT signing config; validates the secret here.
  func (u *UserManager) WithJWT(*config.YAMLConfigurationJWT) *UserManager

Token-issuing callers chain:

  apiUsers   = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)
  adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)

The CLI calls only `CreateUserManager(db.Conn)`. `CreateToken` now
returns an error if invoked on a manager without `WithJWT` —
defense-in-depth so a future caller can't accidentally sign tokens
with a nil config.

== TLS handler: per-endpoint body caps ==

cmd/tls/handlers/post.go: wrap every osquery-facing `io.ReadAll` in
`http.MaxBytesReader` with an endpoint-appropriate ceiling. Without
this, a misbehaving or hostile node can submit an arbitrarily large
body and force the server to buffer it before parsing. Caps are
chosen against real osquery payload sizes:

  enroll:           64 KiB
  config:           64 KiB
  log:             100 MiB   (osquery log batches)
  query read:       16 KiB
  query write:     100 MiB
  carve init:        8 KiB
  carve block:      16 MiB
  quick-enroll:      8 KiB
  flags / cert:      8 KiB
  verify / script:   8 KiB
  osquery config:    2 MiB

A single `readBody(w, r, max)` helper applies the cap and reads, so
the call sites stay one-line.

== Carve compression: out-of-bounds panic guard ==

pkg/carves/utils.go: `CheckCompressionRaw` previously dereferenced
`data[:4]` to compare against the zstd header. A truncated or empty
block (which an authenticated CarveLevel client can submit) would
panic with index-out-of-range. Guarded with a length check.

== Frontend (PR jmpsec#815 scope) ==

- frontend/index.html: removed the inline `<script>` that bootstraps
  the theme attribute and moved it to
  `frontend/public/theme-bootstrap.js`. The inline form violated the
  CSP `script-src 'self' blob:` deployed on the SPA (45 CSP errors per
  page load in the prior audit).
- frontend/package.json (+ package-lock.json): add an `overrides`
  block pinning `dompurify@^3.4.3` to remediate the transitive
  vulnerability advisory.
- frontend/src/features/dashboard/DashboardPage.tsx: wire the
  time-series chart to real `getEnvActivity` data instead of the
  placeholder constants. Added a 7d / 24h interactive toggle and an
  `aggregateBuckets` helper that collapses the 15-min server buckets
  into N display bins.

== Dev stack ==

deploy/docker/conf/osquery/entrypoint.sh: pin
`--host_identifier=specified --specified_identifier=$(hostname)` so
the three dev osquery containers enroll as distinct nodes instead of
colliding on the host kernel UUID. The full Kali docker stack now
enrolls four unique nodes (the Kali host + three containers).

== Toolchain ==

Bump Go from 1.26.1 to 1.26.3 across go.mod, the four
deploy/docker/dockerfiles/Dockerfile-dev-*, deploy/lib.sh,
.env.example, the .github/actions/{build,test}/binaries action
manifests, and the five .github/workflows/*.yml. 1.26.3 carries the
stdlib CVE fixes flagged by the local govulncheck run.

Verified locally:
- go build ./...  (clean on Go 1.26.3)
- go test  ./...  (all packages green, including new CreateUserManager
  / WithJWT tests)
- frontend: vitest 92/92 green; npm run build clean; npm audit
  --omit=dev → 0 vulnerabilities
- Live smoke on the Kali dev stack: container rebuild + recreate;
  osctrl-cli runs `env show`, `node-actions secret`, `show-flags`
  without the old JWT-too-short fatal; osctrl-api signs a real login
  token through the SPA; the four enrolled osquery nodes remain in
  the DB.
@alvarofraguas alvarofraguas force-pushed the pr/round-3-frontend branch from 4235626 to 6c0584b Compare May 14, 2026 08:45
alvarofraguas pushed a commit to alvarofraguas/osctrl that referenced this pull request May 14, 2026
Consolidated follow-up that lands on top of the three stacked PRs to
address lint, a real bug uncovered by lint, a stronger JWT-secret
contract, and a few deployment-correctness items.

== Lint cleanup (golangci-lint on PR jmpsec#815) ==

- pkg/auditlog/audit.go, pkg/dbutil/buckets.go: drop the redundant
  `.Dialector` selector on `*gorm.DB` (QF1008). `Dialector` is an
  embedded interface so the promoted `Name()` works directly.
- cmd/api/handlers/utils.go: remove the unused `postgresQueryLogs`
  function (unused). Pre-existing dead code that surfaced once the
  package was touched by other PRs in the stack.
- cmd/admin/handlers/json-nodes.go: annotate the two legacy admin
  callers of `Nodes.SearchByEnvPage` / `Nodes.GetByEnvPage` with
  `//nolint:staticcheck // SA1019: intentional legacy admin caller;
  new SPA uses GetByEnvPaged`. The deprecation tag is correct — the
  legacy admin will migrate to `GetByEnvPaged` when it adopts the
  SPA's pagination shape; until then these calls are gated by the
  package-layer `SortableColumns` allowlist and are safe.

== Real bug uncovered by ineffassign ==

cmd/api/handlers/environments.go: in the `"create"` action, the
tag-creation failure path set `msgReturn = fmt.Sprintf("error
generating tag %s ", err.Error())` and then `return`-ed without ever
writing to the response. Result: the API returned the request body's
buffered HTTP 200 (or no body at all) on a real failure, masking the
error from the client. Replaced with a proper `apiErrorResponse(w,
"error generating tag", http.StatusInternalServerError, err)`.

== JWT secret contract: decouple user-manager construction from
   token-signing config ==

Round 1 added `MinJWTSecretBytes = 32` to `users.CreateUserManager`,
which `log.Fatal`s when the JWT secret is shorter than 32 bytes. This
was correct for the API and admin services (they sign tokens) but
caught the CLI (`cmd/cli`) by surprise — the CLI doesn't mint JWTs at
all, it just manages user/permission rows directly, and was passing
`appName` ("osctrl", 6 bytes) as a placeholder. Every CLI invocation
with `--db` would have aborted with "JWT Secret too short" once
Round 1 lands.

Fix: split the constructor.

  // DB-only constructor — never validates JWT.
  func CreateUserManager(backend *gorm.DB) *UserManager

  // Attach JWT signing config; validates the secret here.
  func (u *UserManager) WithJWT(*config.YAMLConfigurationJWT) *UserManager

Token-issuing callers chain:

  apiUsers   = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)
  adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)

The CLI calls only `CreateUserManager(db.Conn)`. `CreateToken` now
returns an error if invoked on a manager without `WithJWT` —
defense-in-depth so a future caller can't accidentally sign tokens
with a nil config.

== TLS handler: per-endpoint body caps ==

cmd/tls/handlers/post.go: wrap every osquery-facing `io.ReadAll` in
`http.MaxBytesReader` with an endpoint-appropriate ceiling. Without
this, a misbehaving or hostile node can submit an arbitrarily large
body and force the server to buffer it before parsing. Caps are
chosen against real osquery payload sizes:

  enroll:           64 KiB
  config:           64 KiB
  log:             100 MiB   (osquery log batches)
  query read:       16 KiB
  query write:     100 MiB
  carve init:        8 KiB
  carve block:      16 MiB
  quick-enroll:      8 KiB
  flags / cert:      8 KiB
  verify / script:   8 KiB
  osquery config:    2 MiB

A single `readBody(w, r, max)` helper applies the cap and reads, so
the call sites stay one-line.

== Carve compression: out-of-bounds panic guard ==

pkg/carves/utils.go: `CheckCompressionRaw` previously dereferenced
`data[:4]` to compare against the zstd header. A truncated or empty
block (which an authenticated CarveLevel client can submit) would
panic with index-out-of-range. Guarded with a length check.

== Frontend (PR jmpsec#815 scope) ==

- frontend/index.html: removed the inline `<script>` that bootstraps
  the theme attribute and moved it to
  `frontend/public/theme-bootstrap.js`. The inline form violated the
  CSP `script-src 'self' blob:` deployed on the SPA (45 CSP errors per
  page load in the prior audit).
- frontend/package.json (+ package-lock.json): add an `overrides`
  block pinning `dompurify@^3.4.3` to remediate the transitive
  vulnerability advisory.
- frontend/src/features/dashboard/DashboardPage.tsx: wire the
  time-series chart to real `getEnvActivity` data instead of the
  placeholder constants. Added a 7d / 24h interactive toggle and an
  `aggregateBuckets` helper that collapses the 15-min server buckets
  into N display bins.

== Dev stack ==

deploy/docker/conf/osquery/entrypoint.sh: pin
`--host_identifier=specified --specified_identifier=$(hostname)` so
the three dev osquery containers enroll as distinct nodes instead of
colliding on the host kernel UUID. The full Kali docker stack now
enrolls four unique nodes (the Kali host + three containers).

== Toolchain ==

Bump Go from 1.26.1 to 1.26.3 across go.mod, the four
deploy/docker/dockerfiles/Dockerfile-dev-*, deploy/lib.sh,
.env.example, the .github/actions/{build,test}/binaries action
manifests, and the five .github/workflows/*.yml. 1.26.3 carries the
stdlib CVE fixes flagged by the local govulncheck run.

Verified locally:
- go build ./...  (clean on Go 1.26.3)
- go test  ./...  (all packages green, including new CreateUserManager
  / WithJWT tests)
- frontend: vitest 92/92 green; npm run build clean; npm audit
  --omit=dev → 0 vulnerabilities
- Live smoke on the Kali dev stack: container rebuild + recreate;
  osctrl-cli runs `env show`, `node-actions secret`, `show-flags`
  without the old JWT-too-short fatal; osctrl-api signs a real login
  token through the SPA; the four enrolled osquery nodes remain in
  the DB.
@alvarofraguas alvarofraguas force-pushed the pr/round-3-frontend branch from 6c0584b to 36f4e89 Compare May 14, 2026 17:04
alvarofraguas added a commit to alvarofraguas/osctrl that referenced this pull request May 14, 2026
Consolidated follow-up that lands on top of the three stacked PRs to
address lint, a real bug uncovered by lint, a stronger JWT-secret
contract, and a few deployment-correctness items.

== Lint cleanup (golangci-lint on PR jmpsec#815) ==

- pkg/auditlog/audit.go, pkg/dbutil/buckets.go: drop the redundant
  `.Dialector` selector on `*gorm.DB` (QF1008). `Dialector` is an
  embedded interface so the promoted `Name()` works directly.
- cmd/api/handlers/utils.go: remove the unused `postgresQueryLogs`
  function (unused). Pre-existing dead code that surfaced once the
  package was touched by other PRs in the stack.
- cmd/admin/handlers/json-nodes.go: annotate the two legacy admin
  callers of `Nodes.SearchByEnvPage` / `Nodes.GetByEnvPage` with
  `//nolint:staticcheck // SA1019: intentional legacy admin caller;
  new SPA uses GetByEnvPaged`. The deprecation tag is correct — the
  legacy admin will migrate to `GetByEnvPaged` when it adopts the
  SPA's pagination shape; until then these calls are gated by the
  package-layer `SortableColumns` allowlist and are safe.

== Real bug uncovered by ineffassign ==

cmd/api/handlers/environments.go: in the `"create"` action, the
tag-creation failure path set `msgReturn = fmt.Sprintf("error
generating tag %s ", err.Error())` and then `return`-ed without ever
writing to the response. Result: the API returned the request body's
buffered HTTP 200 (or no body at all) on a real failure, masking the
error from the client. Replaced with a proper `apiErrorResponse(w,
"error generating tag", http.StatusInternalServerError, err)`.

== JWT secret contract: decouple user-manager construction from
   token-signing config ==

Round 1 added `MinJWTSecretBytes = 32` to `users.CreateUserManager`,
which `log.Fatal`s when the JWT secret is shorter than 32 bytes. This
was correct for the API and admin services (they sign tokens) but
caught the CLI (`cmd/cli`) by surprise — the CLI doesn't mint JWTs at
all, it just manages user/permission rows directly, and was passing
`appName` ("osctrl", 6 bytes) as a placeholder. Every CLI invocation
with `--db` would have aborted with "JWT Secret too short" once
Round 1 lands.

Fix: split the constructor.

  // DB-only constructor — never validates JWT.
  func CreateUserManager(backend *gorm.DB) *UserManager

  // Attach JWT signing config; validates the secret here.
  func (u *UserManager) WithJWT(*config.YAMLConfigurationJWT) *UserManager

Token-issuing callers chain:

  apiUsers   = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)
  adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)

The CLI calls only `CreateUserManager(db.Conn)`. `CreateToken` now
returns an error if invoked on a manager without `WithJWT` —
defense-in-depth so a future caller can't accidentally sign tokens
with a nil config.

== TLS handler: per-endpoint body caps ==

cmd/tls/handlers/post.go: wrap every osquery-facing `io.ReadAll` in
`http.MaxBytesReader` with an endpoint-appropriate ceiling. Without
this, a misbehaving or hostile node can submit an arbitrarily large
body and force the server to buffer it before parsing. Caps are
chosen against real osquery payload sizes:

  enroll:           64 KiB
  config:           64 KiB
  log:             100 MiB   (osquery log batches)
  query read:       16 KiB
  query write:     100 MiB
  carve init:        8 KiB
  carve block:      16 MiB
  quick-enroll:      8 KiB
  flags / cert:      8 KiB
  verify / script:   8 KiB
  osquery config:    2 MiB

A single `readBody(w, r, max)` helper applies the cap and reads, so
the call sites stay one-line.

== Carve compression: out-of-bounds panic guard ==

pkg/carves/utils.go: `CheckCompressionRaw` previously dereferenced
`data[:4]` to compare against the zstd header. A truncated or empty
block (which an authenticated CarveLevel client can submit) would
panic with index-out-of-range. Guarded with a length check.

== Frontend (PR jmpsec#815 scope) ==

- frontend/index.html: removed the inline `<script>` that bootstraps
  the theme attribute and moved it to
  `frontend/public/theme-bootstrap.js`. The inline form violated the
  CSP `script-src 'self' blob:` deployed on the SPA (45 CSP errors per
  page load in the prior audit).
- frontend/package.json (+ package-lock.json): add an `overrides`
  block pinning `dompurify@^3.4.3` to remediate the transitive
  vulnerability advisory.
- frontend/src/features/dashboard/DashboardPage.tsx: wire the
  time-series chart to real `getEnvActivity` data instead of the
  placeholder constants. Added a 7d / 24h interactive toggle and an
  `aggregateBuckets` helper that collapses the 15-min server buckets
  into N display bins.

== Dev stack ==

deploy/docker/conf/osquery/entrypoint.sh: pin
`--host_identifier=specified --specified_identifier=$(hostname)` so
the three dev osquery containers enroll as distinct nodes instead of
colliding on the host kernel UUID. The full Kali docker stack now
enrolls four unique nodes (the Kali host + three containers).

== Toolchain ==

Bump Go from 1.26.1 to 1.26.3 across go.mod, the four
deploy/docker/dockerfiles/Dockerfile-dev-*, deploy/lib.sh,
.env.example, the .github/actions/{build,test}/binaries action
manifests, and the five .github/workflows/*.yml. 1.26.3 carries the
stdlib CVE fixes flagged by the local govulncheck run.

Verified locally:
- go build ./...  (clean on Go 1.26.3)
- go test  ./...  (all packages green, including new CreateUserManager
  / WithJWT tests)
- frontend: vitest 92/92 green; npm run build clean; npm audit
  --omit=dev → 0 vulnerabilities
- Live smoke on the Kali dev stack: container rebuild + recreate;
  osctrl-cli runs `env show`, `node-actions secret`, `show-flags`
  without the old JWT-too-short fatal; osctrl-api signs a real login
  token through the SPA; the four enrolled osquery nodes remain in
  the DB.
@alvarofraguas alvarofraguas force-pushed the pr/round-3-frontend branch from 36f4e89 to 8189434 Compare May 14, 2026 17:08
alvarofraguas added a commit to alvarofraguas/osctrl that referenced this pull request May 14, 2026
Consolidated follow-up that lands on top of the three stacked PRs to
address lint, a real bug uncovered by lint, a stronger JWT-secret
contract, and a few deployment-correctness items.

== Lint cleanup (golangci-lint on PR jmpsec#815) ==

- pkg/auditlog/audit.go, pkg/dbutil/buckets.go: drop the redundant
  `.Dialector` selector on `*gorm.DB` (QF1008). `Dialector` is an
  embedded interface so the promoted `Name()` works directly.
- cmd/api/handlers/utils.go: remove the unused `postgresQueryLogs`
  function (unused). Pre-existing dead code that surfaced once the
  package was touched by other PRs in the stack.
- cmd/admin/handlers/json-nodes.go: annotate the two legacy admin
  callers of `Nodes.SearchByEnvPage` / `Nodes.GetByEnvPage` with
  `//nolint:staticcheck // SA1019: intentional legacy admin caller;
  new SPA uses GetByEnvPaged`. The deprecation tag is correct — the
  legacy admin will migrate to `GetByEnvPaged` when it adopts the
  SPA's pagination shape; until then these calls are gated by the
  package-layer `SortableColumns` allowlist and are safe.

== Real bug uncovered by ineffassign ==

cmd/api/handlers/environments.go: in the `"create"` action, the
tag-creation failure path set `msgReturn = fmt.Sprintf("error
generating tag %s ", err.Error())` and then `return`-ed without ever
writing to the response. Result: the API returned the request body's
buffered HTTP 200 (or no body at all) on a real failure, masking the
error from the client. Replaced with a proper `apiErrorResponse(w,
"error generating tag", http.StatusInternalServerError, err)`.

== JWT secret contract: decouple user-manager construction from
   token-signing config ==

Round 1 added `MinJWTSecretBytes = 32` to `users.CreateUserManager`,
which `log.Fatal`s when the JWT secret is shorter than 32 bytes. This
was correct for the API and admin services (they sign tokens) but
caught the CLI (`cmd/cli`) by surprise — the CLI doesn't mint JWTs at
all, it just manages user/permission rows directly, and was passing
`appName` ("osctrl", 6 bytes) as a placeholder. Every CLI invocation
with `--db` would have aborted with "JWT Secret too short" once
Round 1 lands.

Fix: split the constructor.

  // DB-only constructor — never validates JWT.
  func CreateUserManager(backend *gorm.DB) *UserManager

  // Attach JWT signing config; validates the secret here.
  func (u *UserManager) WithJWT(*config.YAMLConfigurationJWT) *UserManager

Token-issuing callers chain:

  apiUsers   = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)
  adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)

The CLI calls only `CreateUserManager(db.Conn)`. `CreateToken` now
returns an error if invoked on a manager without `WithJWT` —
defense-in-depth so a future caller can't accidentally sign tokens
with a nil config.

== TLS handler: per-endpoint body caps ==

cmd/tls/handlers/post.go: wrap every osquery-facing `io.ReadAll` in
`http.MaxBytesReader` with an endpoint-appropriate ceiling. Without
this, a misbehaving or hostile node can submit an arbitrarily large
body and force the server to buffer it before parsing. Caps are
chosen against real osquery payload sizes:

  enroll:           64 KiB
  config:           64 KiB
  log:             100 MiB   (osquery log batches)
  query read:       16 KiB
  query write:     100 MiB
  carve init:        8 KiB
  carve block:      16 MiB
  quick-enroll:      8 KiB
  flags / cert:      8 KiB
  verify / script:   8 KiB
  osquery config:    2 MiB

A single `readBody(w, r, max)` helper applies the cap and reads, so
the call sites stay one-line.

== Carve compression: out-of-bounds panic guard ==

pkg/carves/utils.go: `CheckCompressionRaw` previously dereferenced
`data[:4]` to compare against the zstd header. A truncated or empty
block (which an authenticated CarveLevel client can submit) would
panic with index-out-of-range. Guarded with a length check.

== Frontend (PR jmpsec#815 scope) ==

- frontend/index.html: removed the inline `<script>` that bootstraps
  the theme attribute and moved it to
  `frontend/public/theme-bootstrap.js`. The inline form violated the
  CSP `script-src 'self' blob:` deployed on the SPA (45 CSP errors per
  page load in the prior audit).
- frontend/package.json (+ package-lock.json): add an `overrides`
  block pinning `dompurify@^3.4.3` to remediate the transitive
  vulnerability advisory.
- frontend/src/features/dashboard/DashboardPage.tsx: wire the
  time-series chart to real `getEnvActivity` data instead of the
  placeholder constants. Added a 7d / 24h interactive toggle and an
  `aggregateBuckets` helper that collapses the 15-min server buckets
  into N display bins.

== Dev stack ==

deploy/docker/conf/osquery/entrypoint.sh: pin
`--host_identifier=specified --specified_identifier=$(hostname)` so
the three dev osquery containers enroll as distinct nodes instead of
colliding on the host kernel UUID. The full Kali docker stack now
enrolls four unique nodes (the Kali host + three containers).

== Toolchain ==

Bump Go from 1.26.1 to 1.26.3 across go.mod, the four
deploy/docker/dockerfiles/Dockerfile-dev-*, deploy/lib.sh,
.env.example, the .github/actions/{build,test}/binaries action
manifests, and the five .github/workflows/*.yml. 1.26.3 carries the
stdlib CVE fixes flagged by the local govulncheck run.

Verified locally:
- go build ./...  (clean on Go 1.26.3)
- go test  ./...  (all packages green, including new CreateUserManager
  / WithJWT tests)
- frontend: vitest 92/92 green; npm run build clean; npm audit
  --omit=dev → 0 vulnerabilities
- Live smoke on the Kali dev stack: container rebuild + recreate;
  osctrl-cli runs `env show`, `node-actions secret`, `show-flags`
  without the old JWT-too-short fatal; osctrl-api signs a real login
  token through the SPA; the four enrolled osquery nodes remain in
  the DB.
@alvarofraguas alvarofraguas force-pushed the pr/round-3-frontend branch from 8189434 to 2989a56 Compare May 14, 2026 17:19
alvarofraguas added a commit to alvarofraguas/osctrl that referenced this pull request May 14, 2026
Consolidated follow-up that lands on top of the three stacked PRs to
address lint, a real bug uncovered by lint, a stronger JWT-secret
contract, and a few deployment-correctness items.

== Lint cleanup (golangci-lint on PR jmpsec#815) ==

- pkg/auditlog/audit.go, pkg/dbutil/buckets.go: drop the redundant
  `.Dialector` selector on `*gorm.DB` (QF1008). `Dialector` is an
  embedded interface so the promoted `Name()` works directly.
- cmd/api/handlers/utils.go: remove the unused `postgresQueryLogs`
  function (unused). Pre-existing dead code that surfaced once the
  package was touched by other PRs in the stack.
- cmd/admin/handlers/json-nodes.go: annotate the two legacy admin
  callers of `Nodes.SearchByEnvPage` / `Nodes.GetByEnvPage` with
  `//nolint:staticcheck // SA1019: intentional legacy admin caller;
  new SPA uses GetByEnvPaged`. The deprecation tag is correct — the
  legacy admin will migrate to `GetByEnvPaged` when it adopts the
  SPA's pagination shape; until then these calls are gated by the
  package-layer `SortableColumns` allowlist and are safe.

== Real bug uncovered by ineffassign ==

cmd/api/handlers/environments.go: in the `"create"` action, the
tag-creation failure path set `msgReturn = fmt.Sprintf("error
generating tag %s ", err.Error())` and then `return`-ed without ever
writing to the response. Result: the API returned the request body's
buffered HTTP 200 (or no body at all) on a real failure, masking the
error from the client. Replaced with a proper `apiErrorResponse(w,
"error generating tag", http.StatusInternalServerError, err)`.

== JWT secret contract: decouple user-manager construction from
   token-signing config ==

Round 1 added `MinJWTSecretBytes = 32` to `users.CreateUserManager`,
which `log.Fatal`s when the JWT secret is shorter than 32 bytes. This
was correct for the API and admin services (they sign tokens) but
caught the CLI (`cmd/cli`) by surprise — the CLI doesn't mint JWTs at
all, it just manages user/permission rows directly, and was passing
`appName` ("osctrl", 6 bytes) as a placeholder. Every CLI invocation
with `--db` would have aborted with "JWT Secret too short" once
Round 1 lands.

Fix: split the constructor.

  // DB-only constructor — never validates JWT.
  func CreateUserManager(backend *gorm.DB) *UserManager

  // Attach JWT signing config; validates the secret here.
  func (u *UserManager) WithJWT(*config.YAMLConfigurationJWT) *UserManager

Token-issuing callers chain:

  apiUsers   = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)
  adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)

The CLI calls only `CreateUserManager(db.Conn)`. `CreateToken` now
returns an error if invoked on a manager without `WithJWT` —
defense-in-depth so a future caller can't accidentally sign tokens
with a nil config.

== TLS handler: per-endpoint body caps ==

cmd/tls/handlers/post.go: wrap every osquery-facing `io.ReadAll` in
`http.MaxBytesReader` with an endpoint-appropriate ceiling. Without
this, a misbehaving or hostile node can submit an arbitrarily large
body and force the server to buffer it before parsing. Caps are
chosen against real osquery payload sizes:

  enroll:           64 KiB
  config:           64 KiB
  log:             100 MiB   (osquery log batches)
  query read:       16 KiB
  query write:     100 MiB
  carve init:        8 KiB
  carve block:      16 MiB
  quick-enroll:      8 KiB
  flags / cert:      8 KiB
  verify / script:   8 KiB
  osquery config:    2 MiB

A single `readBody(w, r, max)` helper applies the cap and reads, so
the call sites stay one-line.

== Carve compression: out-of-bounds panic guard ==

pkg/carves/utils.go: `CheckCompressionRaw` previously dereferenced
`data[:4]` to compare against the zstd header. A truncated or empty
block (which an authenticated CarveLevel client can submit) would
panic with index-out-of-range. Guarded with a length check.

== Frontend (PR jmpsec#815 scope) ==

- frontend/index.html: removed the inline `<script>` that bootstraps
  the theme attribute and moved it to
  `frontend/public/theme-bootstrap.js`. The inline form violated the
  CSP `script-src 'self' blob:` deployed on the SPA (45 CSP errors per
  page load in the prior audit).
- frontend/package.json (+ package-lock.json): add an `overrides`
  block pinning `dompurify@^3.4.3` to remediate the transitive
  vulnerability advisory.
- frontend/src/features/dashboard/DashboardPage.tsx: wire the
  time-series chart to real `getEnvActivity` data instead of the
  placeholder constants. Added a 7d / 24h interactive toggle and an
  `aggregateBuckets` helper that collapses the 15-min server buckets
  into N display bins.

== Dev stack ==

deploy/docker/conf/osquery/entrypoint.sh: pin
`--host_identifier=specified --specified_identifier=$(hostname)` so
the three dev osquery containers enroll as distinct nodes instead of
colliding on the host kernel UUID. The full Kali docker stack now
enrolls four unique nodes (the Kali host + three containers).

== Toolchain ==

Bump Go from 1.26.1 to 1.26.3 across go.mod, the four
deploy/docker/dockerfiles/Dockerfile-dev-*, deploy/lib.sh,
.env.example, the .github/actions/{build,test}/binaries action
manifests, and the five .github/workflows/*.yml. 1.26.3 carries the
stdlib CVE fixes flagged by the local govulncheck run.

Verified locally:
- go build ./...  (clean on Go 1.26.3)
- go test  ./...  (all packages green, including new CreateUserManager
  / WithJWT tests)
- frontend: vitest 92/92 green; npm run build clean; npm audit
  --omit=dev → 0 vulnerabilities
- Live smoke on the Kali dev stack: container rebuild + recreate;
  osctrl-cli runs `env show`, `node-actions secret`, `show-flags`
  without the old JWT-too-short fatal; osctrl-api signs a real login
  token through the SPA; the four enrolled osquery nodes remain in
  the DB.
@alvarofraguas alvarofraguas force-pushed the pr/round-3-frontend branch from 2989a56 to a02a826 Compare May 14, 2026 17:39
alvarofraguas added a commit to alvarofraguas/osctrl that referenced this pull request May 15, 2026
Consolidated follow-up that lands on top of the three stacked PRs to
address lint, a real bug uncovered by lint, a stronger JWT-secret
contract, and a few deployment-correctness items.

== Lint cleanup (golangci-lint on PR jmpsec#815) ==

- pkg/auditlog/audit.go, pkg/dbutil/buckets.go: drop the redundant
  `.Dialector` selector on `*gorm.DB` (QF1008). `Dialector` is an
  embedded interface so the promoted `Name()` works directly.
- cmd/api/handlers/utils.go: remove the unused `postgresQueryLogs`
  function (unused). Pre-existing dead code that surfaced once the
  package was touched by other PRs in the stack.
- cmd/admin/handlers/json-nodes.go: annotate the two legacy admin
  callers of `Nodes.SearchByEnvPage` / `Nodes.GetByEnvPage` with
  `//nolint:staticcheck // SA1019: intentional legacy admin caller;
  new SPA uses GetByEnvPaged`. The deprecation tag is correct — the
  legacy admin will migrate to `GetByEnvPaged` when it adopts the
  SPA's pagination shape; until then these calls are gated by the
  package-layer `SortableColumns` allowlist and are safe.

== Real bug uncovered by ineffassign ==

cmd/api/handlers/environments.go: in the `"create"` action, the
tag-creation failure path set `msgReturn = fmt.Sprintf("error
generating tag %s ", err.Error())` and then `return`-ed without ever
writing to the response. Result: the API returned the request body's
buffered HTTP 200 (or no body at all) on a real failure, masking the
error from the client. Replaced with a proper `apiErrorResponse(w,
"error generating tag", http.StatusInternalServerError, err)`.

== JWT secret contract: decouple user-manager construction from
   token-signing config ==

Round 1 added `MinJWTSecretBytes = 32` to `users.CreateUserManager`,
which `log.Fatal`s when the JWT secret is shorter than 32 bytes. This
was correct for the API and admin services (they sign tokens) but
caught the CLI (`cmd/cli`) by surprise — the CLI doesn't mint JWTs at
all, it just manages user/permission rows directly, and was passing
`appName` ("osctrl", 6 bytes) as a placeholder. Every CLI invocation
with `--db` would have aborted with "JWT Secret too short" once
Round 1 lands.

Fix: split the constructor.

  // DB-only constructor — never validates JWT.
  func CreateUserManager(backend *gorm.DB) *UserManager

  // Attach JWT signing config; validates the secret here.
  func (u *UserManager) WithJWT(*config.YAMLConfigurationJWT) *UserManager

Token-issuing callers chain:

  apiUsers   = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)
  adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)

The CLI calls only `CreateUserManager(db.Conn)`. `CreateToken` now
returns an error if invoked on a manager without `WithJWT` —
defense-in-depth so a future caller can't accidentally sign tokens
with a nil config.

== TLS handler: per-endpoint body caps ==

cmd/tls/handlers/post.go: wrap every osquery-facing `io.ReadAll` in
`http.MaxBytesReader` with an endpoint-appropriate ceiling. Without
this, a misbehaving or hostile node can submit an arbitrarily large
body and force the server to buffer it before parsing. Caps are
chosen against real osquery payload sizes:

  enroll:           64 KiB
  config:           64 KiB
  log:             100 MiB   (osquery log batches)
  query read:       16 KiB
  query write:     100 MiB
  carve init:        8 KiB
  carve block:      16 MiB
  quick-enroll:      8 KiB
  flags / cert:      8 KiB
  verify / script:   8 KiB
  osquery config:    2 MiB

A single `readBody(w, r, max)` helper applies the cap and reads, so
the call sites stay one-line.

== Carve compression: out-of-bounds panic guard ==

pkg/carves/utils.go: `CheckCompressionRaw` previously dereferenced
`data[:4]` to compare against the zstd header. A truncated or empty
block (which an authenticated CarveLevel client can submit) would
panic with index-out-of-range. Guarded with a length check.

== Frontend (PR jmpsec#815 scope) ==

- frontend/index.html: removed the inline `<script>` that bootstraps
  the theme attribute and moved it to
  `frontend/public/theme-bootstrap.js`. The inline form violated the
  CSP `script-src 'self' blob:` deployed on the SPA (45 CSP errors per
  page load in the prior audit).
- frontend/package.json (+ package-lock.json): add an `overrides`
  block pinning `dompurify@^3.4.3` to remediate the transitive
  vulnerability advisory.
- frontend/src/features/dashboard/DashboardPage.tsx: wire the
  time-series chart to real `getEnvActivity` data instead of the
  placeholder constants. Added a 7d / 24h interactive toggle and an
  `aggregateBuckets` helper that collapses the 15-min server buckets
  into N display bins.

== Dev stack ==

deploy/docker/conf/osquery/entrypoint.sh: pin
`--host_identifier=specified --specified_identifier=$(hostname)` so
the three dev osquery containers enroll as distinct nodes instead of
colliding on the host kernel UUID. The full Kali docker stack now
enrolls four unique nodes (the Kali host + three containers).

== Toolchain ==

Bump Go from 1.26.1 to 1.26.3 across go.mod, the four
deploy/docker/dockerfiles/Dockerfile-dev-*, deploy/lib.sh,
.env.example, the .github/actions/{build,test}/binaries action
manifests, and the five .github/workflows/*.yml. 1.26.3 carries the
stdlib CVE fixes flagged by the local govulncheck run.

Verified locally:
- go build ./...  (clean on Go 1.26.3)
- go test  ./...  (all packages green, including new CreateUserManager
  / WithJWT tests)
- frontend: vitest 92/92 green; npm run build clean; npm audit
  --omit=dev → 0 vulnerabilities
- Live smoke on the Kali dev stack: container rebuild + recreate;
  osctrl-cli runs `env show`, `node-actions secret`, `show-flags`
  without the old JWT-too-short fatal; osctrl-api signs a real login
  token through the SPA; the four enrolled osquery nodes remain in
  the DB.
@alvarofraguas alvarofraguas force-pushed the pr/round-3-frontend branch from a02a826 to 73dbfc7 Compare May 15, 2026 15:09
Round 3 of 3. Lands the React + TypeScript + Vite SPA under a new
`frontend/` directory at the repo root. The SPA is fully separable from
the legacy `osctrl-admin` templates — both can run side-by-side during a
migration window, and the legacy admin is not touched by this PR.

== Tech stack ==

- React 19 + TypeScript 5 (strict)
- Vite 7 (build), @tailwindcss/vite (styling), Tailwind CSS v4
- TanStack Router (typed file-based routing)
- TanStack Query 5 (server state, polling + cache)
- TanStack Table 8 (headless tables)
- react-hook-form 7 + zod 3 (forms + validation)
- Radix UI primitives (à la carte, unstyled)
- lucide-react (icons; tree-shaken, no emoji)
- Monaco editor (lazy-loaded for the osquery / config editor)
- Vitest + @testing-library/react + jsdom (component tests)

Bundle: ~780KB JS / ~52KB CSS pre-compression; ~214KB JS + ~9KB CSS
after gzip. Monaco is code-split into its own chunk so the initial
load doesn't pay the editor cost on pages that don't need it.

== Pages (covering parity with the legacy admin) ==

- Login (env picker + creds, pre-auth env list)
- Dashboard (cross-env KPIs, per-env tile, agent-version panel,
  active-queries progress, recently-seen nodes, failed-enroll watch)
- Nodes table (paginated, sortable, searchable; quick-filters; 4×24h
  activity heatmap per row)
- Node detail (system info, status logs, result logs, distributed
  queries, carves, activity tab with interval picker)
- Queries list + run form (target selector, Monaco SQL editor with
  osquery-table autocomplete, expHours)
- Query detail (paginated virtual-scroll results, CSV export,
  search-from-result-cell → SQL-template)
- Saved queries (CRUD)
- Carves list, run form, detail (archive download)
- Tags (env-scoped + global)
- Users (list, permissions modal, token modal)
- Profile (display name, password change, token refresh)
- Environments (list, create, edit) + Monaco-based env config editor
  (options / schedule / packs / decorators / ATC) with DiffView
- Enroll page (per-OS one-liners + downloads)
- Audit log (paginated, filtered)
- Settings (per-service, typed inputs)

== Design system ==

- Custom osctrl tokens (dark default, full light parity, signal-teal
  accent #2bc4be / #0a8a85, semantic status colors with icons not
  color-only).
- Density modes (comfortable / compact / dense) via CSS custom
  properties.
- Tabular nums, Inter + Space Grotesk + IBM Plex Mono.
- Restrained motion (120–220ms transitions, reduced-motion honored).
- Single-accent rule: one signal-teal element active per screen.

== Routing ==

TanStack Router with a file-based tree under `frontend/src/routes/`.
The `_app` segment is the authenticated shell that wraps every page
behind the AppShell (top bar + side nav + env switcher). Login at
`/login/$env` is outside `_app`.

== Auth ==

- HttpOnly cookie session (`osctrl_token`) set by the API on login.
- Double-submit CSRF (`osctrl_csrf` cookie + `X-CSRF-Token` header)
  managed via a thin in-memory token store + request interceptor.
- 401 from any endpoint redirects to `/login/$env?next=...`.

== Deployment ==

Three patterns, in `deploy/`:

1. nginx (recommended): `deploy/nginx/frontend.conf.example` shows
   the production pattern (root + try_files for the SPA, /api/* to
   osctrl-api, baseline security headers, immutable cache for hashed
   assets, no-cache for index.html).
2. Docker (`deploy/docker/dockerfiles/Dockerfile-osctrl-frontend`):
   multi-stage (node:20 → nginx:alpine), single image with the SPA
   pre-built + nginx pre-configured.
3. Static hosting + CDN: ship `frontend/dist/` to S3/Cloudfront/etc.,
   configure CORS on osctrl-api.

The dev compose stack adds an `osctrl-frontend` service that builds
the same multi-stage image and serves on :8088 alongside the legacy
admin on :8443 — operators can compare side-by-side on the same data.

== Make targets ==

- `make frontend-install` — npm ci
- `make frontend-dev` — Vite dev server on :5173 (proxies /api → :8081)
- `make frontend-test` — vitest + tsc
- `make frontend-build` — produces frontend/dist/
- `make frontend` — install + build (CI / Docker shorthand)

== CI ==

`.github/workflows/frontend-build.yml`:
- Pinned action SHAs (matches the existing osctrl convention)
- typecheck + tests + build
- forbid `dangerouslySetInnerHTML` (CI gate — every node-originating
  field must be JSX-escaped; future contributors get a build break
  instead of silent XSS regression)
- Uploads dist/ as a build artifact

== Test plan ==

- [x] `npx tsc --noEmit` — clean
- [x] `npx vitest run` — 19 files, 92 tests pass
- [x] `npm run build` — produces frontend/dist/ cleanly
- [x] Backend untouched: `go build ./...`, `go vet ./...`, all 14
  Go packages' tests still pass
- [x] End-to-end smoke against a Kali docker deployment

== What this depends on ==

Stacked on the previous two PRs:
- Security hardening (auth bedrock, CSRF, env secret containment,
  TLS rate-limit)
- API extensions (paginated lists, stats, saved-queries CRUD,
  user/permissions/tokens, env config PATCHes, audit-log filters)

When those merge, this branch will be re-targeted at the new
main HEAD with no conflicts.
Consolidated follow-up that lands on top of the three stacked PRs to
address lint, a real bug uncovered by lint, a stronger JWT-secret
contract, and a few deployment-correctness items.

== Lint cleanup (golangci-lint on PR jmpsec#815) ==

- pkg/auditlog/audit.go, pkg/dbutil/buckets.go: drop the redundant
  `.Dialector` selector on `*gorm.DB` (QF1008). `Dialector` is an
  embedded interface so the promoted `Name()` works directly.
- cmd/api/handlers/utils.go: remove the unused `postgresQueryLogs`
  function (unused). Pre-existing dead code that surfaced once the
  package was touched by other PRs in the stack.
- cmd/admin/handlers/json-nodes.go: annotate the two legacy admin
  callers of `Nodes.SearchByEnvPage` / `Nodes.GetByEnvPage` with
  `//nolint:staticcheck // SA1019: intentional legacy admin caller;
  new SPA uses GetByEnvPaged`. The deprecation tag is correct — the
  legacy admin will migrate to `GetByEnvPaged` when it adopts the
  SPA's pagination shape; until then these calls are gated by the
  package-layer `SortableColumns` allowlist and are safe.

== Real bug uncovered by ineffassign ==

cmd/api/handlers/environments.go: in the `"create"` action, the
tag-creation failure path set `msgReturn = fmt.Sprintf("error
generating tag %s ", err.Error())` and then `return`-ed without ever
writing to the response. Result: the API returned the request body's
buffered HTTP 200 (or no body at all) on a real failure, masking the
error from the client. Replaced with a proper `apiErrorResponse(w,
"error generating tag", http.StatusInternalServerError, err)`.

== JWT secret contract: decouple user-manager construction from
   token-signing config ==

Round 1 added `MinJWTSecretBytes = 32` to `users.CreateUserManager`,
which `log.Fatal`s when the JWT secret is shorter than 32 bytes. This
was correct for the API and admin services (they sign tokens) but
caught the CLI (`cmd/cli`) by surprise — the CLI doesn't mint JWTs at
all, it just manages user/permission rows directly, and was passing
`appName` ("osctrl", 6 bytes) as a placeholder. Every CLI invocation
with `--db` would have aborted with "JWT Secret too short" once
Round 1 lands.

Fix: split the constructor.

  // DB-only constructor — never validates JWT.
  func CreateUserManager(backend *gorm.DB) *UserManager

  // Attach JWT signing config; validates the secret here.
  func (u *UserManager) WithJWT(*config.YAMLConfigurationJWT) *UserManager

Token-issuing callers chain:

  apiUsers   = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)
  adminUsers = users.CreateUserManager(db.Conn).WithJWT(flagParams.JWT)

The CLI calls only `CreateUserManager(db.Conn)`. `CreateToken` now
returns an error if invoked on a manager without `WithJWT` —
defense-in-depth so a future caller can't accidentally sign tokens
with a nil config.

== TLS handler: per-endpoint body caps ==

cmd/tls/handlers/post.go: wrap every osquery-facing `io.ReadAll` in
`http.MaxBytesReader` with an endpoint-appropriate ceiling. Without
this, a misbehaving or hostile node can submit an arbitrarily large
body and force the server to buffer it before parsing. Caps are
chosen against real osquery payload sizes:

  enroll:           64 KiB
  config:           64 KiB
  log:             100 MiB   (osquery log batches)
  query read:       16 KiB
  query write:     100 MiB
  carve init:        8 KiB
  carve block:      16 MiB
  quick-enroll:      8 KiB
  flags / cert:      8 KiB
  verify / script:   8 KiB
  osquery config:    2 MiB

A single `readBody(w, r, max)` helper applies the cap and reads, so
the call sites stay one-line.

== Carve compression: out-of-bounds panic guard ==

pkg/carves/utils.go: `CheckCompressionRaw` previously dereferenced
`data[:4]` to compare against the zstd header. A truncated or empty
block (which an authenticated CarveLevel client can submit) would
panic with index-out-of-range. Guarded with a length check.

== Frontend (PR jmpsec#815 scope) ==

- frontend/index.html: removed the inline `<script>` that bootstraps
  the theme attribute and moved it to
  `frontend/public/theme-bootstrap.js`. The inline form violated the
  CSP `script-src 'self' blob:` deployed on the SPA (45 CSP errors per
  page load in the prior audit).
- frontend/package.json (+ package-lock.json): add an `overrides`
  block pinning `dompurify@^3.4.3` to remediate the transitive
  vulnerability advisory.
- frontend/src/features/dashboard/DashboardPage.tsx: wire the
  time-series chart to real `getEnvActivity` data instead of the
  placeholder constants. Added a 7d / 24h interactive toggle and an
  `aggregateBuckets` helper that collapses the 15-min server buckets
  into N display bins.

== Dev stack ==

deploy/docker/conf/osquery/entrypoint.sh: pin
`--host_identifier=specified --specified_identifier=$(hostname)` so
the three dev osquery containers enroll as distinct nodes instead of
colliding on the host kernel UUID. The full Kali docker stack now
enrolls four unique nodes (the Kali host + three containers).

== Toolchain ==

Bump Go from 1.26.1 to 1.26.3 across go.mod, the four
deploy/docker/dockerfiles/Dockerfile-dev-*, deploy/lib.sh,
.env.example, the .github/actions/{build,test}/binaries action
manifests, and the five .github/workflows/*.yml. 1.26.3 carries the
stdlib CVE fixes flagged by the local govulncheck run.

Verified locally:
- go build ./...  (clean on Go 1.26.3)
- go test  ./...  (all packages green, including new CreateUserManager
  / WithJWT tests)
- frontend: vitest 92/92 green; npm run build clean; npm audit
  --omit=dev → 0 vulnerabilities
- Live smoke on the Kali dev stack: container rebuild + recreate;
  osctrl-cli runs `env show`, `node-actions secret`, `show-flags`
  without the old JWT-too-short fatal; osctrl-api signs a real login
  token through the SPA; the four enrolled osquery nodes remain in
  the DB.
@alvarofraguas alvarofraguas force-pushed the pr/round-3-frontend branch from 73dbfc7 to b36e3b2 Compare May 16, 2026 08:06
osquery agents back off on 413 Payload Too Large but not on 500
Internal Server Error. A misbehaving (or hostile) client whose log
batch exceeds the per-endpoint cap would previously get a 500, keep
retrying with the same payload, and the operator would see a flood
of 500s in audit logs with no useful signal.

readBody now distinguishes the two cases:
- http.MaxBytesError → write 413 directly, return the error
- any other read error → return the error untouched (caller writes 500)

Callers gain one small isMaxBytesError(err) guard so they skip writing
a second status when the helper already wrote 413; a duplicate
WriteHeader is a no-op at the wire but logs a noisy warning.

All 12 readBody call sites updated. Verified end-to-end against
osctrl-tls behind nginx: 100 KiB POST to /enroll (cap 64 KiB) returns
HTTP 413; small invalid-secret body still returns HTTP 403.
Copy link
Copy Markdown
Collaborator

@javuto javuto left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing! Nicely done 👏 👏 👏

@javuto javuto merged commit 4915b24 into jmpsec:main May 16, 2026
3 checks passed
@alvarofraguas alvarofraguas deleted the pr/round-3-frontend branch May 17, 2026 17:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ enhancement New feature or request ⭐️ frontend Frontend related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants