mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Fix security scan findings
Harden proxy-auth admin role checks, metrics listener exposure, Teams webhook escaping, and dependency lockfiles.
This commit is contained in:
parent
9dc4386318
commit
c0ac0762da
27 changed files with 1396 additions and 216 deletions
|
|
@ -372,6 +372,19 @@ Persistent metrics history uses tiered retention windows. These values are store
|
|||
|
||||
See [METRICS_HISTORY.md](METRICS_HISTORY.md) for details.
|
||||
|
||||
### Prometheus Metrics Endpoint
|
||||
|
||||
The `/metrics` listener is separate from the main UI/API listener and binds to loopback by default.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ---------- | ------------- | --------- |
|
||||
| `PULSE_METRICS_PORT` | Metrics listener port | `9091` |
|
||||
| `PULSE_METRICS_BIND_ADDRESS` | Metrics listener bind address | `127.0.0.1` |
|
||||
| `PULSE_METRICS_TOKEN` | Optional bearer token for `/metrics` | *(empty)* |
|
||||
| `PULSE_METRICS_ALLOW_INSECURE_REMOTE` | Explicit opt-in to serve a metrics bearer token over non-loopback plaintext HTTP | `false` |
|
||||
|
||||
For remote scraping with `PULSE_METRICS_TOKEN`, prefer a local scraper, tunnel, VPN-private path, or TLS/mTLS reverse proxy. Pulse refuses non-loopback plaintext token scraping unless `PULSE_METRICS_ALLOW_INSECURE_REMOTE=true` is set.
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Alerts (`alerts.json`)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudfl
|
|||
| `PROXY_AUTH_ADMIN_ROLE` | Role name that grants admin access. | `admin` |
|
||||
| `PROXY_AUTH_LOGOUT_URL` | URL to redirect to after logout. | - |
|
||||
|
||||
If `PROXY_AUTH_ROLE_HEADER` and `PROXY_AUTH_ADMIN_ROLE` are configured, admin access fails closed unless the role header is present and contains the configured admin role. A missing or blank role header still authenticates the user, but the user is treated as non-admin.
|
||||
|
||||
If you intentionally want every proxy-authenticated user to be an admin, leave `PROXY_AUTH_ROLE_HEADER` unset and protect Pulse entirely at the proxy/IdP layer.
|
||||
|
||||
## 📦 Examples
|
||||
|
||||
### Authentik (with Traefik)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,20 @@ Pulse exposes metrics at `/metrics` (default port `9091`).
|
|||
|
||||
Example scrape target:
|
||||
|
||||
- `http://<pulse-host>:9091/metrics`
|
||||
- `http://127.0.0.1:9091/metrics`
|
||||
|
||||
This listener is separate from the main UI/API port (`7655`). In Docker and Kubernetes you must expose `9091` explicitly if you want to scrape it from outside the container/pod.
|
||||
This listener is separate from the main UI/API port (`7655`) and binds to loopback by default. Use `PULSE_METRICS_BIND_ADDRESS` only when a scraper must reach Pulse from another host.
|
||||
|
||||
When `PULSE_METRICS_TOKEN` is set, Pulse refuses to serve that bearer token on a non-loopback plaintext listener unless `PULSE_METRICS_ALLOW_INSECURE_REMOTE=true` is explicitly set. Prefer a local Prometheus agent, SSH tunnel, VPN-private scrape path, or TLS/mTLS reverse proxy for remote scraping.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| :--- | :--- | :--- |
|
||||
| `PULSE_METRICS_PORT` | Metrics listener port. | `9091` |
|
||||
| `PULSE_METRICS_BIND_ADDRESS` | Metrics listener bind address. Keep loopback unless a protected remote scraper needs direct access. | `127.0.0.1` |
|
||||
| `PULSE_METRICS_TOKEN` | Optional bearer token required for `/metrics`. | *(empty)* |
|
||||
| `PULSE_METRICS_ALLOW_INSECURE_REMOTE` | Explicit opt-in to serve `PULSE_METRICS_TOKEN` on a non-loopback plaintext listener. Use only behind a trusted private network or TLS-terminating proxy. | `false` |
|
||||
|
||||
In Docker and Kubernetes you must expose `9091` explicitly if you want to scrape it from outside the container/pod, and the container/pod should set `PULSE_METRICS_BIND_ADDRESS` to the intended listener address.
|
||||
|
||||
**Helm note:** the current chart exposes only port `7655`, so Prometheus scraping requires an additional Service that targets `9091` (and a matching ServiceMonitor).
|
||||
|
||||
|
|
|
|||
|
|
@ -1078,6 +1078,11 @@ surface and no new `internal/api/` lifecycle handler.
|
|||
10. Preserve canonical token-lifecycle reads in shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows do not revoke a displayed relay pairing token after `lastUsedAt` proves that an already paired device is actively depending on that credential.
|
||||
11. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows reuse the canonical mobile token route instead of reintroducing wildcard or browser-authored runtime token bundles.
|
||||
12. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so lifecycle-adjacent setup and install flows do not widen the mobile device credential back into general AI chat/execute scope ownership.
|
||||
Shared proxy-auth admin evaluation in `internal/api/auth.go` remains an
|
||||
API/security trust boundary, not an agent lifecycle enrollment shortcut:
|
||||
lifecycle-adjacent setup and install surfaces may rely on the authenticated
|
||||
user identity passed through that helper, but they must not treat a missing
|
||||
configured role header as administrator proof.
|
||||
13. Preserve shipped security-doc guidance in shared lifecycle setup helpers so `internal/api/config_setup_handlers.go` and adjacent install/setup runtime paths point operators at the running build's local security documentation route rather than GitHub `main` links.
|
||||
14. Keep shared `internal/api/router.go` workload-chart downsampling presentation-only: when that router caps mixed-cadence workload history into equal-time buckets for operator-facing cards, lifecycle-adjacent setup and fleet surfaces must not reuse the shaped chart samples as heartbeat, enrollment, or last-seen authority.
|
||||
That same presentation-only boundary must preserve canonical millisecond timestamps when it serializes chart points, so lifecycle-adjacent first-host and fleet surfaces do not misread rounded chart samples as duplicate or restarted heartbeat evidence.
|
||||
|
|
|
|||
|
|
@ -143,6 +143,14 @@ registration, or the dedicated Pulse Mobile credential is incomplete, while
|
|||
`frontend-modern/src/api/onboarding.ts` and relay settings consumers may only
|
||||
surface those diagnostics and must not synthesize partial pairing payloads.
|
||||
|
||||
Proxy-auth administrator evaluation is a shared auth/API contract. Once
|
||||
`PROXY_AUTH_ROLE_HEADER` and `PROXY_AUTH_ADMIN_ROLE` are configured,
|
||||
`internal/api/auth.go` must treat a valid proxy-auth user with a missing or
|
||||
blank role header as authenticated but non-admin, and only an explicit
|
||||
configured admin role may pass admin gates. Installations that intentionally
|
||||
make every proxy-authenticated user an admin must do that by leaving the role
|
||||
header unset and protecting Pulse at the proxy/IdP layer.
|
||||
|
||||
Assistant chat `workflow_state` events are a shared AI/API payload boundary.
|
||||
The backend source of truth is `internal/ai/chat.WorkflowStateData`, generated
|
||||
to `frontend-modern/src/api/generated/aiChatEvents.ts` by
|
||||
|
|
|
|||
|
|
@ -1386,6 +1386,11 @@ are part of the same governed bootstrap input even when the package manifest
|
|||
range already permits the newer version; the lockfile must identify the
|
||||
resolved package version and integrity that the release build will actually
|
||||
consume.
|
||||
Security-driven Go module graph bumps follow the same rule: `go.mod` and
|
||||
`go.sum` must move together when a reachable vulnerability is remediated, and
|
||||
the slice must carry direct vulnerability or dependency-floor proof so the
|
||||
release and local dev runtimes consume the intended module graph rather than a
|
||||
stale transitive floor.
|
||||
When the managed launcher reports runtime status, it must tell operators which
|
||||
browser URL to use and whether the frontend shell, proxied API path, and
|
||||
direct backend health endpoint all agree, instead of leaving `5173` versus
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ injecting raw alert fields directly into JSON bodies. Custom user templates
|
|||
may still choose their own formatting, but the shipped provider templates may
|
||||
not rely on callers to pre-sanitize alert text or resource names just to keep
|
||||
their JSON payloads valid.
|
||||
Teams Adaptive Card templates are part of that same built-in provider boundary:
|
||||
resolved alert titles and resource-name text must pass through the JSON string
|
||||
helper before entering Adaptive `TextBlock.text`, so a resource name containing
|
||||
quotes or backslashes cannot invalidate the webhook payload.
|
||||
Email single-alert, grouped, resolved, and HTML send paths must follow that
|
||||
same ownership rule: they may expose different calling surfaces, but they must
|
||||
all route through one canonical enhanced email executor instead of rebuilding
|
||||
|
|
|
|||
|
|
@ -881,6 +881,13 @@ direct-loopback browser recovery session, and must not reopen authentication
|
|||
through a shared `.auth_recovery` flag that affects every localhost client.
|
||||
Secret-bearing comparisons on adjacent auth paths such as metrics bearer
|
||||
validation and local-auth username matching must stay constant-time.
|
||||
Metrics bearer-token transport is also part of this trust boundary:
|
||||
`internal/config/config.go` owns `PULSE_METRICS_BIND_ADDRESS`, which defaults
|
||||
the metrics listener to loopback, and the explicit
|
||||
`PULSE_METRICS_ALLOW_INSECURE_REMOTE` escape hatch. Runtime metrics serving
|
||||
must reject a configured bearer token on non-loopback plaintext HTTP unless
|
||||
that override is set, so a UI/API bind address cannot silently widen scrape
|
||||
credentials to a remote network.
|
||||
That same persistence rule also governs API token metadata: even though
|
||||
`api_tokens.json` stores hashed records rather than raw token secrets, a
|
||||
legacy plaintext metadata file may only serve as migration input. Canonical
|
||||
|
|
|
|||
|
|
@ -682,6 +682,11 @@ recovery scope, or a storage/recovery-owned secret source.
|
|||
stable hosted owner principal is server-side org metadata resolved later
|
||||
through magic-link verification.
|
||||
7. Preserve fail-closed API assignment and lookup behavior in shared `internal/api/` helpers so storage and recovery surfaces do not inherit orphaned profile or resource references from unrelated transport mutations.
|
||||
Preserve fail-closed proxy-auth administrator evaluation in those same
|
||||
shared helpers as an adjacent security/API boundary: storage and recovery
|
||||
surfaces may consume an already-authorized admin request, but they must not
|
||||
infer storage, restore, or protected-system authority from a proxy-auth user
|
||||
whose configured role header is missing or blank.
|
||||
8. Preserve canonical configured public endpoint selection in shared `internal/api/` helpers so recovery and storage links do not inherit loopback-local scheme drift from admin-originated setup/install flows.
|
||||
9. Preserve trailing-slash normalization in those shared install-command helpers so recovery-adjacent transport and link surfaces do not inherit double-slash installer paths or slash-suffixed public endpoint drift from canonical backend install payloads.
|
||||
10. Preserve canonical /api/auto-register token-action truth in shared `internal/api/` helpers so adjacent setup and recovery-adjacent transport flows stay on caller-supplied credential completion instead of reviving deleted alternate completion modes.
|
||||
|
|
|
|||
1185
frontend-modern/pnpm-lock.yaml
generated
1185
frontend-modern/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
14
frontend-modern/pnpm-workspace.yaml
Normal file
14
frontend-modern/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
packages:
|
||||
- .
|
||||
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
overrides:
|
||||
esbuild: 0.28.1
|
||||
form-data: ^4.0.6
|
||||
seroval: ^1.4.1
|
||||
seroval-plugins: ^1.4.1
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
12
go.mod
12
go.mod
|
|
@ -27,11 +27,11 @@ require (
|
|||
github.com/spf13/cobra v1.10.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stripe/stripe-go/v82 v82.5.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/term v0.44.0
|
||||
k8s.io/api v0.32.0
|
||||
k8s.io/apimachinery v0.32.0
|
||||
k8s.io/client-go v0.32.0
|
||||
|
|
@ -102,8 +102,8 @@ require (
|
|||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
|
|
|
|||
14
go.sum
14
go.sum
|
|
@ -229,18 +229,23 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -248,6 +253,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
|
@ -258,12 +265,18 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
@ -272,6 +285,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
|
|||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
|||
|
|
@ -526,29 +526,34 @@ func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
|
|||
}
|
||||
}
|
||||
|
||||
// Check admin role if configured
|
||||
// Check admin role if configured. Once a role header and admin role are
|
||||
// configured, admin access must be proven by an explicit role value.
|
||||
isAdmin := true // Default to admin if no role checking configured
|
||||
if cfg.ProxyAuthRoleHeader != "" && cfg.ProxyAuthAdminRole != "" {
|
||||
roles := r.Header.Get(cfg.ProxyAuthRoleHeader)
|
||||
if roles != "" {
|
||||
isAdmin = false
|
||||
if strings.TrimSpace(roles) == "" {
|
||||
log.Debug().
|
||||
Str("header", cfg.ProxyAuthRoleHeader).
|
||||
Msg("Proxy auth role header missing or empty")
|
||||
} else {
|
||||
// Split roles by separator
|
||||
separator := cfg.ProxyAuthRoleSeparator
|
||||
if separator == "" {
|
||||
separator = "|"
|
||||
}
|
||||
roleList := strings.Split(roles, separator)
|
||||
isAdmin = false
|
||||
for _, role := range roleList {
|
||||
if strings.TrimSpace(role) == cfg.ProxyAuthAdminRole {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
log.Debug().
|
||||
Str("roles", roles).
|
||||
Bool("is_admin", isAdmin).
|
||||
Msg("Proxy auth roles checked")
|
||||
}
|
||||
log.Debug().
|
||||
Str("roles", roles).
|
||||
Bool("is_admin", isAdmin).
|
||||
Msg("Proxy auth roles checked")
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
|
|
|
|||
|
|
@ -563,9 +563,32 @@ func TestCheckProxyAuth_RoleCheckingEmptyRolesHeader(t *testing.T) {
|
|||
if username != "testuser" {
|
||||
t.Errorf("username should be 'testuser', got %q", username)
|
||||
}
|
||||
// When role header is empty, isAdmin stays true (default)
|
||||
if !isAdmin {
|
||||
t.Error("isAdmin should be true when roles header is empty")
|
||||
if isAdmin {
|
||||
t.Error("isAdmin should be false when configured roles header is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckProxyAuth_RoleCheckingBlankRolesHeader(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ProxyAuthSecret: "correct-secret",
|
||||
ProxyAuthUserHeader: "X-Remote-User",
|
||||
ProxyAuthRoleHeader: "X-Remote-Roles",
|
||||
ProxyAuthAdminRole: "admin",
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Proxy-Secret", "correct-secret")
|
||||
req.Header.Set("X-Remote-User", "testuser")
|
||||
req.Header.Set("X-Remote-Roles", " ")
|
||||
|
||||
valid, username, isAdmin := CheckProxyAuth(cfg, req)
|
||||
if !valid {
|
||||
t.Error("CheckProxyAuth should return true for valid auth")
|
||||
}
|
||||
if username != "testuser" {
|
||||
t.Errorf("username should be 'testuser', got %q", username)
|
||||
}
|
||||
if isAdmin {
|
||||
t.Error("isAdmin should be false when configured roles header is blank")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19589,3 +19589,35 @@ func TestContract_OrgBoundTokenIsScopedAwayFromDefaultOrg(t *testing.T) {
|
|||
t.Fatal("authenticated users must retain default-org access")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContract_ProxyAuthConfiguredRoleHeaderMissingIsNonAdmin pins the
|
||||
// proxy-auth admin boundary: once an admin role header is configured, admin
|
||||
// authorization must be proven by that header rather than inherited from
|
||||
// successful proxy authentication alone.
|
||||
func TestContract_ProxyAuthConfiguredRoleHeaderMissingIsNonAdmin(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ProxyAuthSecret: "secret123",
|
||||
ProxyAuthUserHeader: "X-Remote-User",
|
||||
ProxyAuthRoleHeader: "X-Remote-Roles",
|
||||
ProxyAuthAdminRole: "admin",
|
||||
}
|
||||
|
||||
handlerCalled := false
|
||||
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/test", nil)
|
||||
req.Header.Set("X-Proxy-Secret", "secret123")
|
||||
req.Header.Set("X-Remote-User", "regular-user")
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
if handlerCalled {
|
||||
t.Fatal("admin handler was called without the configured role header")
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1854,6 +1854,34 @@ func TestRequireAdmin_ProxyAuthNoRoleHeaderDefaultsToAdmin(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRequireAdmin_ProxyAuthConfiguredRoleHeaderMissingForbidden(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
ProxyAuthSecret: "secret123",
|
||||
ProxyAuthUserHeader: "X-Remote-User",
|
||||
ProxyAuthRoleHeader: "X-Remote-Roles",
|
||||
ProxyAuthAdminRole: "admin",
|
||||
}
|
||||
|
||||
handlerCalled := false
|
||||
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/admin/test", nil)
|
||||
req.Header.Set("X-Proxy-Secret", "secret123")
|
||||
req.Header.Set("X-Remote-User", "regular-user")
|
||||
handler(w, req)
|
||||
|
||||
if handlerCalled {
|
||||
t.Error("RequireAdmin should not call handler when configured role header is missing")
|
||||
}
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAdmin_NonAdminSessionForbidden(t *testing.T) {
|
||||
InitSessionStore(t.TempDir())
|
||||
|
||||
|
|
|
|||
|
|
@ -209,6 +209,8 @@ type Config struct {
|
|||
TelemetryEnabled bool `envconfig:"PULSE_TELEMETRY" default:"true"` // Anonymous outbound usage telemetry enabled by default (install ID, version, resource counts, feature flags — opt out any time)
|
||||
MultiTenantEnabled bool `envconfig:"PULSE_MULTI_TENANT_ENABLED" default:"false"` // Enable multi-tenant support
|
||||
MetricsToken string `envconfig:"PULSE_METRICS_TOKEN" default:"" json:"-"` // Bearer token for /metrics endpoint (empty = unauthenticated)
|
||||
MetricsBindAddress string `envconfig:"PULSE_METRICS_BIND_ADDRESS" default:"127.0.0.1" json:"-"` // Bind address for /metrics endpoint
|
||||
MetricsAllowInsecureRemote bool `envconfig:"PULSE_METRICS_ALLOW_INSECURE_REMOTE" default:"false" json:"-"` // Explicit opt-in for bearer-token metrics over remote plaintext HTTP
|
||||
ProTrialSignupURL string `envconfig:"PULSE_PRO_TRIAL_SIGNUP_URL" default:""` // Legacy hosted commercial base URL used for entitlement refresh compatibility
|
||||
|
||||
// Proxy authentication settings
|
||||
|
|
@ -802,6 +804,7 @@ func load(initLogging bool) (*Config, error) {
|
|||
ProTrialSignupURL: pkglicensing.DefaultProTrialSignupURL,
|
||||
EnvOverrides: make(map[string]bool),
|
||||
AgentConnectURL: "",
|
||||
MetricsBindAddress: "127.0.0.1",
|
||||
// Metrics retention defaults (tiered)
|
||||
MetricsRetentionRawHours: 2, // 2 hours of raw ~5s data
|
||||
MetricsRetentionMinuteHours: 24, // 24 hours of minute averages
|
||||
|
|
@ -1430,6 +1433,20 @@ func load(initLogging bool) (*Config, error) {
|
|||
cfg.EnvOverrides["PULSE_METRICS_TOKEN"] = true
|
||||
log.Debug().Msg("Metrics token configured from env var")
|
||||
}
|
||||
if metricsBindAddress := utils.GetenvTrim("PULSE_METRICS_BIND_ADDRESS"); metricsBindAddress != "" {
|
||||
cfg.MetricsBindAddress = metricsBindAddress
|
||||
cfg.EnvOverrides["PULSE_METRICS_BIND_ADDRESS"] = true
|
||||
log.Info().Str("bind_address", metricsBindAddress).Msg("Metrics bind address overridden by environment")
|
||||
}
|
||||
if allowInsecureRemote := utils.GetenvTrim("PULSE_METRICS_ALLOW_INSECURE_REMOTE"); allowInsecureRemote != "" {
|
||||
if enabled, err := strconv.ParseBool(allowInsecureRemote); err == nil {
|
||||
cfg.MetricsAllowInsecureRemote = enabled
|
||||
cfg.EnvOverrides["PULSE_METRICS_ALLOW_INSECURE_REMOTE"] = true
|
||||
log.Warn().Bool("enabled", enabled).Msg("Metrics insecure remote scrape override configured")
|
||||
} else {
|
||||
log.Warn().Str("value", allowInsecureRemote).Msg("Invalid PULSE_METRICS_ALLOW_INSECURE_REMOTE value, ignoring")
|
||||
}
|
||||
}
|
||||
if metricsDBPath := utils.GetenvTrim("PULSE_METRICS_DB_PATH"); metricsDBPath != "" {
|
||||
cfg.MetricsDBPath = metricsDBPath
|
||||
cfg.EnvOverrides["PULSE_METRICS_DB_PATH"] = true
|
||||
|
|
|
|||
|
|
@ -162,6 +162,25 @@ func TestLoad_MetricsStorageEnvOverrides(t *testing.T) {
|
|||
assert.True(t, cfg.EnvOverrides["PULSE_METRICS_ROLLUP_INTERVAL"])
|
||||
}
|
||||
|
||||
func TestLoad_MetricsBindSecurityDefaultsAndOverrides(t *testing.T) {
|
||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "127.0.0.1", cfg.MetricsBindAddress)
|
||||
assert.False(t, cfg.MetricsAllowInsecureRemote)
|
||||
|
||||
t.Setenv("PULSE_METRICS_BIND_ADDRESS", "0.0.0.0")
|
||||
t.Setenv("PULSE_METRICS_ALLOW_INSECURE_REMOTE", "true")
|
||||
|
||||
cfg, err = Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "0.0.0.0", cfg.MetricsBindAddress)
|
||||
assert.True(t, cfg.MetricsAllowInsecureRemote)
|
||||
assert.True(t, cfg.EnvOverrides["PULSE_METRICS_BIND_ADDRESS"])
|
||||
assert.True(t, cfg.EnvOverrides["PULSE_METRICS_ALLOW_INSECURE_REMOTE"])
|
||||
}
|
||||
|
||||
func TestLoad_InvalidMetricsRollupIntervalIgnored(t *testing.T) {
|
||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||
t.Setenv("PULSE_METRICS_ROLLUP_INTERVAL", "2m")
|
||||
|
|
|
|||
|
|
@ -2687,6 +2687,48 @@ func TestGeneratePayloadFromTemplateWithService(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("teams adaptive resolved template escapes resource name", func(t *testing.T) {
|
||||
nm := &NotificationManager{}
|
||||
var teamsAdaptive *WebhookTemplate
|
||||
for _, tmpl := range GetWebhookTemplates() {
|
||||
if tmpl.Service == "teams-adaptive" {
|
||||
teamsAdaptive = &tmpl
|
||||
break
|
||||
}
|
||||
}
|
||||
if teamsAdaptive == nil {
|
||||
t.Fatal("teams-adaptive template not found")
|
||||
}
|
||||
|
||||
data := WebhookPayloadData{
|
||||
ID: "alert-1",
|
||||
Type: "cpu",
|
||||
ResourceName: "db \"primary\" \\\\ prod",
|
||||
Node: "node-1",
|
||||
Message: "Recovered after \"burst\"",
|
||||
Duration: "5m",
|
||||
ResolvedAt: "2026-07-01T08:30:00Z",
|
||||
}
|
||||
|
||||
result, err := nm.generatePayloadFromTemplateWithService(teamsAdaptive.ResolvedPayloadTemplate, data, "teams-adaptive")
|
||||
if err != nil {
|
||||
t.Fatalf("expected valid JSON for escaped teams-adaptive resolved template, got %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(result, &parsed); err != nil {
|
||||
t.Fatalf("result is not valid JSON: %v", err)
|
||||
}
|
||||
attachments := parsed["attachments"].([]interface{})
|
||||
content := attachments[0].(map[string]interface{})["content"].(map[string]interface{})
|
||||
body := content["body"].([]interface{})
|
||||
title := body[0].(map[string]interface{})["text"]
|
||||
wantTitle := "Resolved: " + data.ResourceName
|
||||
if title != wantTitle {
|
||||
t.Fatalf("expected title %q, got %v", wantTitle, title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("telegram service validates JSON", func(t *testing.T) {
|
||||
nm := &NotificationManager{}
|
||||
data := WebhookPayloadData{
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ func GetWebhookTemplates() []WebhookTemplate {
|
|||
"body": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "Resolved: {{.ResourceName}}",
|
||||
"text": "Resolved: {{.ResourceName | jsonString}}",
|
||||
"weight": "Bolder",
|
||||
"size": "Large",
|
||||
"color": "Good"
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@ package server
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
const defaultMetricsBindAddress = "127.0.0.1"
|
||||
|
||||
// ResolveMetricsPortFromEnv returns the configured metrics port using the same
|
||||
// env contract for both the core and enterprise binaries.
|
||||
func ResolveMetricsPortFromEnv(stderr io.Writer, fallback int) int {
|
||||
|
|
@ -29,3 +34,23 @@ func ResolveMetricsPortFromEnv(stderr io.Writer, fallback int) int {
|
|||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func metricsListenAddress(cfg *config.Config, port int) string {
|
||||
bindAddress := defaultMetricsBindAddress
|
||||
if cfg != nil && strings.TrimSpace(cfg.MetricsBindAddress) != "" {
|
||||
bindAddress = strings.TrimSpace(cfg.MetricsBindAddress)
|
||||
}
|
||||
return net.JoinHostPort(bindAddress, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
func metricsAddressIsLoopback(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package server
|
|||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
func TestResolveMetricsPortFromEnvPrefersPrefixedOverride(t *testing.T) {
|
||||
|
|
@ -25,3 +27,27 @@ func TestResolveMetricsPortFromEnvFallsBackOnInvalidValue(t *testing.T) {
|
|||
t.Fatal("expected invalid metrics port warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsListenAddressDefaultsToLoopback(t *testing.T) {
|
||||
if got := metricsListenAddress(&config.Config{}, 9091); got != "127.0.0.1:9091" {
|
||||
t.Fatalf("metricsListenAddress() = %q, want loopback default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsListenAddressUsesConfiguredAddress(t *testing.T) {
|
||||
cfg := &config.Config{MetricsBindAddress: "192.0.2.10"}
|
||||
if got := metricsListenAddress(cfg, 9091); got != "192.0.2.10:9091" {
|
||||
t.Fatalf("metricsListenAddress() = %q, want configured address", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsAddressIsLoopback(t *testing.T) {
|
||||
for _, addr := range []string{"127.0.0.1:9091", "[::1]:9091", "localhost:9091"} {
|
||||
if !metricsAddressIsLoopback(addr) {
|
||||
t.Fatalf("metricsAddressIsLoopback(%q) = false, want true", addr)
|
||||
}
|
||||
}
|
||||
if metricsAddressIsLoopback("0.0.0.0:9091") {
|
||||
t.Fatal("metricsAddressIsLoopback(0.0.0.0:9091) = true, want false")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ func TestStartMetricsServer(t *testing.T) {
|
|||
addr := listener.Addr().String()
|
||||
listener.Close()
|
||||
|
||||
startMetricsServer(ctx, addr, "")
|
||||
if err := startMetricsServer(ctx, addr, "", false); err != nil {
|
||||
t.Fatalf("startMetricsServer returned error: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 200 * time.Millisecond}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
|
|
@ -50,3 +52,52 @@ func TestStartMetricsServer(t *testing.T) {
|
|||
}
|
||||
t.Fatal("expected metrics server to stop after context cancellation")
|
||||
}
|
||||
|
||||
func TestStartMetricsServerRejectsBearerTokenOnRemotePlaintextByDefault(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
err := startMetricsServer(ctx, "0.0.0.0:9091", "secret-token", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected remote plaintext metrics token listener to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartMetricsServerAllowsBearerTokenOnLoopback(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := listener.Addr().String()
|
||||
listener.Close()
|
||||
|
||||
if err := startMetricsServer(ctx, addr, "secret-token", false); err != nil {
|
||||
t.Fatalf("startMetricsServer returned error: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 200 * time.Millisecond}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var authorized bool
|
||||
for time.Now().Before(deadline) {
|
||||
req, err := http.NewRequest(http.MethodGet, "http://"+addr+"/metrics", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer secret-token")
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
authorized = resp.StatusCode == http.StatusOK
|
||||
resp.Body.Close()
|
||||
if authorized {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
if !authorized {
|
||||
t.Fatal("expected bearer-token metrics endpoint to allow loopback request")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,9 +266,12 @@ func Run(ctx context.Context, version string) error {
|
|||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Metrics port is configurable via MetricsPort variable
|
||||
metricsAddr := fmt.Sprintf("%s:%d", cfg.BindAddress, MetricsPort)
|
||||
startMetricsServer(ctx, metricsAddr, cfg.MetricsToken)
|
||||
// Metrics uses a dedicated bind address so bearer-token scrape credentials
|
||||
// do not inherit the public UI/API listener exposure by default.
|
||||
metricsAddr := metricsListenAddress(cfg, MetricsPort)
|
||||
if err := startMetricsServer(ctx, metricsAddr, cfg.MetricsToken, cfg.MetricsAllowInsecureRemote); err != nil {
|
||||
log.Error().Err(err).Str("addr", metricsAddr).Msg("Metrics server disabled")
|
||||
}
|
||||
|
||||
// Initialize WebSocket hub first
|
||||
wsHub := websocket.NewHub(nil)
|
||||
|
|
@ -859,7 +862,11 @@ func agentIngestHandler(inner http.Handler) http.Handler {
|
|||
|
||||
// startMetricsServer starts the Prometheus /metrics endpoint. When metricsToken
|
||||
// is non-empty, requests must include a matching Authorization: Bearer <token> header.
|
||||
func startMetricsServer(ctx context.Context, addr string, metricsToken string) {
|
||||
func startMetricsServer(ctx context.Context, addr string, metricsToken string, allowInsecureRemote bool) error {
|
||||
if metricsToken != "" && !allowInsecureRemote && !metricsAddressIsLoopback(addr) {
|
||||
return fmt.Errorf("metrics bearer token over remote plaintext HTTP is disabled; bind metrics to loopback or set PULSE_METRICS_ALLOW_INSECURE_REMOTE=true")
|
||||
}
|
||||
|
||||
handler := promhttp.Handler()
|
||||
if metricsToken != "" {
|
||||
inner := handler
|
||||
|
|
@ -901,6 +908,8 @@ func startMetricsServer(ctx context.Context, addr string, metricsToken string) {
|
|||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ShouldAutoImport() bool {
|
||||
|
|
|
|||
|
|
@ -2917,7 +2917,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 1106,
|
||||
"line": 1114,
|
||||
"heading_line": 137,
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -314,6 +314,15 @@ test_hot_dev_browser_urls_distinguish_bind_and_browser_hosts() {
|
|||
assert_contains "loopback binds do not advertise a LAN browser URL" "${output}" "loopback_lan="
|
||||
}
|
||||
|
||||
test_go_module_security_dependency_floors() {
|
||||
local output
|
||||
output="$(cd "${ROOT_DIR}" && go list -m golang.org/x/net golang.org/x/crypto golang.org/x/sys)"
|
||||
|
||||
assert_contains "Go module floor keeps x/net past restricted-outbound advisories" "${output}" "golang.org/x/net v0.56.0"
|
||||
assert_contains "Go module floor keeps x/crypto aligned with x/net security floor" "${output}" "golang.org/x/crypto v0.53.0"
|
||||
assert_contains "Go module floor keeps x/sys aligned with security module graph" "${output}" "golang.org/x/sys v0.46.0"
|
||||
}
|
||||
|
||||
source "${HOT_DEV_RUNTIME_LIB}"
|
||||
test_pulse_process_count_handles_zero_matches_under_pipefail
|
||||
test_pulse_process_count_counts_matching_processes
|
||||
|
|
@ -325,6 +334,7 @@ test_hot_dev_network_defaults_are_local_first_with_explicit_lan_opt_in
|
|||
test_hot_dev_lab_agent_mode_enables_lan_and_guest_docker_inventory_defaults
|
||||
test_hot_dev_remembers_explicit_lab_agent_mode_for_later_managed_starts
|
||||
test_hot_dev_browser_urls_distinguish_bind_and_browser_hosts
|
||||
test_go_module_security_dependency_floors
|
||||
|
||||
if (( failures > 0 )); then
|
||||
echo "FAIL: ${failures} hot-dev runtime assertions failed" >&2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue