Commit Graph

729 Commits

Author SHA1 Message Date
Anso 0384c47d1e feat: add posture reasons and review queue to Security overview (#1462)
Add structured posture reasons derived alongside the posture verb in
securityPosture.ts so the masthead and Overview tab can answer why the
page is red, what to do first, and what clears it.

Backend:
- derivePostureReasons() returns blocker, review, and info reasons from
  the same SecurityPostureFacts used by deriveSecurityPosture()
- deriveSecurityPosture() depends on derivePostureReasons() internally
- Exposure split: public exposure with KEV, fixable, or EPSS >= 0.1 is a
  blocker; exposure without any of those is a review item
- Fully dismissed exposed images produce no posture reason
- postureReasons and primaryAction returned by the overview endpoint

Frontend:
- ReviewQueueCard on the Overview tab with per-row CTAs for blockers
- Action summary in masthead subtitle and desktop primary CTA button
- Card gated on posture not being Unknown
- Backward compatible with older remote nodes
2026-06-25 21:56:25 -04:00
Anso 7320a86579 feat: add cron scheduling mode for image update checks (#1460)
* feat: add cron scheduling mode for image update checks

Adds a cron scheduling mode alongside the existing fixed-interval
dropdown in Settings > Automation > Image update checks. Users can
now set a 5-field cron expression (e.g. "0 3 * * 1") for precise
time-of-day scheduling of registry polls.

- Backend: ImageUpdateService gains mode/cronExpression fields and
  cron-based nextDelayMs() using the existing cron-parser dependency.
  PUT /api/image-updates/interval extended with transactional writes
  and server-authoritative cron validation matching the Scheduled
  Operations contract. Nicknames like @daily are supported.
- Frontend: UpdatesSection gains a SegmentedControl toggle and cron
  text input with cronstrue-powered live description. The frontend
  does advisory validation only; backend 400s are surfaced inline.
  SettingsPrimaryButton used for explicit "Save schedule" action.
- No cron jitter (the user chose a specific time). Interval mode
  keeps existing ±10% jitter.
- Tests: 15 new backend tests covering valid cron, invalid cron,
  6-field rejection, nickname support, backward compat, runtime
  fallback, and transactional writes.
- Docs: auto-update-policies.mdx, alerts-notifications.mdx, and
  openapi.yaml updated with new scheduling mode.

* fix: add mode and cronExpression to UpdatesSection test fixtures

The existing tests failed because the mock status object was missing
the new required fields (mode, cronExpression) added with cron
scheduling support. Without them, status.mode was undefined, causing
uiMode to never match 'interval' and the Select combobox to not render.

* fix: prevent SegmentedControl from stretching full-width in SettingsField

The flex-col container defaults items to align-self: stretch, making the
Interval/Cron toggle bar span the full card width. Add self-start so it
sizes to its content.
2026-06-25 19:47:57 -04:00
Anso a698aaa926 feat: add per-stack project env file selection for Docker Compose (#1457)
* feat: add per-stack project env file selection for Docker Compose

Allow users to configure an ordered list of env files per stack that serve
as the project environment file(s) for Docker Compose ${VAR} interpolation.
The selected files are passed via repeated --env-file flags during all
compose commands.

Backend:
- Add stack_project_env_files table (node-scoped, ordered)
- Extend authoredComposeEnvFileArgs to emit --env-file for configured files
- Add GET/PUT /stacks/:name/project-env-files and /candidates endpoints
- Update resolveStackEnvSources to use configured files as interpolation source
- Update resolveAllEnvFilePaths to merge injection + interpolation sources
- Add discoverStackLocalEnvFiles for candidate discovery
- Extend backupStackFiles and snapshotStackFiles for project env files
- Add project-env-files capability to CapabilityRegistry

Frontend:
- Add project env file selector to EnvironmentPanel (capability-gated)
- Update EditorView banner to generic "project environment file" language
- Add project-env-files capability to capabilities.ts

Issue: #1454

* fix: add realpath validation, clear all stale backup files, reject nested paths

- authoredComposeEnvFileArgs: use fsPromises.realpath + isPathWithinBase
  for symlink escape defense at use time
- backupStackFiles: clear ALL non-marker files from backup slot before
  writing, not just PROTECTED_STACK_FILES (handles stale old.env)
- PUT project-env-files: reject paths containing / or \ (root-level
  only, matching Compose auto-discovery behavior)

* fix: add getStackProjectEnvFiles to compose-service mock

The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles
on the DatabaseService singleton. The compose-service mesh-override
tests mock that singleton without the new method, causing 6 failures.
Add getStackProjectEnvFiles: () => [] (empty = fall back to legacy
behavior, which is what these tests exercise).

* fix: add getStackProjectEnvFiles to remaining service mocks

The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles,
which is missing from the mock in compose-images.test.ts (6 failures)
and image-update-service.test.ts (proactive fix).

* fix: apply inline path-injection barrier at fs sink for CodeQL

The PUT project-env-files route resolved paths via isPathWithinBase
before calling fsp.stat, but CodeQL does not credit a containment check
separated from the sink. Apply the canonical inline barrier pattern
(path.resolve + startsWith at the sink) used throughout the codebase.

* fix: resolve stackDir from the same canonical root as safePath

Prevents a containment bypass when the compose base directory is
a symlink: stackDir was previously joined from the unresolved
baseDir while the inline barrier used path.resolve(baseDir),
which could differ for symlinked paths. Now both stackDir and
safePath are resolved from a single canonical root, then each is
containment-checked against it.

* fix: remove unused isPathWithinBase import

The inline path-injection barrier refactor replaced isPathWithinBase
with an inline startsWith check at the fs sink, so the import is now
unused and fails ESLint no-unused-vars.
2026-06-25 18:03:05 -04:00
Anso b7dd9dc1b0 feat: add ON/OFF toggle for host threshold alerts (#1456)
* feat: add ON/OFF toggle for host threshold alerts

Add host_alerts_enabled setting (default ON) as a master switch for CPU,
RAM, and disk host threshold evaluation. When OFF, the four threshold
controls in Settings > Host Alerts are disabled and MonitorService skips
the systeminformation calls and alert dispatch entirely, while clearing
stale suppression state so re-enabling starts fresh.

The dashboard Configuration Status card shows "Off" when host threshold
alerts are disabled. Crash capture, health gate, deploy guardrails,
stack alert rules, and the Docker janitor are all unaffected.

* fix: exit NumberChip edit mode when externally disabled

When the host threshold alerts master toggle is turned OFF while a
NumberChip is in edit mode, force-exit edit mode so the chip renders
the greyed-out button state consistently with the other chips.
2026-06-25 16:05:16 -04:00
Anso f1f64ec7f6 feat: show container name in structured log output (#1452)
* feat: show container name in structured log output

Prepend a normalized container name prefix to each line in
ComposeService.streamLogs() so both the structured log viewer
and the raw terminal identify which container produced each entry.

- Backend: prepend displayName (normalized via normalizeContainerName)
  before LogFormatter.process() in sendOutput and flushBuffer.
- LogFormatter: refactor process() to handle both prefix-first and
  timestamp-first input orders via a while-loop; widen PREFIX_REGEX
  to accept dotted service names.
- Frontend: add containerName to LogRow, extract prefix in parseLine,
  render as an inline mono chip in the message column, and include
  the name in downloaded logs (omitting the bracket prefix when null).
- Tests: 14 new tests across log-formatter, compose-service streamLogs,
  and StructuredLogViewer chip rendering + download formatting.

* fix: guard LogFormatter loop to at most one prefix and one timestamp

The while-loop refactored for order-agnostic prefix/timestamp
parsing could continue matching beyond the intended single prefix
and timestamp. A log line like "redis | 2024-...Z api | started"
would falsely colorize "api |" as a second container prefix in
raw terminal output.

Add prefixFound/timestampFound boolean guards so the loop stops
after one prefix and one timestamp, regardless of input order.

* feat: per-service color alternation for log container chips

Add an Appearance setting that lets users switch between unified
cyan and per-service label-token colors for the container name chips
in the structured log viewer.

- Extract HUE_VARS and hashLabel() from NodeLabelPill into a shared
  utility at frontend/src/lib/label-colors.ts.
- Add useLogChipColorMode hook (browser-local localStorage,
  sencho.log-chip-color-mode key, unified by default).
- Add SegmentedControl in Settings > Appearance > Display.
- Apply inline label-token styles via style attribute in per-service
  mode; keep current text-brand/80 bg-brand/10 classes in unified mode.
- 14 new tests across label-colors, hook, and viewer chip rendering.
2026-06-25 14:13:38 -04:00
Anso 3a22f59057 feat(security): surface Compose internet-reachability exposure in posture (#1442)
* feat(security): surface Compose internet-reachability exposure in posture

Builds a per-stack per-service exposure descriptor from the rendered
effective Compose model, cached at deploy/update time, and joins it into
the Security action posture. A service is publicly exposed when it
publishes a port on a non-loopback host IP or uses host networking.

The exposure cache lives in a new stack_exposure table, refreshed inside
ComposeService.deployStack and updateStack (covering all funneled paths:
manual, scheduler, mesh, templates, labels, App Store, Git, webhooks).
Cleanup runs on stack delete, blueprint withdrawal, and node delete.

The overview route intersects the exposed image set with the existing
per-image suppression-aware Critical/High tally, so a clean public
nginx does not escalate posture. The scan sheet shows a "Published
service" or "Internal only" evidence badge per image.

* fix(test): provide fresh auto-close proc for exposure spawn in stall tests

Two deployStack idle-stall tests used mockSpawn.mockReturnValue(proc)
which returned the same already-closed process for the new config spawn
added by the exposure refresh. The renderConfig promise hung waiting for
a close event that had already fired.

The fix uses mockImplementation to return the controlled proc for the
first spawn (up) and a fresh auto-closing proc for the second spawn
(config via refreshExposureCache).

* fix(security): tighten loopback detection, clarify exposure semantics, drop internal-only badge

- Expand isLoopback to cover full 127.0.0.0/8 range (127.0.0.2 etc)
- Clarify that exposure is configured (Compose model), not live topology
- Remove "Internal only" badge: false is not proof of non-exposure when
  other stacks using the same image may lack a cached descriptor
2026-06-24 23:22:13 -04:00
Anso db8bb70b7d fix(scheduler): reject 6-field cron in Scheduled Operations (#1435)
* fix(scheduler): reject 6-field cron in Scheduled Operations

Create and edit validation parsed cron with cron-parser, which accepts both
5- and 6-field expressions, while the form, presets, and docs all describe a
5-field cron. Because the scheduler ticks once per minute, a leading seconds
field can never improve precision, so a 6-field expression was silently
accepted but never honored on its stated schedule.

Add a field-count guard on both sides: the API rejects 6-field input at
create and edit with a clear message, and the form surfaces the same error
inline and disables save. Cron nicknames such as @daily still pass. Document
the five-field requirement in the cron reference.

* chore: merge main into scheduled cron validation

* fix: avoid logging policy bypass actor in debug output
2026-06-24 23:00:56 -04:00
Anso bc8c051962 feat(scheduler): consistent action targeting in Scheduled Operations (#1431)
Give every scheduled action an explicit, predictable target model
(Action then Node then Stack then Options then Schedule):

- System Prune now exposes a Node picker and requires a node, so it can
  no longer run silently on the default node.
- Vulnerability Scan and System Prune list local nodes only; both run on
  the hub-local Docker daemon and reject remote nodes on the backend.
- Restart Stack service discovery loads services from the selected node
  via fetchForNode instead of the active or local node.
- Fleet Snapshot shows a read-only "Scope: Entire fleet" summary.

Backend gains a shared local-node guard and prune node validation on
create and update, plus an executor-level remote-node guard, so the
frontend and backend validation now agree for every action.
2026-06-24 21:02:15 -04:00
Anso 0af7ad1df2 refactor(scheduler): drive scheduled-action metadata from a shared registry (#1428)
Scheduled-operation action metadata was duplicated across the backend route
validator, the DatabaseService action union, the desktop action picker, the
Timeline lanes, and the mobile labels/tones. Adding or renaming one action meant
editing all of them.

Introduce one registry per package as the single source within that package:

- backend/src/services/scheduledActionRegistry.ts owns the action list and
  target-type validation; routes/scheduledTasks.ts and DatabaseService import
  from it (BackendScheduledAction type, VALID_ACTIONS, validateActionTarget).
- frontend/src/lib/scheduledActions.ts owns the UI metadata (labels, short
  labels, categories, tones, target/node/stack/service flags, helper text) and
  drives the create-flow picker, the All Tasks label, the Timeline lanes, and
  the mobile schedule view.

Timeline lanes now group by semantic category (Lifecycle, Updates, Security,
Maintenance, Backups) sourced from the registry. The update-fleet UI alias is
made explicit via a backendAction field. Backend validation stays authoritative;
parity tests on each side keep the action sets in lockstep.
2026-06-24 20:09:13 -04:00
Anso 6527bc971b feat(security): gate deploys on exploitation risk, not just severity (#1432)
Scan-policy deploy gates can now block on a known-exploited CVE (CISA KEV)
and on a fixable Critical/High finding, in addition to an optional severity
threshold. New policies default risk-first (KEV and fixable on, severity off);
existing policies keep their severity-only behavior. CVSS stays captured for
context but is never the sole basis for a block, and a finding whose
exploitability cannot be confirmed is treated as risky rather than safe
(incomplete scan detail fails closed on KEV/fixable inputs).

The decision logic is shared between the pre-deploy gate and the informational
post-scan banner via a pure helper, so the two never disagree. Block messages
and the block dialog now name the conditions an image matched. Backend and
frontend gates move together, the new inputs replicate across the fleet, and a
blocking policy with no active input is rejected on both sides.
2026-06-24 20:05:17 -04:00
Anso bb4ddde35a feat(sidebar): surface partial status for multi-container stacks (#1426)
Bulk stack-status aggregation collapsed a stack to "running" as soon as any
container was up, so a multi-container stack with crashed containers showed a
green UP pill and the degradation was invisible from the sidebar.

Add a crash-aware "partial" state: a stack is partial when at least one
container is running and at least one has genuinely failed (exited with a
non-zero code, dead, or crash-looping). Cleanly finished one-shot containers
(exit 0) and clean restart-policy cycling do not count, so an app with a
completed init job stays UP. The exit code is read from the container Status
string, so no extra inspect calls are needed.

Render partial as an amber PT pill with a hover tooltip showing the
running/total count, fold it into the Down filter (needs-attention), and treat
it as running for context-menu lifecycle actions so operators keep
stop/restart/update. The dashboard stack-health table, cross-node search rows,
and the command palette all pick up the new state through the shared status
surfaces.
2026-06-24 19:57:49 -04:00
Anso 2eafee3594 fix(networking): treat host-network services as host-exposed in summaries (#1430)
The exposure summaries derived a stack's exposure solely from the declared
published-port list, so a service running with network_mode: host (which
publishes every container port on the host but declares no ports:) was
under-reported as less exposed than it actually is.

Capture network_mode in the lightweight dependency parser, add an
isHostNetwork predicate, and treat a host-network service as exposed and
publishing across the Fleet networking summary, the Stack Dossier export, and
the Networking panel, matching how the Compose Doctor already flags host
networking.
2026-06-24 19:50:45 -04:00
Anso 2ed01641c8 fix(preflight): suppress node-state checks when the Docker snapshot is unavailable (#1423)
When the Docker daemon is unreachable the node snapshot collection fails and
returns empty sets. The preflight rules read those empty sets as "resource
absent", so a stack referencing an external network or volume got false "not
found" blockers while real host-port and container_name conflicts went
undetected.

Add a nodeStateAvailable flag to the preflight context, mirroring the existing
sourceReadable gate. The six node-state rules now suppress themselves when the
snapshot could not be collected, and a single info advisory reports that the
node-state checks were skipped so a clean pass during an outage is not mistaken
for full coverage.
2026-06-24 19:48:34 -04:00
Anso 96b3c49359 fix(deploy): verify atomic-deploy backup integrity before restore (#1422)
* fix(deploy): verify atomic-deploy backup integrity before restore

Atomic deploy and the Rollback action restore a stack from a backup of
its compose file and .env. A backup truncated or corrupted at write time
(out of disk, interrupted copy) was copied back silently, overwriting a
working stack with bad content.

The backup now writes a .checksums manifest holding a SHA-256 of each
backed-up file, and a restore re-hashes every file and compares it before
touching the stack. A mismatch aborts the restore with a clear error and
leaves the live files unchanged. Backups without a manifest, and files
with no recorded checksum, are restored unverified so a rollback is never
blocked by missing integrity data.

* fix(deploy): guard backup source reads with an inline path barrier

The integrity change reads each managed file from the stack directory before
hashing it. Static analysis flags those reads because the source path derives
from the user-provided stack name and the containment check lived in a helper
it does not trace. Re-establish containment inline at each read sink, resolving
against the compose base and confirming the path stays within it, mirroring
snapshotStackFiles. Behavior is unchanged for valid stacks; the guard only
rejects a path that escapes the compose directory, which validation already
prevents.

* test(deploy): assert compose stays put when the backup .env is corrupt

Strengthen the .env-corruption test so it also mutates the live compose.yaml
and asserts it is left untouched, proving the integrity abort halts before any
file is copied back rather than relying on the backup happening to match. Also
note on the test hash oracle that it matches production for UTF-8 text fixtures.
2026-06-24 19:46:59 -04:00
dependabot[bot] 08e46d6bf3 chore(deps): bump the all-npm-backend group in /backend with 8 updates (#1439)
Bumps the all-npm-backend group in /backend with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.18.1` |
| [cron-parser](https://github.com/harrisiirak/cron-parser) | `5.5.0` | `5.6.0` |
| [semver](https://github.com/npm/node-semver) | `7.8.4` | `7.8.5` |
| [systeminformation](https://github.com/sebhildebrandt/systeminformation) | `5.31.7` | `5.31.10` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.3` | `26.0.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.61.1` | `8.62.0` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1070.0` | `3.1075.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1070.0` | `3.1075.0` |


Updates `axios` from 1.18.0 to 1.18.1
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.18.1)

Updates `cron-parser` from 5.5.0 to 5.6.0
- [Release notes](https://github.com/harrisiirak/cron-parser/releases)
- [Commits](https://github.com/harrisiirak/cron-parser/compare/v5.5.0...v5.6.0)

Updates `semver` from 7.8.4 to 7.8.5
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.4...v7.8.5)

Updates `systeminformation` from 5.31.7 to 5.31.10
- [Release notes](https://github.com/sebhildebrandt/systeminformation/releases)
- [Changelog](https://github.com/sebhildebrandt/systeminformation/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sebhildebrandt/systeminformation/compare/v5.31.7...v5.31.10)

Updates `@types/node` from 25.9.3 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `typescript-eslint` from 8.61.1 to 8.62.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.62.0/packages/typescript-eslint)

Updates `@aws-sdk/client-ecr` from 3.1070.0 to 3.1075.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1075.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1070.0 to 3.1075.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1075.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: cron-parser
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: semver
  dependency-version: 7.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: systeminformation
  dependency-version: 5.31.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.62.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1075.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1075.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 16:15:07 -04:00
Anso b1630788ba feat(security): prioritization-led Overview charts (posture + exploit intel) (#1427)
* feat(security): rework Overview charts around posture and exploit intel

Replace the three severity-variation charts (severity donut, top exposed images,
findings by type) with prioritization views, keeping the risk trend for context:

- Action posture: bars of fixable / known-exploited / needs-review / accepted /
  not-affected with a known-exploited headline, derived from the existing
  overview facts (no new fetch).
- Top exploit-risk findings: ranked actionable Critical/High by KEV, then EPSS,
  then CVSS, each row opening its scan.
- Severity x exploitability: a CVSS-by-EPSS quadrant that separates
  scary-but-not-exploitable from act-first.

The two intel panels are fed by a new bounded GET /security/overview/exploit-intel
(latest-scan Critical/High, suppression-filtered, KEV/EPSS joined at read time)
and degrade to clear empty states until exploit intel is fetched and images are
rescanned.

* fix(security): drop the redundant SECURITY kicker from the desktop masthead

The desktop nav strip already names the page, so the masthead's "SECURITY" label
above the posture word was redundant. Make PageMasthead's kicker optional (pages
that pass one render unchanged) and omit it on the Security masthead. The mobile
masthead keeps its kicker, since on the phone layout it is the page identity and
there is no nav strip.

* docs(security): describe the prioritization-led Overview charts

Update the Security overview docs for the reworked chart set (risk trend, action
posture, top exploit-risk, and the severity-by-exploitability quadrant) and note
the exploit-risk charts populate once exploit intelligence is enabled.

* fix(security): move the scanner-detections note into a masthead info icon

Replace the standing "scanner detections show vulnerable components..." caption
below the masthead (desktop and mobile) with an info affordance next to the
scanned-images count in the masthead subtitle. Declutters the overview while
keeping the disclaimer one hover away.

* fix(security): apply "assume it's automatable" to exploit-risk ranking

Absence of exploitability evidence must not be treated as low risk. Rank the top
exploit-risk list by tier (known-exploited > known-high EPSS > unknown EPSS >
known-low EPSS) so an unrated finding outranks one with evidence of low
likelihood; label unrated findings "EPSS n/a"; and reword the quadrant footnote
so excluded findings read as unrated rather than lower risk.
2026-06-23 21:55:11 -04:00
Anso f794702171 feat(security): action-posture Security dashboard with exploit intel and triage (#1424)
* feat(security): reframe masthead as action posture, not worst-CVE severity

Derive the Security masthead from an action posture (Action needed /
Monitoring / Secure / Unknown) instead of raw scanner severity, and label
the raw Critical/High counts as scanner detections. "Secure" now means
nothing is actionable right now, never a claim that no vulnerabilities
exist; Unknown covers a missing scanner or a node with no completed scan.

Phase-1 bootstrap: "actionable" is approximated from the overview facts
that already exist (fixable findings, secrets, misconfigs); a later phase
moves the bucketing to the backend.

* feat(security): derive overview action posture from triaged facts

Add deriveSecurityPosture as the single bucketing function and extend
/security/overview with posture facts (fixableCriticalHigh, dangerousCompose,
accepted, rawCritical/rawHigh, plus knownExploited/publiclyExposed placeholders
that later phases populate) and the derived posture verb.

Suppression- and acknowledgement-aware counts come from one bounded read-time
pass over the latest-scan Critical/High findings, grouped per image so the
existing read-time filters apply unchanged. The pass is capped and flags
posturePartial, so a large node degrades gracefully instead of scanning every
detail row. The masthead now prefers the backend posture and keeps the local
bootstrap only as a fallback for older remote nodes reached through the proxy.

* feat(security): capture Trivy finding enrichment (status, CVSS, vendor, purl, layer)

parseTrivyOutput now keeps the per-finding fields Trivy already returns and we
previously discarded: Status (fixed / will_not_fix / end_of_life / ...), CVSS
(score + vector, preferring the NVD source then falling back), vendor severity,
package URL, package path, and layer digest. Persisted on vulnerability_details
via additive nullable columns (guarded ALTER), bound null when absent, and
carried through the cached-scan reconstruction path.

These fields separate scary from exploitable and feed the action posture and the
per-finding evidence tags. Field paths verified against Trivy's documented
image-scan JSON; covered by parse and insert/read round-trip tests.

* feat(security): add CVE exploit-intel service (CISA KEV + FIRST EPSS)

Add CveIntelService, a daily background cache of CISA KEV membership and FIRST
EPSS scores stored in a new cve_intel table and joined to findings at read time
by CVE id (never frozen onto scan rows, so a CVE entering KEV later lights up on
scans already stored). EPSS is fetched only for CVE ids present in stored
findings, batched; both feeds are best-effort and keep the last cache on
failure, so the Security page degrades gracefully offline. Wired into
startup/shutdown like the other background services.

The overview now counts known-exploited Critical/High findings, and KEV
membership escalates posture to Action needed even when no fix is available.

A per-instance "Exploit intelligence" toggle on the scanner setup surface lets
air-gapped or firewalled hosts disable the outbound fetch; the daily tick keeps
running but skips the fetch body when it is off.

* feat(security): show per-finding evidence tags (KEV, EPSS, vendor status, CVSS)

The vulnerabilities endpoint joins read-time exploit intel (KEV membership and
EPSS score) onto each finding by CVE id, and the scan sheet renders evidence
tags beside each CVE: known-exploited, EPSS probability, vendor will-not-fix /
end-of-life, and the CVSS score. Severity becomes one signal among several so an
operator can tell scary from exploitable, with no invented composite score.

* feat(security): evolve CVE suppressions into triage decisions

Layer a triage status and optional OpenVEX justification onto CVE suppressions.
Statuses: needs review / affected / not affected / accepted risk / fixed / false
positive / ignored. Dismissing states (not affected, accepted, fixed, false
positive, ignored) stop a finding from driving the action posture; needs review
and affected stay actionable and are surfaced as counts. Existing rows default
to "accepted" (the prior suppress behavior), so nothing changes for them.

The overview now reports needsReview / notAffected / accepted as distinct facts
derived from the triage status. The decision replicates across the fleet
(snapshot + replicated-insert carry status + justification) so a replica's
posture matches the control node. The inline suppress dialog gains a triage
decision selector; the read-time filter surfaces the status and justification on
every finding.

* feat(security): export fleet triage decisions as OpenVEX (Admiral)

Add an OpenVEX exporter that turns the instance's CVE triage decisions into a
standard VEX document (not_affected / fixed / affected / under_investigation,
with justifications), and a GET /security/vex/export endpoint to download it.
Authoring fleet VEX is a governance capability, so it is gated to Admiral (paid)
plus admin, mirroring the SARIF export gate; the Suppressions panel shows an
Export VEX action only on Admiral.

* docs(security): document action posture, evidence tags, exploit intel, and triage

Update the Security page and CVE suppressions docs for the action-posture
masthead (scanner detections vs product posture), per-finding evidence tags
(KEV / EPSS / CVSS / vendor status), the exploit-intelligence toggle (CISA KEV +
FIRST EPSS) on scanner setup, triage decisions layered on suppressions, and
OpenVEX export of fleet triage decisions.

* test(security): match intel hosts exactly in CveIntelService test

Route the fetch stub and its call assertions by exact hostname
(www.cisa.gov / api.first.org) instead of a domain substring check.
Resolves the js/incomplete-url-substring-sanitization code-scanning
alerts on the test's URL routing; behavior is unchanged.
2026-06-23 17:42:11 -04:00
Anso 4c47c47a27 docs: caveat interpolated secrets in structural Compose fields (#1425)
The effective-model read surfaces (Networking, Dossier/Anatomy, Storage,
and Compose Doctor) avoid environment, label, and command values, but
docker compose config resolves any ${VAR} interpolation before the model
is parsed, leaving no provenance. A secret interpolated into a structural
field (a bind path, network name, published port, or extra_hosts entry)
is therefore returned resolved. That value is already readable at the
same stack:read scope through the stack's files, so this documents the
caveat rather than changing behavior.

- Reword the "secret-safe / never shows a secret value" claims on the
  Networking, Dossier, Storage, and Doctor docs pages, and add a
  canonical note to the Environment and Secrets Guardrails page steering
  secrets to environment:/env_file: injection.
- Make the matching code comments honest in effectiveAnatomy,
  composeNetworkInspector, effectiveModel (extra_hosts), and the
  effective-anatomy route.
2026-06-23 16:58:26 -04:00
Anso b753d2d5e0 fix(deploy): preserve compose.override.yml when Mesh is enabled (#1420)
When a single-file stack is opted into Sencho Mesh, the deploy builds an
explicit `docker compose -f <base> -f <mesh override>` list. Passing any
explicit -f disables Compose's automatic discovery of compose.override.yml
(and the docker-compose.override variants), so a user's hand-authored
override was silently dropped from the effective deploy once Mesh was on.

Resolve the user's override file (first existing variant, with the same
stack-name and symlink-containment guards as the base compose file) and
insert it between the base and the mesh override, so it layers exactly as
Compose's implicit discovery would, with the mesh override still taking
precedence. A transient read failure during the lookup degrades to "no
override" rather than failing the deploy; a stack-name or containment-guard
rejection still aborts. Multi-file Git-source stacks and non-mesh deploys
are unaffected.
2026-06-23 10:44:49 -04:00
Anso b2713b8a4d refactor(self-update): isolate helper run-args builder and lock argv path safety (#1419)
Extract the docker run argv construction for the self-update helper container
from triggerUpdate() into a pure, exported buildSelfUpdateRunArgs, mirroring
the existing buildSelfUpdateComposeCmd helper. The emitted argv is unchanged;
the extraction makes the mount and flag ordering, the dedup rules, and the
subpath-skip guard unit-testable.

These operator-set paths (the compose working dir, the /app/data host path,
and forwarded bind-mount sources) are passed as discrete argv elements to
execFile, which spawns docker without a shell, so a path containing shell
metacharacters stays inert data and cannot alter the recreate command. New
tests lock the exact argv ordering and the dedup behavior against regression.
2026-06-23 10:01:09 -04:00
Anso bf18fbbb9a test(audit-log): assert PAID_REQUIRED code on Community-blocked stats and export (#1417)
The /stats and /export routes run the paid-license guard before the
permission guard, so a Community admin's rejection comes from the paid
gate. The existing tests only checked the 403 status, which a permission
gate would also produce, so they did not prove which gate fired.

Assert res.body.code === 'PAID_REQUIRED' on both so each test pins the
rejection to the paid gate, matching the assertion already used in the
secrets suite.
2026-06-22 20:55:32 -04:00
Anso 82cc13951b fix(containers): guard container and port reads with stack:read (#1416)
The container list, the per-container log stream, and the ports-in-use
endpoint served data with only the global auth gate, while every mutating
route on the same router already enforced a role check. Align these reads
with the read model used across the stacks router by gating each on
stack:read, so a future restricted role cannot read container output it is
not entitled to. Every current role carries stack:read, so behavior is
unchanged today; this closes the gap before a more limited role exists.

Adds an authorization test covering denial, the admitted read model, and the
guard running ahead of the log stream's header flush.
2026-06-22 20:54:04 -04:00
Anso 69ba0e6d21 fix(stacks): harden stack file path containment against symlink escapes (#1415)
* fix(stacks): harden stack file path containment against symlink escapes

The legacy managed-stack methods enforced path containment lexically
(path.resolve + startsWith), which does not follow symlinks. A stack
directory under the compose root that is itself a symlink or junction
could let a managed-file operation (write compose.yaml/.env, delete a
stack, backup, restore, snapshot) follow the link and read, write, or
delete a file of the same name outside the compose root.

Add a realpath-based containment guard that walks up to the deepest
existing path component, confirms its canonical location is inside the
canonical compose root, and rejects both an out-of-tree resolution and a
dangling symlink (which a write or mkdir would still follow). The guard
runs at every legacy managed-stack sink, alongside the existing lexical
barriers. A legitimately symlinked compose root is not a false positive
because both sides are canonicalized through the same link, and the
guard is a no-op for not-yet-created targets so stack creation and the
flat-to-directory migration are unaffected.

* fix(stacks): satisfy the path-injection sanitizer in the symlink containment guard

assertRealWithinBase resolves a user-derived path and probes it with
realpath/lstat to run the containment check, which static analysis flags
as path injection because the probes lacked the inline barrier its
sanitizer recognizes. Add the canonical path.resolve + startsWith barrier
at the top of the helper, the same form every other sink in this file
uses, and seed the realpath walk from the sanitized value.

Behavior is unchanged: callers always pass an absolute, already-contained
path, so the barrier is a no-op pre-check for them, and the realpath walk
still catches the symlink and dangling-link escapes.
2026-06-22 19:53:59 -04:00
Anso 37e6e48b40 feat(files): copy & duplicate, bulk actions, disk-backed uploads, and an accessible file tree (#1409)
* perf(files): spool uploads to disk instead of buffering in memory

Switch the stack file-explorer upload from multer memoryStorage to
diskStorage and stream the spooled temp file through the file-root
gateway, so an upload is never held fully in RAM. Authorization and
root resolution now run before multer spools, so an unauthorized or
read-only-root request is rejected without writing a temp file, and the
spool is removed on every exit path. The named-volume helper write
verifies the written byte count, since cat cannot report a short write.

* feat(files): copy and duplicate files in the explorer

Add a copy capability to the stack file explorer: a same-folder
Duplicate (auto-suffixed name) and a "Copy to..." destination picker,
on both filesystem and named-volume roots. Copying is within-root,
symlink-leaf-safe, blocks a directory copy into its own subtree, and
refuses to create a protected name (compose/.env) at the stack root
while still allowing a protected file to be duplicated under a new name.

* feat(files): make the file tree keyboard accessible

Bring the stack file explorer tree to the WCAG tree pattern: rows are
treeitems carrying aria-level, aria-selected, and aria-expanded, with a
single roving tabindex and full keyboard navigation (arrow keys,
Home/End, Enter/Space) over a flattened visible-node list that stays in
lockstep with the rendered rows. A polite live region announces the
selected file. No visual change to the tree.

* feat(files): bulk select, delete, move, and download files

Add multi-select to the stack file explorer (checkboxes plus Shift and
Ctrl/Cmd click over the visible order) driving three bulk actions:
delete, move, and download as a streamed .tar.gz. All run within the
active root on both filesystem and named-volume backends, report
per-item results so partial failures surface (with the failed items
kept selected for retry), normalize ancestor/descendant selections
server-side, and cap the archive entry and byte counts before any
bytes are streamed. Protected compose/.env files are excluded from
bulk delete and move but may still be downloaded.

* docs(files): document copy, bulk actions, and keyboard navigation

Add the copy/duplicate and multi-select bulk delete/move/download
sections to the Files & Volumes page, a keyboard-navigation note for the
tree, an updated context-menu reference, and bulk troubleshooting entries.

* fix(files): inline path-injection barriers at the new file-op sinks

CodeQL js/path-injection does not credit the wrapped isPathWithinBase
containment check, so the new copy/bulk/disk-upload flows tripped the
gate. Inline the canonical path.resolve + startsWith barrier at the
realpath sink in resolveSafePathWithin (covers every user-relPath flow)
and confirm the multer spool path resolves within UPLOAD_TMP_DIR before
unlinking it or streaming it onward. Behavior is unchanged; the paths
were already validated.

* fix(files): guard the ancestor-walk realpath sink too

The first barrier covered realpath(target), but the ENOENT ancestor
walk re-derives the path via path.dirname, which static analysis treats
as a fresh tainted value. Add the same inline containment barrier before
that realpath and resolve the root case via the untainted base, so the
only tainted realpath input is one the startsWith check has cleared.
Behavior is unchanged.

* fix(files): resolve the root case off the taint path in the ancestor walk

The compound guard on existing (the same variable as the startsWith
subject) was not credited as a sanitizer. Handle the root case before
the barrier by resolving the untainted base directly, leaving a plain
canonical startsWith guard on the strictly-within ancestor. Behavior is
unchanged.

* fix(files): harden helper-backend bulk download and uploads

Address three issues found in the named-volume (helper) backend:

- Bulk download could send 200 headers before discovering a file the
  helper download path refuses, tearing the archive mid-stream. The
  prewalk now rejects symlinks, non-regular ("other") entries, and
  files over the per-file download cap before any header (400/413).
  FileEntry gains an 'other' type so non-regular entries stay distinct
  from regular files as they pass through the gateway.
- The helper directory listing was fully buffered before the archive
  entry cap could fire. listDir now accepts a limit; the list script
  stops after limit+1 rows and the gateway reports truncation.
- A stdin pipeline error during a helper upload masked the container's
  real nonzero exit code (and its 4xx mapping) as a generic 500. The
  nonzero exit now wins; the masked stream error is logged.

* feat(files): add a New file toolbar button with server-enforced create-only

The stack file explorer could create a folder from a toolbar button but a
new file only from a folder's right-click menu, so a file could not be
created at the stack root at all. Add a New file toolbar button beside New
folder, targeting the current directory.

Creating a file now routes through a new createEmptyStackFile helper that
posts a zero-byte file through the existing upload endpoint with overwrite
off, so the server's exclusive-create path rejects an existing name instead
of clobbering it. A file collision surfaces inline in the dialog; a folder
collision and other failures surface as a toast.

* fix(files): widen the tree row hit area and add horizontal scroll for long names

Right-clicking a file tree row only opened the Sencho context menu when the
click landed on the filename; the rest of the row fell through to the native
browser menu, and long names were truncated with no way to read them.

Make each row span the full pane width (and grow with its content) so the
whole row is the context-menu trigger, and let the tree scroll horizontally
so a long name is reachable instead of clipped. A new opt-in horizontal prop
on ScrollArea adds the styled horizontal scrollbar without clamping content
width.

* docs(files): document the New file button, full-row right-click, and long-name scrolling

* test(files): cover createEmptyStackFile targeting the stack root

Add an API-layer case for the empty-directory (stack root) create path, the
primary reason the New file toolbar button exists, so a regression in the
root-level URL would be caught at unit speed rather than only in e2e.
2026-06-22 19:33:06 -04:00
Anso 9480cc98bb fix(rate-limit): key authenticated requests by verified JWT, not unverified decode (#1412)
The hybrid rate-limit key generator bucketed authenticated requests by a
username read from an unverified jwt.decode of the session cookie or Bearer
JWT. One source could fragment the per-source cap by rotating a forged JWT with
a varying username, minting a fresh bucket per value. Each forged request is
still rejected at auth, so this is DoS amplification, not an auth bypass; the
same class as the API-token vector hardened earlier.

Verify the JWT signature against the cached signing secret before keying by
username; forged, expired, or otherwise invalid credentials fall back to
per-IP. Enforce strict bearer-over-cookie precedence so a present-but-invalid
Bearer cannot be rescued into a valid cookie's bucket. The verification helper
fails closed: any error degrades to per-IP rather than throwing out of the key
generator.
2026-06-22 16:54:30 -04:00
Anso f9c6c5fd09 fix(drift): reconcile the drift ledger on deploy and timestamp its history (#1405)
* fix(drift): reconcile the drift ledger on deploy and timestamp its history

The drift ledger (persisted history + activity timeline) only advanced
when someone clicked re-check on a stack's Drift tab, so the history could
sit indefinitely out of sync with the live status: a stack reading
"drifted" live while its history still said "resolved". Two corrections:

- Deploy and update reconcile the ledger against the just-deployed runtime
  (the rollback route re-deploys through deployStack, so it is covered),
  resolving what the change fixed and recording what it left.
- Every authoritative reconcile stamps the dossier last-checked time, and
  the Drift tab labels its history "checked {time}" so a stale finding
  reads as history, not a claim about the live status above it.

Adds the last_drift_check_at column and tests across the ledger reconcile
stamp, reconcileStack, the deploy hook, and the panel.

* fix(drift): stamp last-checked inside the ledger transaction

Move the dossier last-checked stamp into the same transaction as the
finding insert/resolve, so the "checked {time}" the Drift tab shows can
never persist without the ledger update it describes. The stamp still runs
on a no-op authoritative check (a transaction that only stamps), keeping
the history "as of" honest. Adds a test that a failed deploy does not
reconcile the ledger.
2026-06-21 18:20:57 -04:00
Anso b9d8e9f490 feat(stacks): browse and edit mounted volume files in the explorer (#1403)
* feat(stacks): browse and edit mounted volume files in the explorer

Reposition the stack file explorer around runtime configuration access:
discover a stack's declared mounts and expose each as a safe, stack-scoped
file root. The explorer opens on a Volumes group (bind mounts and named
Docker volumes) by default, with the stack source directory as a secondary
group, on a "Files & Volumes" tab.

- Discover roots from the rendered effective compose model; resolve named
  volumes to their Docker name and browse/edit them through the hardened
  helper container, with bind mounts handled directly when reachable.
- Re-derive the allowed roots server-side on every file operation and match
  the client root id against them, so a request can never address a path the
  stack did not declare. Block dangerous host mounts and binds that overlap
  Sencho's managed directories; reject writes to read-only mounts.
- Thread an optional root id through the existing file endpoints and an
  opaque, parseable optimistic-concurrency token through read, conflict,
  and write, for both filesystem and helper backends.
- Keep compose and env file protection on the stack source root only.

* fix(stacks): theme the Files & Volumes root switcher

Replace the raw native select in the file-root switcher with the design
system Select component. The native control did not honour the dark theme,
so the panel rendered white with unreadable text. The themed Select gives a
dark popover with grouped Volumes / Stack source labels and disabled items.

* fix(stacks): contain the bind-root probe and de-taint the file-op error log

Gate the volume-root bind probe's realpath/stat behind a compose-base
containment check (mirroring the storage host-path probe) so they never run
on an unvalidated host path; a source outside the compose dir is unreachable
in the containerized deployment anyway and is reported non-accessible without
touching the filesystem. Log the helper-backed file-op failure through a
constant format string with sanitized arguments instead of an interpolated
template literal.

* fix(stacks): inline the bind-probe containment guard at the fs sinks

The wrapped containment predicate was not recognized as a path barrier, so
the bind probe's realpath/stat still flagged as uncontrolled-data-in-path.
Inline the path.resolve + startsWith check directly at each filesystem sink
(and re-check the resolved canonical before stat, so a within-base symlink
that resolves outside the compose dir is also rejected).

* fix(stacks): harden file-root lifecycle, upload race, and helper errors

Address review findings on the Files & Volumes feature:

- Invalidate the file-root allowlist on stack create/delete/import/from-git
  (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a
  stack deleted and recreated under the same name cannot serve the old stack's
  roots from the 15s cache.
- Use the atomic exclusive write for a non-overwrite upload so a file created
  by another writer after the existence check is not silently clobbered.
- Let the helper's real cd errno through and map permission failures to 403
  consistently across list/stat/read/write/mkdir/delete/pathKind, instead of
  reporting EACCES as 404/500; pathKind no longer reports a permission-denied
  parent as absent.
- Document the realpath-then-open TOCTOU as a known, pre-existing limitation of
  every file op (O_NOFOLLOW is not viable because config volumes legitimately
  contain symlinks); the bind root is contained to the compose dir and the op
  requires stack:edit.
- Docs: drop a missing screenshot reference and correct the protected-file
  delete behavior (stack-root compose/.env cannot be deleted via the explorer).
2026-06-21 18:16:20 -04:00
Anso b611f41872 fix(drift): stop flagging declared external networks as drift (#1402)
The spatial drift engine reported a service attached to a declared
external network (top-level networks: { foo: { external: true } }) as
"attached to a network not declared in compose", marking the stack
permanently drifted. runtimeResourceName project-prefixed every declared
network to <project>_<key>, but Docker never prefixes an external
network: it references a pre-existing network by its real name. The
phantom <project>_<key> never matched the runtime name, so the
attachment fell through to a foreign-network finding.

Resolve external networks to their real name (the key, or a name:
override) without the project prefix, so the raw declared adapter agrees
with the rendered model the Network Inspector already used. Add unit,
adapter, and engine-level regression tests, and extend the adapter
equivalence test to cover an external network with no name override.
2026-06-21 17:58:00 -04:00
Anso 9ea2864d60 feat: per-stack storage inventory and portability guardrails (#1399)
* feat: per-stack storage inventory and portability guardrails

Add a Storage tab to the stack Anatomy panel that derives a per-stack mount
inventory (bind mounts, named/anonymous volumes, tmpfs, docker socket;
read-only vs read-write; host-path existence, type, and owner) from the
effective Compose model, and classifies the stack as Portable, Partially
portable, Node-bound, or Unknown with the reasons behind it.

- New GET /api/stacks/:stackName/storage route (stack:read, Community), served
  by an on-demand, non-persisted service that renders the effective model,
  probes within-stack bind sources (symlink-escape aware), and runs the
  deterministic portability classifier.
- Extend the effective-model parser additively with a full per-mount inventory
  and service-level tmpfs, leaving the rule-facing binds/namedVolumes
  byte-identical for the existing preflight rules.
- New anonymous-volume preflight finding.
- Admin-visible "no recent snapshot" warning that reuses the existing hub-local
  snapshot-coverage endpoint, plus a static note distinguishing config
  snapshots from application-data backups.
- Surface storage assumptions in the Stack Dossier markdown export.
- Gate the tab behind a new compose-storage capability on both sides.

* docs: phrase the Storage tab availability as current behavior

Replace the "older Sencho version / until it is updated" wording in the
Storage feature page with present-tense, capability-based phrasing.
2026-06-20 15:06:26 -04:00
Anso 57a0856ffc feat(stacks): per-stack environment inventory and secret-safe guardrails (#1397)
* feat(stacks): per-stack environment inventory and secret-safe guardrails

Add an Environment tab to Stack Anatomy that derives a per-stack inventory
of environment variables from the compose files and env files. Each variable
shows its source, whether Compose interpolates it or injects it into a
container, and a status (present, missing, unused, duplicate, or shell-only),
plus likely-secret classification. The inventory works from variable names
only: a value is never read, returned, or logged, and a likely secret shows
presence only. A copy env checklist action exports names and status without
values.

Surface a missing required env_file as a Compose Doctor preflight finding,
and add an opt-in node setting that refuses a deploy or update when a
required ${VAR:?...} variable is unset or empty, before any backup, pull, or
up runs. Default off.

The Environment tab is capability-gated so it hides on older remote nodes.

* fix(stacks): harden env-file reader against a stat-then-open race

Open the env-file handle first and fstat the open handle instead of
stat-ing the path before opening, removing the check-then-use window in
readEnvFileKeys. Use a secure mkdtemp directory for the out-of-base test
path instead of a predictable name in the temp root.

* fix(stacks): resolve nested env_file paths per compose file, reconcile inline keys per service

Resolve each env_file relative to the directory of the compose file that
declared it, so a nested multi-file Git override (infra/prod.yml referencing
./prod.env) lands next to that file instead of the stack root. The root
compose file is unaffected, since its directory is the stack directory.

Reconcile inline environment provenance per service, so a key an override
removed from one service's effective env is not labeled compose-inline just
because another service injects the same name from a different source.
2026-06-20 11:58:42 -04:00
Anso d26ab58189 feat(fleet): cross-node bulk label assign with authoritative label discovery (#1389)
* feat(fleet): cross-node bulk label assign with authoritative label discovery

Make Fleet Actions > Bulk label assign work across the fleet. Pick a stack
label that exists anywhere in the fleet, select stacks on one or more nodes,
and the control orchestrates: each target node resolves the label by name,
creating it with the same name and color if missing, then adds it to the
selected stacks while preserving their existing labels. The local node runs
in process; each remote runs its own admin-only local-assign receiver over
the node proxy. Per-node failures (unknown node, no proxy target, unreachable,
mixed-version remote) degrade that node only and are reported per node in the
result. Assignment writes use a transactional INSERT OR IGNORE so the
add-preserve path is idempotent and race-free.

Also make the shared fleet label discovery authoritative: suggestions,
match-preview, and the fleet-stop remote leg now read each node's labels live
over the proxy instead of the control database, which does not mirror remote
labels. A propagated label therefore appears in, and is stoppable by,
Stop-by-label across the fleet, and unreachable nodes are surfaced rather than
silently dropped.

Fleet Actions runs against the unfiltered node list, so overview filters no
longer narrow its scope. The previous node-scoped, replace-by-id bulk-assign
endpoint is removed.

* fix(fleet): treat malformed remote label responses as per-node failures

A 200 response from a remote node whose body is not the expected shape was
treated as a benign empty result, so a malformed remote could read as a clean
zero-stack assign or a "matched, nothing to stop" no-op and even surface a
success toast. Validate the wire shape in the bulk-assign and fleet-stop remote
legs and in the authoritative label discovery fan-out; on a malformed body,
report the node as a per-node failure with the error attributed to its stacks
instead of silently dropping it.

* chore: drop accidentally committed temp file
2026-06-20 11:52:28 -04:00
Anso 29c599d370 fix(stacks): return a handled response when saving env to a stack with no env file (#1393)
PUT /api/stacks/:stackName/env resolved no env path when a stack had a
compose file but no .env on disk. It then wrote to an undefined path,
which threw and surfaced as an opaque 500. Guard the missing-env case
and return a clean 404 so direct API callers get an actionable response
instead of a crash. The editor still only edits an existing env file, so
behavior in the UI is unchanged.
2026-06-19 00:44:36 -04:00
Anso 2821e87d3f fix: gate fleet stop-by-label on a resolved preview and validate remote stop responses (#1392)
* fix: gate fleet stop-by-label on a resolved preview and validate remote stop responses

The destructive "Stop fleet by label" action could run before its blast
radius was known, and a malformed remote response could be rendered as a
successful zero-stack stop. Both are release blockers for the fleet
stop-by-label flow.

Gate the "Stop fleet" button on a resolved blast radius: the live
match-preview resolving to at least one matching stack, or a dry run of the
current label when the preview endpoint is unavailable. Carry the resolved
node and stack list into the confirm modal so the operator confirms against
the concrete targets rather than a label name alone. Editing the label
invalidates a prior dry-run snapshot, so a stale blast radius cannot
re-enable the action.

Validate the remote local-stop 200 body before trusting it. A body that is
not the local-stop contract (missing matched flag, non-array results, or a
malformed result element) now fails that node with a clear error, consistent
with the non-ok and unreachable paths, instead of defaulting matched to true
and results to empty.

* fix: ignore stale fleet stop-by-label preview responses after the label changes

The debounced match-preview callback set the preview state unconditionally,
so a request issued for one label that resolved after the operator switched
to another label would mark the preview ready with the old label's blast
radius. That re-enabled the destructive Stop and showed stale nodes/stacks
in the confirm modal for a label that no longer matched them.

Guard the in-flight request with a per-run cancelled flag flipped in the
effect cleanup, so a response for a label that is no longer current cannot
set the preview. This mirrors the cancellation pattern already used by the
suggestions effect. Add a test that holds a preview request in-flight,
switches the label, then resolves the stale response and asserts Stop stays
disabled and the old targets do not leak.
2026-06-18 23:29:20 -04:00
Anso ba09e6f69e fix: base Git multi-file Compose deploy env and dossier on the effective config (#1391)
* fix: resolve the root .env at deploy and render time for Git context-dir stacks

A Git multi-file source with a context dir set --project-directory to that
dir, so Docker Compose looked for .env there and missed the root .env Sencho
writes. Validation already passed the root .env with --env-file, so a stack
could validate with one effective config but deploy or render another.

Add authoredComposeEnvFileArgs, which appends --env-file <stackDir>/.env when
the applied deploy spec has a context dir and a root .env exists, and wire it
into the deploy/update, image-scan, render, and container-listing compose
invocations so they all resolve env from the same file the validator used. A
non-ENOENT access error surfaces instead of silently dropping the flag.

* fix: base multi-file Git dossier and doc-drift on the effective Compose model

The Stack Dossier and its documentation-drift check parsed only the stored root
compose file. For a multi-file Git source, services, ports, networks, or volumes
that an override file adds were invisible, so the dossier showed incomplete facts
and doc-drift could falsely warn that a documented port is unpublished when an
override actually publishes it.

Add a secret-safe GET /stacks/:name/effective-anatomy that renders the merged
effective model and extracts only structural facts (services, ports, volumes,
networks, restart), never env, label, or command values. StackAnatomyPanel
fetches it for multi-file Git stacks and feeds those facts into the dossier and
doc-drift, falling back to the root-only parse for single-file or non-git stacks
and whenever the render is unavailable.

* fix: add an inline path-injection barrier to the Git env-file resolver

CodeQL js/path-injection flagged the fs.access in authoredComposeEnvFileArgs
because the env path derives from the route-supplied stack name and the only
containment check lived in the callers, not at the sink. Resolve the stack dir
against the compose base and assert containment with startsWith inline, then
derive the .env path from the validated dir, mirroring the existing inline guards
in renderConfig and validateCompose. Valid stack names are unaffected; a name
that escapes the base now yields no --env-file.

* test: stabilize the dossier doc-drift e2e against the dossier-load race

The first assertion filled the access_urls field as soon as the Dossier panel
was visible, but the panel's GET /stacks/:name/dossier resolves by overwriting
the fields from the server (empty access_urls) and only then flips the doc-drift
gate on. When the GET landed after the fill, it clobbered the typed value and the
warning never rendered, so the test failed intermittently under CI timing. Wait
for that GET to land before typing, mirroring the spec's openStack helper.
2026-06-18 18:25:58 -04:00
Anso 5f1baa7522 fix: harden deploy/update concurrency and node-targeting safety (#1390)
* fix: harden deploy/update concurrency and node-targeting safety

Release stabilization for deploy/update operational safety.

Per-stack operation locking is now global. Background lifecycle paths
(scheduler auto stop/down/start/backup/update, webhook execute, Git source
auto-deploy, image auto-update, label bulk actions, fleet snapshot redeploy,
and mesh redeploy) acquire the per-node, per-stack lock through a new
StackOpLockService.runExclusive helper and skip rather than race a manual
deploy/update/rollback/backup on the same stack and node. Skips surface
honestly (a failed scheduled run, a recorded webhook failure, a per-stack
batch result, or a thrown error) instead of a silent no-op.

Update readiness and policy-bypass now run against the node captured when the
dialog opened, not the live active node, so switching nodes while a dialog is
open cannot retarget the update or the bypass retry.

Rollback readiness no longer presents a moving-tag or unpinned image as a ready
image revert. Restoring files does not revert a moving tag, so those stacks
read as partial, and the rollback success message states that the compose and
env files were restored.

* fix: lock blueprint reconcile against manual ops and correct rollback wording

Follow-up to the deploy/update safety hardening, closing two more gaps from a
verification pass.

BlueprintService.deployLocal and withdrawLocal called ComposeService directly,
so blueprint reconciliation could race a manual deploy/update/rollback/backup on
an owned stack. Both now run their compose lifecycle call through
StackOpLockService.runExclusive and skip (recorded as a failed reconcile,
retried on the next cycle) on conflict. The withdraw holds the lock across both
the compose down and the directory delete so neither races a manual operation.

The runtime rollback messages overstated recovery: a rollback restores the
compose and env files and recreates containers, but does not revert an image
behind a moving tag. The auto-rollback deploy-progress output, the recovery
panel and chip, the failure toasts, and the manual rollback route message now
state that the compose and env files were restored, with the matching OpenAPI
example and atomic-deployments doc updated.

* fix: acquire stack lock before blueprint deploy mutates compose and marker files

Local blueprint deploy wrote the compose and marker files and ran the policy
assert before acquiring the per-stack lock; the lock only wrapped the deploy
itself. A reconcile could therefore rewrite an owned stack's files while a
manual deploy/update/rollback/backup was running. The lock now wraps the whole
critical section (create, write compose, write marker, policy assert, deploy),
so on conflict nothing is written and the reconcile records a failed outcome.

Adds a test asserting a deploy under a held lock records failed, writes no
marker file, and leaves the manual lock untouched.

* fix: make remote blueprint apply atomic under the receiving node's stack lock

Remote blueprint deploy wrote the compose and marker files to the target node
via separate HTTP calls and only locked on the final deploy, so the file writes
could race a manual operation on that node. A node's operation lock is
process-local and cannot be held by the hub across HTTP calls, so the locked
create/write/deploy now runs on the receiving node.

The locked critical section is extracted into BlueprintService.applyLocalUnderLock
and exposed via POST /api/blueprints/apply-local. The hub posts the blueprint to
that endpoint in one call; the receiving node runs create + write compose+marker
+ deploy under its own per-stack lock. Older nodes without the route answer 404
and fall back to the legacy multi-call flow. The endpoint is gated by paid tier
and the same per-stack stack:edit and stack:deploy permissions as the
PUT-compose + deploy it bundles, validates the stack name, compose size, and
marker structure, and returns 409 on a lock conflict without writing anything.

Adds tests for the atomic single-call path, the 404 legacy fallback, the 409
lock-conflict mapping, the route validation and permission paths, and the
write-compose-then-marker-then-deploy ordering of the shared locked apply.

* fix(deps): bump undici to 7.28.0 to clear high-severity advisory

The frontend CI npm audit gate (--audit-level=high) failed on a transitive
undici 7.25.0 (a dev-only dependency via jsdom): TLS certificate validation
bypass (GHSA-vmh5-mc38-953g) and cross-user cache information disclosure
(GHSA-pr7r-676h-xcf6). Bumping undici within jsdom's existing ^7.25.0 range to
7.28.0 clears the high-severity advisory and unblocks the frontend job. Lockfile
only; no direct dependency or source change.
2026-06-18 13:38:19 -04:00
dependabot[bot] c0f84cb04b chore(deps): bump the all-npm-backend group in /backend with 11 updates (#1387)
Bumps the all-npm-backend group in /backend with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` |
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` |
| [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) | `4.1.0` | `4.1.1` |
| [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) | `1.38.4` | `1.38.5` |
| [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `25.9.3` |
| [eslint](https://github.com/eslint/eslint) | `10.4.1` | `10.5.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.61.0` | `8.61.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1065.0` | `3.1070.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1065.0` | `3.1070.0` |


Updates `axios` from 1.17.0 to 1.18.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0)

Updates `better-sqlite3` from 12.10.0 to 12.11.1
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1)

Updates `http-proxy-middleware` from 4.1.0 to 4.1.1
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/master/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v4.1.0...v4.1.1)

Updates `isomorphic-git` from 1.38.4 to 1.38.5
- [Release notes](https://github.com/isomorphic-git/isomorphic-git/releases)
- [Commits](https://github.com/isomorphic-git/isomorphic-git/compare/v1.38.4...v1.38.5)

Updates `multer` from 2.1.1 to 2.2.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0)

Updates `@types/node` from 25.9.2 to 25.9.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.4.1 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.1...v10.5.0)

Updates `typescript-eslint` from 8.61.0 to 8.61.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

Updates `@aws-sdk/client-ecr` from 3.1065.0 to 3.1070.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1070.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1065.0 to 3.1070.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1070.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: better-sqlite3
  dependency-version: 12.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: http-proxy-middleware
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: isomorphic-git
  dependency-version: 1.38.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: multer
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@types/node"
  dependency-version: 25.9.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1070.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1070.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 13:26:06 -04:00
Anso cb58cc423f fix(fleet): resolve Stop-by-label stack labels across all nodes (#1382)
* fix(fleet): resolve Stop-by-label stack labels across all nodes

The Fleet Actions "Stop by label" card only ever saw the control node's own
stack labels. The stack-label routes are proxied, so each node stores its
labels in its own database and the control holds no mirror for remote nodes.
The suggestions, match-preview, and fleet-stop endpoints all read that
nonexistent mirror, so remote-only labels were invisible and remote stacks
were skipped before the remote was ever asked.

Make all three authoritative across the fleet with a new
collectFleetLabelSummaries helper: the local node reads its own database,
and each remote is queried live through its labels and label-assignments
endpoints over the proxy, with fail-closed parsing and per-node
reachability. fleet-stop drops the mirror pre-check and always calls each
reachable remote's local-stop receiver, reporting unreachable nodes at the
node level so they never block the reachable ones.

The picker now surfaces remote-only labels, aggregates shared names once
with combined counts and the carrying node names, and flags incomplete
coverage when a node is unreachable. The preview groups matches per node,
lists unreachable nodes separately, and distinguishes no matching stacks
from "label exists but no stacks" from "remote unavailable".

* fix(fleet): harden Stop-by-label card against malformed responses

Guard the match-preview and fleet-stop response bodies so a malformed but
200 reply degrades instead of crashing or misreporting. match-preview now
validates the per-node shape before rendering (the preview reads it outside
any try/catch) and logs a malformed body; fleet-stop distinguishes a
non-array results body (a server bug, now logged and surfaced as an
unexpected-response error) from a genuine empty fleet, and guards per-node
stackResults.

Docs: an unreachable or errored remote is reported once per node, not as a
per-stack error row.
2026-06-17 13:25:12 -04:00
Anso f23b7e1bac feat: ordered multi-file Compose for Git sources (#1380)
* feat: ordered multi-file Compose for Git sources

Extend Git sources to deploy an ordered list of compose files merged with
docker compose -f base.yaml -f override.yaml ..., plus an optional project
directory.

- Pick and reorder compose files from the repository tree (drag to reorder on
  desktop, up/down arrows on phones); manual path entry is also supported.
- The ordered set drives every stack-scoped compose command (deploy, update,
  start/stop/restart/down, image scans, Compose Doctor) and the container
  lookup, so a service or image declared only in an override is handled too.
- Runtime keys off the materialized set, not the saved configuration: saving a
  source does not change deploy args until the pull is applied, and apply
  materializes from the pending snapshot rather than live config.
- The project directory is passed as --project-directory, with -p <stack>
  pinning the Compose project so container labels stay stable.
- The Mesh override is layered last; single-file sources are byte-identical to
  before, and existing rows keep working via the single-path fallback.

Docs cover the picker, ordering, project directory, and the new troubleshooting
and limitations (referenced files are not materialized; the dependency graph,
drift, and networking views read the primary file).

* fix: harden multi-file Git source (hash, unlink, collisions, node id)

- hashContent folds ordered file CONTENTS (not paths) so a clean multi-file
  stack is not flagged as locally edited: create/apply hash the fetched files
  (repo paths) while pull hashes the on-disk files (materialized paths), which
  previously disagreed and showed a false "local edits detected".
- Block unlinking a multi-file or project-directory Git source (409): the deploy
  spec lives on the source row, so removing it would silently revert deploys to
  root compose.yaml. Single-file sources still unlink.
- Reject materialized-path collisions in the selection validator: an additional
  file equal to or nested under compose.yaml, an ancestor/descendant overlap
  between selected files, and a project directory nested under a compose file
  (previously a 500 at materialization).
- DockerController.getContainersByStack uses the controller's node compose dir
  and passes its node id to the authored prefix, instead of the process default.

* fix: CI failures on multi-file Git source (test crash, aria query, path barrier)

- GitSourceFields no longer crashes when repoUrl/branch are falsy: the canBrowse
  trim() is optional-chained, so a reusable field component tolerates partial
  props. Fixes the apply-binding panel test, which feeds a minimal source object.
- GitSourcePanel tests query the footer Remove button by its exact name, so the
  picker's per-file "Remove <path>" buttons no longer collide with the broad
  /remove/i match (the test intent, footer Remove present/absent, is unchanged).
- validateCompose uses an inline resolve + startsWith barrier at the context-dir
  mkdir sink (CodeQL does not credit the wrapped isPathWithinBase helper),
  clearing the js/path-injection alert. The containment check is equivalent and
  contextDir is also validated upstream.

* test: update Git source E2E spec for the multi-file compose picker

The compose-file picker replaced the single #git-source-path input and added
per-file Remove buttons, so the E2E spec drove selectors that no longer exist:

- Drop the redundant compose.yaml fills (the picker defaults to compose.yaml).
- Select the footer Remove button by exact name so the picker's per-file
  "Remove <path>" buttons no longer make the locator ambiguous.
- Set a custom compose path through the picker (add via the manual input, press
  Enter, then remove the default compose.yaml).

* test: match the footer Remove button with an exact Playwright name

Playwright's getByRole name option is a substring match by default, so
{ name: 'Remove' } also matched the picker's "Remove <path>" buttons. Require an
exact match so only the footer Remove button is selected.
2026-06-17 13:24:55 -04:00
Anso 7ce045accb feat: pre-deploy scan visibility and pinned scanner version (#1378)
* feat: pre-deploy scan visibility and pinned scanner version

Pin managed Trivy installs and add an opt-in pre-deploy scan advisory so a
manual deploy can surface each image's latest scan before it runs.

- Managed Trivy now installs a pinned, known-good version by default for
  reproducible installs. Auto-update still tracks the latest release, and an
  explicit update always pulls the latest.
- Add an opt-in pre-deploy scan advisory: when enabled, deploying a stack from
  the editor first shows each image's latest cached scan severity for review.
  It is visibility only and never blocks; deploy enforcement is unchanged.
- Backend: pre_deploy_scan_advisory setting, PUT
  /security/pre-deploy-scan-advisory, a cache-only GET
  /security/stacks/:name/pre-deploy-summary, and a node-scoped
  getLatestVulnScanByDigestForNode lookup.
- Frontend: advisory toggle on the Security page scanner setup, and a
  PreDeployScanDialog wired into the editor deploy flow that fails open when the
  summary is unavailable.
- Docs: scanner configuration, version pinning, and the advisory.

* fix: harden pre-deploy advisory guard, toggle visibility, and installer busy state

Addresses review findings on the pre-deploy advisory.

- Block a second editor deploy during the async advisory window with a
  synchronous pending ref, cleared on cancel and in the deploy's finally, so a
  double-click can no longer start two deploys.
- Keep the pre-deploy advisory toggle visible to admins whenever the setting is
  on, so it can still be turned off after the scanner becomes unavailable.
- Resolve the managed Trivy version inside the install lock so the busy state and
  serialization cover the latest-version fetch and the managed-install check.
2026-06-16 00:42:33 -04:00
Anso 770bead889 fix(deps): bump form-data, protobufjs, and vite to clear high-severity CVEs (#1379)
The npm audit and Trivy image-scan CI gates fail on newly disclosed
high-severity advisories in transitive backend dependencies. Bump the
locked versions to their fixed releases:

- form-data 4.0.5 -> 4.0.6 (via axios; CVE-2026-12143, CRLF injection)
- protobufjs 7.5.8 -> 7.6.4 (via dockerode/@grpc; CVE-2026-48712, Any-expansion DoS)
- vite 8.0.5 -> 8.0.16 (vitest dev dependency; fs.deny bypass and launch-editor)

Lockfile-only change (npm audit fix, no package.json edits). Backend build,
lint, and the route/integration tests stay green, and backend
npm audit --audit-level=high now reports 0 vulnerabilities.
2026-06-15 20:33:03 -04:00
Anso 058cf8f2c7 feat: make image-update check cadence configurable and visible (#1377)
* feat: make image-update check cadence configurable and visible

The background image-update scanner polled registries on a hardcoded
6-hour interval, with no way to see when it last ran or when the next
run was due. Operators testing updates read this as auto-update being
unreliable: a manual update checks the registry immediately and applies,
so the slow background scan rarely raised the "update available"
notification before the stack was already current.

Backend:
- ImageUpdateService reads image_update_check_interval_minutes (15-1440,
  default 120) and drives a single generation-guarded self-rescheduling
  timer with 10% per-run jitter so fleet nodes do not poll in lockstep.
  restartPolling() applies a new interval live, with no restart, and
  cannot leave a duplicate timer when a save lands mid-scan.
- GET /api/image-updates/status now returns checking, intervalMinutes,
  lastCheckedAt, nextCheckAt, and the manual-cooldown fields. New
  admin-only PUT /api/image-updates/interval persists the setting and
  reschedules.

Frontend:
- New Settings > Automation > Image update checks section to choose the
  interval (read-only for non-admins; admin enforced on the backend).
- The Auto-Update readiness view shows last-checked, next-check, and a
  ticking manual-recheck cooldown, and the copy distinguishes registry
  detection from scheduled auto-update execution.

Adds backend unit and route tests and frontend component tests, and
updates the auto-update documentation.

* fix: drop stale image-update status response in the readiness strip

loadCadence() ran on mount and again after a Recheck with no request
token, so a slow initial /image-updates/status response could resolve
after the recheck-triggered one and overwrite the fresh cooldown with
stale data, or set state after the view unmounted. Guard setCadence with
a monotonic token mirroring loadReadiness, and bump it on unmount. Adds a
regression test for the out-of-order resolution.
2026-06-15 20:06:13 -04:00
Anso 888f658a7a feat(files): move files and folders across directories in the stack explorer (#1373)
* feat(files): move files and folders across directories in the stack explorer

Add a cross-directory move to the stack file explorer. Files and folders can
be relocated either through a "Move to..." context-menu item that opens a
folder-picker dialog, or by dragging an entry onto a folder node (or onto the
root area to move it to the stack root).

The backend reuses the existing rename endpoint: renameStackPath now resolves
both ends through the leaf helper, so a symlink moves as the link entry rather
than its target, and it guards against moving a directory into its own subtree.
A cross-filesystem rename surfaces as a clean 409 instead of a 500. Protected
root files (compose / docker-compose / .env) stay put. Moving the open file, or
a folder containing it, deselects the viewer; a move that would discard unsaved
edits is blocked with a clear message.

* fix(files): fold case in move guards and keep the move dialog open on failure

Harden the cross-directory move against case-insensitive filesystems and fix a
dialog dismissal edge:

- Protected root files (compose / docker-compose / .env) were gated by an exact,
  lowercase name match. On a case-insensitive filesystem a request like
  COMPOSE.YAML resolves to the real compose.yaml and slipped past the gate, so a
  protected file could be moved out of the stack root via the API. The gate now
  folds case on case-insensitive platforms; Linux stays case-sensitive, where a
  differently-cased name is a distinct, unprotected file.
- The directory-into-descendant guard compared resolved paths case-sensitively,
  so a source supplied with non-disk casing skipped the guard and fell through to
  an opaque OS error (500) instead of a clean 400. The comparison now folds case
  the same way.
- The move dialog closed after awaiting the move regardless of outcome, so a
  blocked move (unsaved edits) or a failed move dismissed the picker as if it had
  succeeded. The shared handler now reports success and the dialog only closes on
  an actual move.
2026-06-14 21:56:06 -04:00
Anso 4610a433e6 fix(fleet): scope Stop-by-label to stack labels with a typed suggestion source (#1368)
* fix(fleet): scope Stop-by-label to stack labels with a typed suggestion source

The Fleet Actions "Stop by label" card labelled its target field generically
as "Label", so a same-named node label could look like a valid stop target in
a destructive workflow. The action has always matched stack labels only, but
nothing in the copy or the data flow made that explicit.

Add a stack-label-only suggestions endpoint and make the scope unmistakable:

- New GET /api/fleet/labels/suggestions aggregates the per-node stack labels
  into a name-keyed list with stack and node counts (admin-only, central DB,
  covers every configured node including offline remotes). Node labels are
  never folded in.
- The card now sources its autocomplete from that endpoint and renders each
  suggestion with its stack and node counts via a typed FleetStopLabelSuggestion
  model, so node-label data cannot be fed into this destructive card.
- Copy is explicit throughout: "Stack label" target field with a helper line
  that node labels are not used, a clear "0 matching stacks" readout and a
  "No stacks are assigned to this stack label" empty preview, and confirm and
  result copy that references stacks and the stack label.
- Docs updated (fleet-actions, stack-labels) and tests added on both sides,
  including node-only exclusion, name collision, multi-node counts, the
  zero-stack preview, and the non-fatal suggestions-load path.

* docs: correct stale Stop-by-label button and helper references

The Stop-by-label walkthrough referenced a "Stop matching stacks" button and a
warning callout that no longer exist on the card. Align the docs with the live
card: the primary action is "Stop fleet", and the scope is stated by the helper
line under the input.
2026-06-12 22:14:04 -04:00
Anso ef5a3f00a7 feat: add an on-demand node-wide security scan with live progress (#1367)
Add a "Scan this node" action on the Security overview that scans, in one pass,
any combination of three types: image vulnerabilities, image secrets, and
compose misconfigurations. Progress streams live into the deploy-feedback modal.

- TrivyService.scanNode runs the selected scanners across the node's images and,
  for misconfig, every stack's compose file, behind a per-node lock and tolerant
  of per-item failures. The existing scanAllNodeImages becomes a thin vuln-only
  wrapper over the shared image loop, so scheduled scans are unchanged.
- POST /api/security/scan-node (admin, scanner-gated) streams sanitized progress
  to the deploy terminal and returns a combined summary. Secret scans stream
  counts only, never matched values.
- Frontend adds a "scan" action verb and a ScanNodeLauncher wired into the
  overview; the scan stays bound to the node it started on even if the active
  node changes mid-run.
2026-06-12 19:07:45 -04:00
Anso 3d39d856a3 feat: chart-led Security overview with sortable Images and History tables (#1364)
* feat: chart-led Security overview with sortable Images and History tables

Refine the Security page around the existing design system and add the
data the dashboard needs.

- Overview leads with four charts (30-day risk trend, severity donut, top
  exposed images, findings by type); the signal-rail counts become a
  secondary summary, and the scanner and deploy-enforcement posture follow.
- Images becomes a recessed table with search, a severity filter, sortable
  columns, a last-scan column, and inline scan actions; the findings cell is
  clickable into the scan sheet, and the per-row cursor tooltip is dropped
  where the columns already carry that information.
- Policies puts deploy-enforcement first, collapses the policy packs into an
  accordion, and uses the standard primary button for Add policy.
- Suppressions and acknowledgements move their titles and Add buttons outside
  the cards, matching the Fleet tab layout.
- History switches from the detail sheet to an inline table (search, sortable
  columns, two-scan compare, pagination); the now-unreachable scan-history
  overlay is removed.
- Add GET /api/security/overview/trend, a node-scoped daily critical/high
  rollup backing the risk-trend chart.
- Extract the shared image-scan hook and the severity classifier, and harden
  the overview data fetch so a malformed non-critical response can never read
  as a clean security state.

* fix: treat malformed Security responses as errors, not empty or clean states

Address an independent review of the data-fetch paths so a 200 with an
unexpected shape can never read as a benign "no findings" view.

- SecurityView: validate that the image-summaries body is a scan-summary map; an
  unexpected shape now sets the error state instead of an empty map. Isolate the
  trend fetch in its own self-catching promise so a transport failure on the
  non-critical chart can no longer poison the overview or summaries error state.
- useImageScan: only a "completed" poll counts as success (a malformed or unknown
  status now throws), and a failed post-scan summaries refresh is logged instead
  of silently dropped.
- HistoryTab: a 200 whose body lacks an items array is treated as an error, not
  an empty "no completed scans" list.
2026-06-12 14:35:03 -04:00
Anso 2a4955f56d feat: add dedicated Security page and policy-pack foundation (#1362)
* feat: add dedicated Security page and policy-pack foundation

Bring vulnerability scanning, scan history, suppressions, Compose risks,
secrets, policy packs, and scanner setup into one node-scoped Security
command center instead of scattering them across Resources and Settings.

- New top-level Security view with Overview, Images, Compose risks,
  Secrets, Policies, Suppressions, History, and Scanner setup tabs
  (status masthead + signal rail; controlled tabs with deep-link support).
- Backend: GET /security/overview rollup and GET /security/policy-packs
  static catalog (auth-only, Community). DatabaseService gains an uncapped
  scan-status count and a node-eligible block-policy count, and
  getImageScanSummaries now projects secret and misconfig counts.
- Reuse existing surfaces: the scan-history sheet, the control-governed
  suppression and acknowledgement panels, and the scan-detail sheet (now
  with an initial-tab prop so it opens on the matching finding type).
- Extract a shared SeverityBadge (from Resources) and a TrivyManager
  (from Settings) so both surfaces render identical controls.
- Resources "Scan history" now links into the Security page History tab.
- Docs for the new Security surface and tests for the new endpoints,
  helpers, nav wiring, and tabs.

* refactor: consolidate scanner and policy management onto the Security page

Remove the Settings "Vulnerability Scanning" section now that the Security
page covers the same ground, with every option preserved:

- Scanner install / update / uninstall / auto-update live on the Scanner setup
  tab (TrivyManager).
- Scan policies, the honor-suppressions toggle, and the replica
  managed-by-control / demote controls move into a new ScanPolicyManager on the
  Policies tab (paid; Community sees only the policy-pack catalog).
- CVE suppressions and acknowledgements remain on the Suppressions tab.

Wiring removed: the registry section and the now-empty Security settings group,
the SectionId, the SettingsSectionContent case and the isPaid prop it was the
sole consumer of, and SecuritySection itself. The dashboard configuration-status
"Vulnerability scanning" row now navigates to the Security page Policies tab.

Docs that pointed at "Settings -> Security -> Vulnerability Scanning" are swept
to the relevant Security page tabs.

* fix: harden Security page scanner refresh, policy-load errors, and secret-only badges

Address independent-review findings on the Security page:

- Scanner setup now refreshes Trivy state when the active node changes, so the
  displayed scanner status matches the node TrivyManager's actions target (both
  follow x-node-id). Previously, switching nodes on the tab left stale state.
- ScanPolicyManager surfaces an explicit error state on a failed policy fetch
  instead of falling through to a false "No scan policies configured".
- The shared SeverityBadge and the Images findings column no longer label a scan
  "clean" when it has secrets or misconfigurations but no CVE severity
  (highest_severity is derived from vulnerabilities only); they show a "Findings"
  state and the secret/misconfig counts instead.
- The Overview enforcement note points to the Policies tab, not the removed
  Settings section.
- The History tab auto-opens the scan-history sheet only on a deep-link (mount
  with the History tab active), not on every manual tab selection.

Adds tests for the badge secret/misconfig state and the policy-load error state.
2026-06-12 10:41:39 -04:00
Anso 77f1611971 feat: Compose Network Inspector and exposure intent guard (#1360)
* feat: add Compose Network Inspector facts engine

Render a stack's authored effective model and pair it with the live
Docker snapshot to derive per-stack networking facts: project networks
with external and internal flags, service-to-network membership and
aliases, published ports with host-binding scope, network_mode, and
extra_hosts, plus runtime drift (runtime-only attachments, foreign
networks, and declared-but-unused or missing networks).

Extend the effective-model parser with service network membership,
extra_hosts, and label keys (key names only, never values), and add a
key-space normalized network model with adapters from both the rendered
model and the raw declared compose so the Inspector and drift share one
comparison. Expose GET /api/stacks/:stackName/networking: advisory and
read-only, it renders the authored model only and never returns or logs
raw stderr, env values, or label values.

* feat: store and edit per-stack and per-service exposure intent

Add a stack_exposure_intent table (intent values constrained by a CHECK,
unique per node, stack, and service) with DAO methods to read, upsert,
clear one row, and clear all rows for a stack. The classification is
stored independently of the generated networking facts so a later
mismatch stays detectable; service rows are kept separately from the
stack-level row (service '').

Expose GET and PUT /api/stacks/:stackName/exposure: GET requires read
access, PUT requires edit access and validates the intent against the
allowed set. Sending intent null clears that row, returning the scope to
unset so a service inherits the stack intent again. Intent rows are
cleared when the stack is deleted and when the owning node is removed,
so a later same-named stack never picks up stale classification.

* feat: add exposure-aware Compose Doctor findings

Feed the Compose Doctor's effective-model context with the stored
exposure intent (resolved into a stack-level value plus per-service
overrides) and the dossier's documented access-URL ports, read fail-soft
so a metadata read error skips these checks rather than failing the
preflight. Add five deterministic findings on top of that context:

- a service classified internal or same-node that publishes a host port
  (same-node tolerates a loopback bind),
- a sensitive database or admin image published on all interfaces,
- a port-publishing stack with no exposure intent set,
- a published port not reflected in the documented access URLs,
- reverse-proxy labels with no documented URL or reverse-proxy intent.

The rules stay pure functions over the preflight context; the registry
completeness test pins the new rule set.

* feat: detect compose network drift in the drift ledger

Extend the spatial drift engine with two network-level findings: a
running container attached to a stack-owned or foreign network that
compose does not declare (one finding per service), and a declared
network that no running service uses or that is absent from the runtime
(one stack-level finding, every network named by its resolved runtime
name). The comparison reuses the same helper the Network Inspector uses,
so the two surfaces never disagree.

Network drift runs only when the stack has running containers and the
runtime is reachable, preserving the existing missing-runtime,
parse-error, and unreachable behavior. The findings persist through the
existing drift ledger and surface on the Drift tab, which now labels the
two new kinds.

* feat: link a Docker network back to its owning stack

Add a cross-component open-stack event and make the owning-stack badge on
a managed network in Resources a link: clicking it loads that stack on
its node and opens the editor, reusing the existing fleet navigation. A
latest-ref keeps the window listener current without re-subscribing each
render. Image and volume badges are unchanged; only a managed network
opts in via the new optional handler.

* feat: add the Networking tab to the stack detail panel

Add a capability-gated Networking tab that reads the per-stack networking
facts and exposure intent. It shows the project networks (with external,
internal, and created-by-stack flags), per-service network membership and
aliases, published ports with their host-binding scope, network_mode and
extra_hosts, and runtime drift, degrading to the declared model when the
runtime is unavailable. Users can classify the stack and each service
(internal, LAN, reverse proxy, public, and so on) or clear a row to
inherit; the controls are read-only when the user cannot edit, and a
broken exposure response never tears down the facts view.

A new compose-networking capability is added to both registries so older
nodes hide the tab, and the tab cross-links to the Doctor for the deploy
and security findings.

* docs: document the Compose Networking tab

Add a feature page covering the Networking tab: the network facts,
published ports and host bindings, the exposure-intent classification
and inheritance, the exposure-aware Doctor findings, runtime drift, and
a troubleshooting section. Register it in the docs navigation next to
Compose Doctor.

* feat: add a redacted network summary to the Stack Dossier export

Append a network exposure section to the dossier Markdown: the stack and
per-service exposure intents, the networks with their external and
internal flags, and each service's published ports with their binding
scope. It carries only names, intents, port numbers, and scope, never an
env value or a label value.

The summary is fetched only when the user exports (copy or download), so
opening the panel costs nothing, and it degrades to omitting the section
when the data is unavailable. The whole-fleet dossier export collects the
same summary per stack, rethrowing the unauthorized sentinel like the
sibling loaders.

* feat: add a Fleet networking filter for exposure and drift

Add a per-node networking summary that classifies a node's stacks as
exposed (a host port published beyond loopback), unknown-exposure
(publishes ports with no exposure intent set), or network-drift. It
reads each stack's compose with the light dependency parser and one
Docker snapshot, so it stays cheap across a node's full stack set, and
it skips drift when the runtime is unreachable rather than inventing it.

Serve it node-locally at GET /api/networking/summary, and aggregate it
fleet-wide at GET /api/fleet/networking-summary: the hub computes its own
summary in-process and reaches each remote through its node-local route,
degrading an unreachable or older node to a skip. Because the aggregate
lives under the proxy-exempt /api/fleet prefix it is never wrongly
proxied. The Fleet overview gains a networking filter chip backed by that
aggregate, fetched fail-soft and detached so it never gates the grid.

* fix: spin the Networking refresh button while it reloads

The refresh button silently refetched the same data, so a click gave no
feedback. Track a refreshing state and spin the icon while the load is in
flight, disabling the button, matching the Compose Doctor preflight
button.

* fix: apply effective per-service exposure intent to unclassified checks

The "unclassified exposure" decisions only consulted the stack-level intent
row, so a service classified directly (with no stack row) was still reported
as unclassified, and a service explicitly marked unknown over a classified
stack was missed.

Both the exposure-unclassified preflight rule and the networking summary's
unknown-exposure bucket now resolve the effective intent per publishing
service (service row overrides stack row), matching the precedence already
used by the exposure-internal-published rule.

* fix: resolve drift network names via the compose top-level name

When a compose file sets a top-level name:, Docker prefixes resource names
with that project name instead of the stack directory. The light dependency
parser dropped name:, so network-drift normalization compared runtime
networks against directory-prefixed names and reported false
network-undeclared / network-missing findings.

Carry the parsed project name through DeclaredCompose and use it when
normalizing declared networks for drift, while still filtering containers by
the stack directory.
2026-06-12 02:15:11 -04:00
Anso f253276303 fix: make published port links open reliably (#1359)
* fix: make published port links open reliably

Container published-port links now render as real anchors that open on
desktop and mobile, replacing ad hoc window.open calls. A shared service
URL builder centralizes host resolution (configured host, remote node
API host, or the browser host, with no browser fallback for unreachable
remote nodes), protocol selection (HTTPS for port 443), and known app
sub-paths (Plex opens its web path). The container port mapping itself is
the link, with a Copy URL action beside it. The stack Open App menu and
the anatomy panel footer use the same builder, and the menu only offers
Open App when a reachable URL can be built.

* fix: skip UDP ports and scope known-app paths to the container port

Two follow-ups to the published-port links:

- The known-app path (Plex web sub-path) was borrowed from the published
  host port even when the container port was known and unregistered, so a
  non-Plex service published on host port 32400 wrongly inherited it. The
  container-port lookup now wins when known; the published-port lookup stays
  a fallback for the menu and anatomy footer, which only have the host port.

- UDP ports could surface as HTTP links. The backend now carries the port
  protocol through both container-mapping paths and skips UDP when choosing
  the main web port (extracted as selectMainWebPort), and the container card
  filters UDP before selecting a port to link.
2026-06-11 16:37:53 -04:00
dependabot[bot] a2fe58f62f chore(deps): bump @grpc/grpc-js (#1356)
Bumps the all-npm-backend-security group with 1 update in the /backend directory: [@grpc/grpc-js](https://github.com/grpc/grpc-node).


Updates `@grpc/grpc-js` from 1.14.3 to 1.14.4
- [Release notes](https://github.com/grpc/grpc-node/releases)
- [Commits](https://github.com/grpc/grpc-node/compare/@grpc/grpc-js@1.14.3...@grpc/grpc-js@1.14.4)

---
updated-dependencies:
- dependency-name: "@grpc/grpc-js"
  dependency-version: 1.14.4
  dependency-type: indirect
  dependency-group: all-npm-backend-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-11 14:02:40 -04:00
Anso 38aabe7064 feat: health-gated updates and rollback readiness (#1354)
* feat: classify stack deploy and update failures with suggested next actions

Failed deploy and update responses now carry a failure classification
(cause category, headline, and suggested next step) derived from the
compose error output. The recovery panel and chip render the
classification and include it in copied diagnostics, and gateway-style
failures surface as a node-unreachable cause.

* feat: add update and rollback readiness reports for stacks

Before a manual update, Sencho now shows an advisory readiness verdict
computed from the stored preflight result, open drift findings, live
container health, the pending image change, the rollback backup slot,
and node disk headroom. The Stack Dossier gains a rollback readiness
section that states what a rollback can restore and explicitly
discloses that volume and bind-mounted data are not covered. Toolbar
and sidebar updates now share one update path, and admins can create a
fleet snapshot from the readiness dialog before updating. Nodes that do
not advertise the capability keep the direct update flow.

* feat: observe stack health after updates with a post-deploy health gate

After a deploy or update succeeds, Sencho now watches the stack for a
configurable observation window and records a passed, failed, or
unknown verdict: containers must stay running, healthchecks must report
healthy, and restart loops or disappearing containers fail the gate.
The deploy panel shows the observation live and holds off auto-closing
until the verdict lands, a failed gate surfaces the existing recovery
actions including rollback, and the stack timeline records update
started and gate verdict events. Scheduled, webhook, bulk, and
git-source updates are gated the same way; rollbacks and installs are
deliberately not. The gate is observational only and can be tuned or
disabled per node under host alert settings.

* docs: document health-gated updates and rollback readiness

New operator page covering the update readiness dialog, the post-update
health gate and its settings, the rollback readiness disclosure, and
classified failures, with cross-links from the atomic deployments and
deploy progress pages. The API reference gains the readiness and
health-gate endpoints, the healthGateId success field, and the failure
classification schema on deploy and update error responses.

* feat: withhold the success verdict while the health gate observes

An update used to show a green Succeeded that a failed health gate then
contradicted moments later. The deploy modal now reports Verifying
health while the gate observes, shows success only when the gate
passes, and makes a failed or unknown gate the headline result; success
toasts soften to a verifying message while a gate runs. The mobile
recovery card groups its actions behind one bottom-right Take action
menu so it stays compact on a phone, with the classified cause still
visible on the card. A successful image update now also counts as the
last known-good marker in rollback readiness, and the docs gain
screenshots of the readiness dialog, gate states, dossier section, and
settings.

* fix: harden log format strings and the env existence path check

Log calls that interpolated the stack name into the console format
string now use constant format strings with placeholder arguments, and
envExists validates path containment inline at its filesystem access,
matching the established patterns used elsewhere in the same files.

* test: adapt deploy modal success specs to the post-deploy health gate

The deploy feedback modal now withholds its success verdict while the
health gate observes the new containers, showing "Verifying health"
until the gate passes. The two success-path E2E tests waited for
"Succeeded" within the gate's 90s default window and timed out.

Shorten the observation window to the 15s minimum for these tests via
the settings API, assert the verify-then-succeed sequence the modal
actually renders, and restore the default window afterward so the test
value does not leak into later runs.

* fix: serialize health gate polling and harden gate observation

Address race conditions in the post-update health gate found in review.

Backend: the gate poller used setInterval, so a Docker observe slower
than the 5s tick could overlap the next poll and corrupt the restart and
missing-container accounting, and a wedged socket could leave a poll
pending forever. Polling is now single-flight: each cycle self-schedules
the next only after it settles, and the observe is bounded by an 8s
timeout so a hung probe counts as a poll error and resolves the gate
unknown after three in a row.

Frontend: the gate poller could overlap requests, letting a slow earlier
"observing" response overwrite an already-applied terminal verdict. It is
now single-flight with a terminal latch, so a late response can never
roll the UI back from passed or failed.

Also reject a non-digit nodeId on the snapshot coverage route instead of
letting parseInt coerce it, document that turning off the deploy progress
panel opts out of the live gate UI while the gate still runs server-side,
and add gate-coverage tests for the webhook, git source, and auto-update
apply paths plus the new single-flight, observe-timeout, and recovery
cases.
2026-06-11 00:26:26 -04:00