Skip to content

sec: address 12 findings from SECURITY_AUDIT.md batch 1#311

Merged
PaulDotterer merged 3 commits into
mainfrom
sec/audit-findings-batch-1
May 18, 2026
Merged

sec: address 12 findings from SECURITY_AUDIT.md batch 1#311
PaulDotterer merged 3 commits into
mainfrom
sec/audit-findings-batch-1

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Triaged the 30 findings in SECURITY_AUDIT.md and implemented the 12 that are valid, scoped, and don't require cross-service infrastructure work.

Implemented

High-severity hardening

  • F-04 — constant-time cert fingerprint compare (internal/api/certificate_handler.go:66)
  • F-05 — Content-Security-Policy header with frame-ancestors 'none', base-uri 'self', form-action 'self' (internal/middleware/security.go)
  • F-14 — pin JWT validator to exact HS256 (internal/auth/jwt.go:155)
  • F-03 — Valkey password out of /proc/<pid>/cmdline: rendered config file (mode 0400) + healthcheck via REDISCLI_AUTH (deploy/compose.yml, deploy/setup.sh, deploy/valkey.conf.template)

Medium / defence-in-depth

  • F-08 — SCIM secondary rate-limit bucket keyed on (slug, client IP), ceiling 20/min. Closes the slug-spreading bypass.
  • F-15 — SSO redirect URL same-origin allowlist (loopback exempted for pm-enroll).
  • F-28 — info-level log on auto-link-by-email + doc comment warning the flag makes the IdP's email-verification policy a trust dependency. Proto default already false.

Low / informational

  • F-13 — cap OutputChunk at 64 KiB, drop oversized with WARN.
  • F-20 — rate-limiter 100k LRU key cap to bound memory under wide-IP-distribution attacks.
  • F-26 — public /health returns ok, no version leak.
  • F-27 — documented unscoped StartTerminal cross-user behaviour + recipe for StartTerminal:self.
  • F-12 — symmetric whitespace trim in dynamic-query bare-value consumer.

Skipped (18)

Full reasoning in the commit body. Key ones:

  • F-02 (CRITICAL — Asynq task payload signing) is genuinely valid but needs cross-service HMAC infrastructure (env var on control + gateway, sign/verify layers, deployment migration). The realistic exploit surface is the terminal-input / osquery dispatch path — the action-dispatch path is already gated by the agent's signature check. Worth a focused follow-up PR.
  • F-07 (backup-code race) blocks on the sqlc version-drift issue tracked in refactor(store): wave H cheap wins — IsVersionConflict + TestingPool + drop PL/pgSQL #308.
  • F-17 (encryptor warning) already implemented; verified.
  • F-19 (cert PEM in registration event) conflicts with documented design intent.
  • The rest are either by-design (F-01, F-11, F-22), need tooling (F-18, F-29), operator-deployment concerns (F-23, F-25), or are out of scope (web tooling, contradictions in the audit itself).

Test plan

  • go vet ./... clean
  • gofmt -l . clean
  • go build ./... clean
  • internal/dynamicquery + internal/auth tests green (covers F-12 + F-14 + F-20 behaviour)
  • CI: full suite + CodeRabbit
  • Deployment note: operators must re-run deploy/setup.sh once after upgrade to render valkey.conf from the new template.

Summary by CodeRabbit

  • New Features

    • Redirect URL validation for SSO login flows.
    • Per-IP rate limiting for SCIM API requests.
    • Content-Security-Policy header added.
    • 64 KiB live-chunk cap and 1 MiB per-command output storage cap with explicit truncation marker.
  • Security Enhancements

    • Constant-time certificate fingerprint comparison.
    • JWT signing algorithm restricted to HS256.
    • Deployment config rendered and mounted to avoid exposing secrets on command lines; healthcheck hardened.
  • Tests

    • Audit redaction schema tests aligned with wire-format field names.

Review Change Stack

Triaged the 30 findings in SECURITY_AUDIT.md and implemented 12 that
are valid, scoped, and don't require cross-service infrastructure
work. Skipped findings are documented below.

## Implemented (12)

### High-severity hardening

* **F-04 — constant-time cert fingerprint compare** (`internal/api/certificate_handler.go:66`):
  replaced the `!=` string compare with `subtle.ConstantTimeCompare`
  so a co-located attacker can't extract the stored fingerprint
  byte-by-byte via response timing.

* **F-05 — Content-Security-Policy header** (`internal/middleware/security.go`):
  added a tight default-src 'self' policy with frame-ancestors 'none'
  for defence-in-depth against XSS / clickjacking. Tightened beyond
  the audit's recommendation by also adding base-uri + form-action.

* **F-14 — JWT algorithm pinned to HS256** (`internal/auth/jwt.go:155`):
  replaced the `*jwt.SigningMethodHMAC` family check with an exact
  match on `jwt.SigningMethodHS256`. Issuance always uses HS256, so
  anything else is by definition forged.

* **F-03 — Valkey password out of cmdline** (`deploy/compose.yml`,
  `deploy/setup.sh`, `deploy/valkey.conf.template`): replaced
  `--requirepass ${VALKEY_PASSWORD}` with a config file rendered at
  setup time (mode 0400) and mounted read-only into pm-valkey.
  Healthcheck now uses REDISCLI_AUTH instead of `-a <pw>`. Closes
  the /proc/<pid>/cmdline leak.

### Medium / defence-in-depth

* **F-08 — SCIM per-IP rate limiter** (`internal/scim/auth.go`,
  `internal/scim/handler.go`, `internal/auth/interceptor.go`): added
  a secondary rate-limit bucket keyed on (slug, client IP) with a
  20/min ceiling. Closes the slug-spreading bypass — knowing multiple
  valid slugs no longer dilutes the per-IP attempt count. Exported
  `auth.ClientIPFromHTTP` so non-Connect handlers reuse the same
  trusted-proxy semantics.

* **F-15 — SSO redirect URL allowlist** (`internal/api/sso_handler.go`):
  added `validateSSORedirectURL` enforcing same-origin against
  `CONTROL_SSO_CALLBACK_BASE_URL` or loopback (for pm-enroll CLI flows).
  Was previously only validated by the OIDC provider — an IdP CVE or
  misconfiguration would have let an attacker pin sessions to a
  redirect they control.

* **F-28 — auto-link-by-email visibility** (`internal/api/idp_handler.go`,
  `internal/idp/linker.go`): proto default already false (correct), but
  promoted the linker's auto-link Debug log to Info so an operator who
  enables the flag can audit which identities get linked. Added doc
  comment on the create path warning that enabling this flag makes the
  IdP's email-verification policy a trust dependency.

### Low / informational

* **F-13 — cap OutputChunk size at 64 KiB** (`internal/handler/agent.go`):
  oversized chunks from a compromised agent are now dropped with a
  WARN log instead of being enqueued. Stops Valkey memory exhaustion +
  executions.output blow-up via flood.

* **F-20 — rate-limiter LRU eviction cap** (`internal/auth/ratelimit.go`):
  added a 100k-key ceiling per limiter with LRU eviction on a separate
  lastSeen map. Bounds memory growth under wide-IP-distribution attacks
  between the 5-minute cleanup ticks.

* **F-26 — strip version from public /health** (`cmd/control/main.go:284`):
  unauthenticated health endpoint now returns `ok` rather than
  `{"status":"ok","version":"vYYYY.MM.PP"}`. Version remains available
  via the mTLS-protected internal /health for operator scrape tools.

* **F-27 — StartTerminal cross-user doc comment**
  (`internal/api/terminal_handler.go`): documented that the unscoped
  `StartTerminal` permission deliberately grants cross-user TTY access
  for incident response, with attribution captured via the
  `TerminalSessionStarted` actor_id. Includes the recipe for adding a
  `StartTerminal:self` scope if a deployment needs the no-cross-user
  variant.

* **F-12 — symmetric whitespace trim in dynamic-query parser**
  (`internal/dynamicquery/parser.go:340`): `consumeBareValue` was
  TrimRight-only; now TrimSpace so `  admin` and `admin  ` produce the
  same value. Cosmetic parser-smell fix; the field allowlist already
  made this non-exploitable.

## Skipped (18) — see SECURITY_AUDIT.md for full reasoning

* **F-01** JWT permissions embedded statically — design call, section 6
  of the audit already classifies this as standard practice.
* **F-02** Asynq task payloads unsigned (CRITICAL) — genuinely valid
  but requires cross-service HMAC infrastructure (new env var on
  control + gateway, signing layer, verification layer, deployment
  migration). Worth a focused PR; tracked separately. Note: the
  realistic exploit path is the terminal-input / osquery dispatch
  surface, since the action-dispatch path is already gated by the
  CA-signed action signature the agent verifies.
* **F-06** TOTP secret in response — required for QR provisioning;
  the audit's recommendation doesn't have a clean alternative.
* **F-07** Backup-code race — fix needs an atomic UPDATE which
  requires sqlc query changes + regen, blocked on the sqlc-version
  drift documented in #308.
* **F-09** SSO actor_type attribution — audit contradicts itself
  (section 6 acknowledges the StreamType carries the IdP identity).
* **F-10** 6-char backup-code collision — 1-in-16M, audit acknowledges
  negligible probability.
* **F-11** Pagination total-result cap — by design; authorised users
  are authorised.
* **F-16** Audit-gap Prometheus counter — needs Prometheus integration
  not present in this codebase.
* **F-17** Encryptor missing-key warning — already implemented in
  cmd/control/setup.go::initEncryptor:96 (verified).
* **F-18** :assigned compile-time lint — needs custom analyzer
  tooling.
* **F-19** CertPEM/CACertPEM in DeviceRegistered event — conflicts
  with documented design intent (event-store replay can reconstruct
  cert bytes).
* **F-21** Manager.Register silent disconnect — needs proto change
  for a new ServerMessage_Disconnect variant.
* **F-22** Registration token SHA-256 vs bcrypt — audit acknowledges
  current ULID entropy makes SHA-256 adequate.
* **F-23** Postgres init script password — operator deployment
  concern, not application runtime.
* **F-24** Web npm audit — CI tooling, not a code change.
* **F-25** Valkey image licensing — license review, not a security
  fix.
* **F-29** Audit-redaction CI check — needs go/analysis tooling.
* **F-30** Empty-string env var as no-op — minor; CONTROL_CORS_ORIGINS
  has no published "clear previously-configured" workflow yet.

No proto change. No schema change. Tests-and-fmt clean. Container
deployment requires re-running deploy/setup.sh once after upgrade to
render the new valkey.conf — documented in QUICKSTART next pass.
@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Consolidates Valkey deployment templating and multiple security/operational hardening edits: constant-time certificate checks, JWT algorithm pinning, client-IP extraction, per-instance rate-limiter eviction with SCIM IP gating, SSO redirect-url validation, agent output-size caps and truncation, CSP header, and audit/schema/test updates.

Changes

Security hardening and deployment improvements

Layer / File(s) Summary
Valkey deployment and configuration templating
deploy/compose.yml, deploy/setup.sh, deploy/valkey.conf.template
Valkey mounts a rendered, read-only configuration file; setup renders valkey.conf from valkey.conf.template substituting __VALKEY_PASSWORD__; Compose starts redis-server with that config and healthcheck uses REDISCLI_AUTH.
Authentication and cryptographic validation hardening
internal/api/certificate_handler.go, internal/auth/jwt.go, internal/auth/interceptor.go
Certificate fingerprint comparison uses constant-time compare; JWT validation enforces HS256; added ClientIPFromHTTP(*http.Request) to extract caller IP respecting trusted proxies.
Rate limiter eviction and per-IP SCIM gating
internal/auth/ratelimit.go, internal/scim/handler.go, internal/scim/auth.go
RateLimiter caps distinct keys with least-recently-seen eviction and tracks lastSeen; SCIM handler adds an ipRateLimiter (20/min) keyed by provider slug + client IP and enforces it when IP is resolvable.
Request validation and output-size controls
internal/api/sso_handler.go, internal/handler/agent.go, internal/middleware/security.go
SSO login validates redirect_url (empty, same-origin vs callback base, or loopback) and rejects others; agent enforces 64 KiB max output chunk and drops oversized chunks with WARN; SecurityHeaders middleware sets a tightened Content-Security-Policy header.
Audit, control, parser, and test updates
cmd/control/main.go, internal/api/audit_handler.go, internal/api/audit_redactor_internal_test.go, internal/api/audit_redactor_paths_test.go, internal/dynamicquery/parser.go, internal/control/inbox_worker.go, internal/idp/linker.go, internal/api/idp_handler.go, internal/api/terminal_handler.go
Health endpoint simplified to plain 200 OK body "ok"; action-redaction schemas updated to camelCase and tests adjusted; added schema-vs-wire validator test; parser trims leading+trailing whitespace; inbox drops oversized chunks and truncates stored command output; auto-link log level raised with extra fields; IDP and terminal comments added.

Sequence Diagram(s)

No sequence diagrams generated.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibbled templates through the night,
I swapped the secrets, stitched them tight,
Timed the fingerprints, pinned the key,
Counted IP hops for safety—see?
Hop, patch, and guard till morning light.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically references the security audit findings being addressed (batch 1) and uses a conventional security prefix, accurately summarizing the main purpose of this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/audit-findings-batch-1

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deploy/setup.sh`:
- Line 684: The current chmod call sets "$rendered" to 0400 which may prevent
the valkey process (running under a different UID/GID inside the container) from
reading /etc/valkey/valkey.conf; update the script so the rendered config is
readable by the container user—e.g., change chmod 0400 "$rendered" to chmod 0440
"$rendered" or chmod 0640 "$rendered" and (if needed) chown the file to the
valkey user/group (use the same variable "$rendered" and perform chown before
chmod) so the valkey process UID/GID can read the file at startup.
- Around line 680-683: The awk gsub(/__VALKEY_PASSWORD__/, pw) call in the
script corrupts passwords containing & or \ because gsub's replacement
interprets those characters; update the awk invocation that writes "$template"
-> "$rendered" to perform a safe literal substitution using match() and substr()
(or a loop using match() to find "__VALKEY_PASSWORD__" and rebuild the line with
substrings and the pw variable) instead of gsub(), ensuring the VALKEY_PASSWORD
value is inserted verbatim without special-character expansion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 83d6af9c-3e23-43ef-8021-b54dc8f5a7f3

📥 Commits

Reviewing files that changed from the base of the PR and between cc723a3 and b707c7e.

📒 Files selected for processing (17)
  • cmd/control/main.go
  • deploy/compose.yml
  • deploy/setup.sh
  • deploy/valkey.conf.template
  • internal/api/certificate_handler.go
  • internal/api/idp_handler.go
  • internal/api/sso_handler.go
  • internal/api/terminal_handler.go
  • internal/auth/interceptor.go
  • internal/auth/jwt.go
  • internal/auth/ratelimit.go
  • internal/dynamicquery/parser.go
  • internal/handler/agent.go
  • internal/idp/linker.go
  • internal/middleware/security.go
  • internal/scim/auth.go
  • internal/scim/handler.go

Comment thread deploy/setup.sh Outdated
Comment thread deploy/setup.sh Outdated
…eadable mode

CodeRabbit caught two real issues in the valkey.conf renderer:

* **awk gsub treats & as the match.** A password containing `&` would
  have landed in the rendered config as the literal `__VALKEY_PASSWORD__`
  placeholder text instead of the password char. Same hazard with bash's
  `${var//pat/rep}` (it also interprets `&`), sed's `s///` (interprets
  `&` and `\`), and any other regex-based substitution. Replaced with a
  split-and-concatenate loop on `${remaining%%PAT*}` / `${remaining#*PAT}`
  so the password is concatenated as a plain string — no interpretation,
  no escape needed, adversarial chars (&, \, /, |, $, newlines) all
  round-trip verbatim. Verified with `pw='adv&pwd\with/all|the$chars/&'`.

* **chmod 0400 is unreadable to the in-container valkey UID.** The
  redis-stack-server image runs as uid 999, which does not match the
  host operator's uid that owns the rendered file. 0400 meant the
  container redis process couldn't open /etc/valkey/valkey.conf at
  startup. Switched to 0644 (still ro,z bind-mounted, so the container
  cannot tamper). The password is already on the host in .env at
  similar exposure, so 0644 does not materially widen the secret-
  handling model — and the audit-F-03 fix (cmdline / /proc visibility)
  holds regardless of the rendered file mode.

Atomic write via mktemp + mv kept; switched echo -> printf so a
password starting with `-` is not interpreted as a flag.
…redaction schema fix

Two more findings from the deeper-dive update of SECURITY_AUDIT.md.

## F-33 — Execution-output size caps (server-side)

Cap output streams on the way into the event store so a compromised
agent can't blow up events.data or the Valkey inbox queue.

* internal/control/inbox_worker.go::commandOutputPayload — cap
  stdout/stderr at 1 MiB per stream via truncateOutputStream. Keeps
  the head and appends an explicit "[truncated ...]" marker so the
  UI shows it was capped rather than silently dropping.
* internal/control/inbox_worker.go::handleExecutionOutputChunk —
  second-line 64 KiB cap on individual streaming chunks (the
  gateway already enforces this in handler/agent.go from F-13, but
  a future gateway compromise must not bypass the cap). Oversized
  chunks are dropped with WARN.

## F-34 — Audit redaction schema fix (more severe than audit framed)

The audit said "future proto field renames could silently leak
secrets". Deeper inspection found the schema was ALREADY broken for
every secret-bearing action type — paths used snake_case but the
production wire format is camelCase (protojson with UseProtoNames=
false in internal/actionparams/actionparams.go), so the redactor
walked paths that didn't exist on the actual payload.

Concretely:
  - ADMIN_POLICY: schema scrubbed params.unit_content (a field that
    doesn't exist; the proto was renamed to AdminPolicyParams.custom_config,
    wire emit key is "customConfig"). Sudoers/doas.conf fragments
    were LEAKING through the audit log.
  - REPOSITORY: schema scrubbed params.gpg_key; wire key is "gpgKey".
    GPG signing key material was leaking.
  - ENCRYPTION: schema scrubbed params.passphrase + params.preshared_key;
    EncryptionParams has no passphrase field, and preshared_key's
    wire key is "presharedKey". LUKS pre-shared keys were leaking.
  - LPS: schema scrubbed params.password + params.preshared_key;
    LpsParams has neither (the password is generated agent-side and
    surfaces via the lps_password.LpsPasswordRotated event, which IS
    correctly redacted). Both paths were phantoms.
  - SHELL: schema scrubbed params.script only; ShellParams also has
    detectionScript which can carry the same kind of secret-bearing
    body.

Fix:
  1. Updated actionRedactionSchemas with the correct camelCase paths
     and the right set of fields per proto. Removed the phantom LPS
     entry entirely.
  2. Updated the existing redaction-internal tests to use the
     production wire shape (camelCase) — the previous snake_case
     fixtures masked the production leak by matching the broken
     schema.
  3. Added internal/api/audit_redactor_paths_test.go::
     TestActionRedactionSchemas_PathsMatchWireFormat — for each
     entry in actionRedactionSchemas, build a fully-populated proto
     of the corresponding params type, run it through the production
     emit path (actionparams.MarshalActionParams), and assert every
     redaction path resolves to a real string field on the wire emit.
     CI now catches schema drift in lock-step with proto field
     renames; a future field rename without a schema update fails the
     test instead of silently leaking through the audit log.

Agent-side findings F-31 (REBOOT/SYNC skip signature verification),
F-32 (Debug logs leak shell output), F-35 (journalctl --grep ReDoS)
require changes in the agent repo and will land in a separate PR
there. F-31 in particular needs paired control-side signing of
parameterless actions, which I'll bundle with the F-02 task-signing
follow-up.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/api/audit_redactor_paths_test.go (1)

142-148: 💤 Low value

Function name sortedKeys is misleading — keys are not sorted.

The function iterates the map and returns keys in random order. Since it's only used in test error messages, this doesn't affect correctness, but the name could confuse future readers.

🔧 Suggested fix
+import "sort"
+
-func sortedKeys(m map[string]any) []string {
+func sortedKeys(m map[string]any) []string {
 	out := make([]string, 0, len(m))
 	for k := range m {
 		out = append(out, k)
 	}
+	sort.Strings(out)
 	return out
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/audit_redactor_paths_test.go` around lines 142 - 148, The helper
sortedKeys currently returns map keys in unspecified order but its name implies
sorting; update it so it actually returns keys in deterministic sorted order:
collect keys from the map (function sortedKeys), call sort.Strings on the slice,
and then return the sorted slice so test error messages are stable and name
matches behavior (ensure the sort package is imported if not already).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/api/audit_redactor_paths_test.go`:
- Around line 142-148: The helper sortedKeys currently returns map keys in
unspecified order but its name implies sorting; update it so it actually returns
keys in deterministic sorted order: collect keys from the map (function
sortedKeys), call sort.Strings on the slice, and then return the sorted slice so
test error messages are stable and name matches behavior (ensure the sort
package is imported if not already).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0e043270-509a-4680-b942-9acb5b4df654

📥 Commits

Reviewing files that changed from the base of the PR and between da3b09e and bdf14f5.

📒 Files selected for processing (4)
  • internal/api/audit_handler.go
  • internal/api/audit_redactor_internal_test.go
  • internal/api/audit_redactor_paths_test.go
  • internal/control/inbox_worker.go

@PaulDotterer PaulDotterer merged commit 18df3bd into main May 18, 2026
5 checks passed
@PaulDotterer PaulDotterer deleted the sec/audit-findings-batch-1 branch May 18, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant