mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
adcd04b01ac97e7bdd8d6073d585386d4d25e8d3
12 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8a3889dc67 |
feat(security): move managed Trivy auto-update to Skipper tier (#1150)
* feat(security): move managed Trivy auto-update to Skipper tier Drop the gate on the managed Trivy auto-update toggle from Admiral to Skipper so it lives alongside the rest of Sencho's automation features (auto-heal, scheduled ops, per-stack image auto-update, scan policies) instead of behind the enterprise-control tier. Backend: `PUT /api/security/trivy-auto-update` switches from `requireAdmiral` to `requirePaid`. The 24h scheduler tick in SchedulerService that reads the setting is tier-neutral and picks the new gate up automatically. Frontend: SecuritySection.tsx swaps the inline `isAdmiral` conditional on the toggle render for `isPaid`. The local `isAdmiral` derivation and the `useLicense` import become unused and are removed. Docs: licensing, overview, vulnerability-scanning matrix, trivy-setup, and the settings reference now read Skipper consistently for this feature. * fix(security): require admin role on trivy-auto-update toggle Independent audit of the prior commit flagged that PUT /api/security/trivy-auto-update had no admin-role guard. The route was authenticated and tier-gated, but the global /api authGate only authenticates and `requirePaid` only checks tier. Any paid viewer could flip the global trivy_auto_update setting via a direct API call. Add `requireAdmin` ahead of `requirePaid`, matching the pattern used by every other mutating route in this file (sbom, policies, suppressions, misconfig-acks). Add route tests covering paid admin allowed, paid viewer rejected, community admin rejected, and unauthenticated rejected. |
||
|
|
3b650523c1 |
Audit-hardening pass for secret and misconfiguration scanning (#977)
* fix(security): dedupe concurrent compose-stack scans
Track stack scans in scanningImages keyed stack:<nodeId>:<stackName>.
The /scan/stack route returns 409 when an in-flight scan exists, and
the service-side check is the real correctness barrier (the route
pre-check is a fast-path optimization that mirrors scanImage). The
dedup key release lives in a try/finally so failed scans free the
slot for retry.
Why: scanComposeStack had no equivalent of scanImage's scanningImages
guard, so two simultaneous calls for the same stack would both run
trivy config, both insert a vulnerability_scans row, and double-
process the result.
* feat(security): acknowledge misconfig findings
Adds a parallel acknowledgement system for Trivy misconfig findings
that mirrors cve_suppressions: a new misconfig_acknowledgements table,
read-time enrichment via the new misconfig-ack-filter utility, REST
CRUD endpoints, fleet-sync replication from control to replicas, a
Settings panel, and an Acknowledge button on the Misconfigs tab.
Schema and behavior parity with cve_suppressions:
- UNIQUE(rule_id, COALESCE(stack_pattern, '')) so fleet-wide acks
collide as expected
- blockIfReplica on every write
- Audit-log entries name the scope (rule_id, stack_pattern) but
never the reason text
- replicated_from_control flag controls UI delete affordance and
drives clearReplicatedRows on demote/reanchor
- Validators reused: validateStackPatternForRedos for glob safety,
sanitizeForLog for log fragments
SARIF export emits an external/accepted suppression entry per
acknowledged misconfig, matching the CVE pattern.
Per-row Acknowledge dialog prefills stack_pattern with the scan's
stack_context so the default scope is "rule + this stack only" and an
operator must broaden explicitly.
Tests: misconfig-ack-filter (15) and misconfig-ack-routes (23)
including the duplicate-409 case for both pinned and fleet-wide acks.
* fix(security): reap orphaned trivy tmp dirs at startup
When the buildEnv path writes a per-scan DOCKER_CONFIG dir under
os.tmpdir() and the process crashes before the finally block runs,
the dir leaks. Mirrors GitSourceService.sweepStaleTempDirs:
exported sweepStaleTrivyTempDirs is fire-and-forget at boot,
removes prefix-matching dirs older than 1 hour, swallows
permission/race failures, logs a single line if any were reaped.
* perf(security): emit per-batch summary for scanAllNodeImages
Adds one diag() line at the end of scanAllNodeImages summarising
unique image count, scanned, skipped, failed, violation count, and
elapsed time. Per-image diag inside scanImage stays useful for
debugging individual scans; the summary gives operators a single
fleet-level checkpoint when developer_mode is on.
* perf(security): cap SARIF export at 5000 findings per type
Replace the unbounded fetchAllPages walk on /scans/:id/sarif with a
hard limit of 5000 findings per type. When any type trips the cap,
emit run-level properties.truncated=true plus row_limit and per-type
totals so downstream tooling can flag the export as partial.
Console-warns for ops visibility.
A scan with 50k vulns previously streamed every row into memory
before serialising; the cap bounds memory and serialisation time at
the cost of completeness on pathological scans.
* docs(env): document TRIVY_BIN host-binary override
The env var is honored by TrivyService.detectTrivy as a fallback when
no managed install is present, but it was undocumented in
.env.example. Adds the var with a comment explaining precedence
(managed > TRIVY_BIN > PATH).
* test(security): cover scanComposeStack failure modes
Two new cases drive the existing try/catch through real failure
paths:
- Malformed Trivy stdout: row flips to status='failed' with the
parser error preserved on `error`.
- execFile rejection: row flips to status='failed' with a string
error message.
Pairs with the existing dedup tests so the failure path now also
verifies the scan row state, not just the thrown exception.
* test(e2e): security scanner + misconfig acknowledgement flow
Seven Playwright tests covering the scanner UI and the new
acknowledgement system end-to-end:
- Trivy availability gate (skips suite when binary absent so CI
without Trivy can opt out via E2E_SKIP_TRIVY=1)
- Stack config scan completes and records misconfig findings
- Concurrent stack scan returns 409 from the dedup gate
- Misconfig ack POST creates and lists on Settings
- Duplicate (rule_id, stack_pattern) returns 409
- Malformed rule_id (shell metacharacters) returns 400
- Misconfigs tab renders against a real stack scan
Tests drive the API for behaviour assertions and the UI only for
shell-rendering checks; the visual snapshot suite owns screenshots.
* docs(features): add misconfig acknowledgement workflow and SARIF cap
Refreshes vulnerability-scanning.mdx with:
- Misconfig acknowledgements section covering the per-row dialog,
Settings panel, scope/matching rules, and SARIF emission
- Tier table row for the new feature
- SARIF section note on the 5000 row-per-type cap and the
properties.truncated marker for partial exports
- Troubleshooting entries: SARIF cap, hidden Acknowledge button,
findings resurfacing after delete, Trivy DB phone-home, and
409 on concurrent compose-stack scans
* fix(ci): clear backend lint and CodeQL alerts
- Remove the dead fetchAllPages helper in routes/security.ts. It lost
its callers when the SARIF endpoint switched to direct paged reads
for the truncation cap. ESLint flagged it as unused.
- Switch the trivy-tmp-cleanup test helper to fs.mkdtempSync. Building
paths under os.tmpdir() with predictable names tripped CodeQL's
js/insecure-temporary-file rule (high severity), which warns about
symlink-pre-creation attacks even in test code. mkdtempSync appends
a process-random suffix and creates the dir atomically; the
sencho-trivy- prefix is preserved so the production sweep still
matches the test fixtures.
|
||
|
|
4b1de35dda |
Audit-hardening pass for fleet-replicated CVE suppressions (#976)
* perf(security): bucket CVE suppressions by id at read time
applySuppressions now builds a Map<cve_id, suppression[]> once before the
per-finding loop, dropping per-finding work to O(matching-cve-suppressions)
rather than O(all-suppressions). At a fleet-wide cap of 10000 rows against
a multi-thousand-finding scan, the prior linear-per-finding shape drifted
into tens of millions of comparisons per render.
Public API of findSuppression and applySuppressions is unchanged.
Specificity scoring, expiry handling, and image-glob matching are
preserved bit-for-bit. Adds a regression guard that pins a 10000x2000
workload under 1.5s and asserts at least one match was actually returned.
* feat(security): record audit-log entries on control-side CVE suppression CRUD
The replica receive path already wrote an audit-log entry on apply; the
control-side POST/PUT/DELETE handlers did not. Operators reading the
audit panel saw mirrored security-rule changes from the replica view but
could not see who originated them on the control. Symmetric logging
closes that gap.
The summary records the CVE id and pinned scope (pkg, image) but never
the suppression's reason text. Reasons are free-form admin input that
replicate fleet-wide and may carry incident-tracker IDs or vendor
context the operator did not intend to broadcast.
The summary is also sanitised before emission so an operator-supplied
package name or image pattern carrying a smuggled newline plus a forged
"cve_suppression.delete:" prefix cannot inject a fake row into the audit
panel. Control characters become "?" and the field is capped to its
validator length.
Adds a Control-side audit log block to suppression-routes.test.ts that
asserts the privacy contract on each verb (scope present, reason absent),
plus a log-injection guard test, plus a regression that GET still works
when the local instance is a replica.
* test(security): cover cve_suppressions fleet-sync receive path
The existing fleet-sync route tests covered the protocol mechanics
(auth, anchor, stale push, reanchor, demote) for cve_suppressions only
with empty-rows payloads. The suppression-specific concerns were
unverified end-to-end:
- actual rows replace prior replicated rows on a fresh push
- the receive path writes an audit-log entry naming the source
fingerprint and row count
- a malformed suppression row is rejected at the validator before any
DB write
Adds a focused block exercising those three properties against the real
Express app, real SQLite, and the real apply transaction.
* docs(features): refresh CVE suppressions troubleshooting and field guidance
Converts the troubleshooting section to Mintlify Accordion blocks so
each entry is foldable and the page stays scannable. Adds entries for:
- control-identity mismatch on a replica (anchor-aware reanchor flow)
- mirrored rules persisting after a control demote
- the 10000-row truncation cap on a fleet sync push
Adds a privacy note to the Reason field guidance: do not paste
credentials, tokens, or vendor secrets there, since the field replicates
fleet-wide and surfaces on every node's suppressions panel.
|
||
|
|
a284732a95 |
feat(fleet-sync): hide other replicas' identity-scoped policies on a replica (#973)
GET /api/security/policies on a replica now returns only the policies
that apply to THIS replica. Replicated rows with a node_identity
targeting a sibling replica are filtered out so an operator cannot
enumerate the names and rules of policies meant for another node in
the fleet. Defense in depth: the security panel is admin-only, but a
backend filter is bypass-proof and matches Sencho's privacy posture.
Internal evaluators (getMatchingPolicy, evaluateScanAgainstPolicies)
keep using the unfiltered list because they already enforce identity
matching at evaluation time.
CVE suppressions are fleet-wide on every replica (no node_identity
column) so no analogous filter is needed.
Public surface:
- DatabaseService.getScanPoliciesForUi(role, selfIdentity): the
filtered variant, called from securityRouter.get('/policies').
Tests:
- 3 new vitest cases: control sees full set; replica hides
other-replica scoped rows; replica always includes locally created
rows.
- Full backend suite: 1795 pass / 5 skipped.
|
||
|
|
4007709590 |
fix(fleet-sync): hygiene pass on receiver behavior and cleanup (#972)
A bundle of small file-local fixes to the receiver path and node-deletion flow. Changes: - F4 receiver audit log: applyIncomingSync now writes a system audit entry on every applied push so mirrored security-rule changes show up in the replica's audit panel with a clear control-side origin. - F7 pilot-agent skip: pushResource explicitly excludes pilot-agent nodes (they have no api_url for HTTP push) and warns once per node id so the operator sees they will not receive replicated policies. - B4 identity-drift notification: when targetIdentity differs from the cached fleet_self_identity, dispatch a warning so the operator can audit any identity-scoped policies that may need re-targeting. - B6 stack_pattern ReDoS guard: reject patterns with 4+ consecutive wildcards or more than 8 wildcards total. Both control-side validators (POST/PUT scan policies) and the receiver-side row validator share the helper. - B9 deleteNode cascade: clear fleet_sync_status rows for the node inside the existing transaction so the sync-status panel does not render ghost entries after a node is removed. - S6 last_error redaction: formatError strips Bearer tokens and JWT-shaped values from error messages and caps at 500 chars before storing in fleet_sync_status.last_error or logging. Tests: - 8 new vitest cases covering audit-log entry, identity-drift alert, pilot-agent warn-once, formatError redaction (Bearer + JWT), ReDoS validator rejection, and a backtracking-time smoke test. - New database-fleet-sync-cascade.test.ts: deleteNode removes fleet_sync_status rows for the deleted node and leaves siblings untouched. - Full backend suite: 1792 pass / 5 skipped. |
||
|
|
ecf4dd5d52 |
feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.
Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh
Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)
The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.
Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.
Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
|
||
|
|
e5b1c7b22b |
refactor(backend): collapse entitlement provider abstraction back to LicenseService (#889)
Removes backend/src/entitlements/ (registry, loadProvider, CommunityEntitlementProvider, types, headers, normalize) and the two abstraction-only tests. Relocates headers/normalize/types to services/license-*.ts. Swaps 22 consumer call sites from getEntitlementProvider() to LicenseService.getInstance(). Drops the Dockerfile install step plus PRO_PACKAGE_VERSION build-arg and github_token BuildKit secret in docker-publish.yml. Removes the now stale no-restricted-imports rule in backend/eslint.config.mjs. Net: 37 files changed, ~700 lines removed, no behavior change. Local dev no longer requires GitHub Packages auth to start the backend. Rationale and revisit conditions in docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md. |
||
|
|
3324616e59 |
refactor(backend): extract EntitlementProvider abstraction (Phase 1) (#878)
* refactor(backend): extract EntitlementProvider abstraction (Phase 1)
Phase 1 of the open-core hybrid extraction described in
docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md. Introduces
the abstraction without moving any code out of the public repo; Phase
2 will actually move services/LicenseService.ts to a private
@studio-saelix/sencho-pro package.
The new backend/src/entitlements/ module contains:
- types.ts. The EntitlementProvider interface plus all tier/license
types (LicenseTier, LicenseVariant, LicenseInfo, SeatLimits,
ActivationResult, etc.). The interface mirrors the existing
LicenseService public surface so the migration was mechanical.
- registry.ts. Module-scope holder for the active provider with
setEntitlementProvider, getEntitlementProvider, and a test-only
reset helper. getEntitlementProvider throws if called before
bootstrap registers a provider; the throw is intentional fail-fast
on a bootstrap-order bug rather than a silent degradation.
- CommunityEntitlementProvider.ts. Phase 2 fallback that returns
community tier and rejects activate(). NOT instantiated in
production today; a smoke test keeps it covered against bitrot.
- loadProvider.ts. Async resolver. Phase 1 returns
LicenseService.getInstance() directly. The async signature matches
what Phase 2 needs (dynamic import of @studio-saelix/sencho-pro
with a "module not found" vs "construction threw" narrowing); the
call site does not change between phases.
- headers.ts. PROXY_TIER_HEADER and PROXY_VARIANT_HEADER constants.
These are part of the wire contract between Sencho instances and
belong in the public core regardless of which entitlement provider
is bound.
- normalize.ts. isLicenseTier, isLicenseVariant, normalizeTier,
normalizeVariant. Domain knowledge about Sencho's tier model
(legacy name maps from pre-0.38.1 versions), not LemonSqueezy
internals. Phase 2 keeps these in the public core.
services/LicenseService.ts now imports its types from
entitlements/types and adds an "implements EntitlementProvider"
clause. Re-exports the types for back-compat with ~20 type-only
consumers; a follow-up PR will sweep those imports to entitlements/
types directly before Phase 2 deletes the file.
bootstrap/startup.ts awaits loadEntitlementProvider, registers the
result, then calls initialize. shutdown.ts calls
getEntitlementProvider().destroy() instead of the LicenseService
singleton.
middleware/tierGates.ts, the chokepoint for ~154 tier-check call
sites, now reads through getEntitlementProvider. Sixteen other
production files (routes/{fleet,imageUpdates,license,permissions,
scheduledTasks,security,stacks,templates,users,webhooks},
services/{BlueprintService,CloudBackupService,SchedulerService,
SSOService}, proxy/remoteNodeProxy, websocket/{hostConsole,
remoteForwarder}, middleware/auth) had their LicenseService.getInstance
calls and utility-export imports redirected to the entitlements
module. The only remaining LicenseService.getInstance in production
code is in entitlements/loadProvider.ts itself, which is the
intentional Phase-1 binding site.
Test infrastructure: setupTestDb registers
LicenseService.getInstance() as the active provider so existing
test files using the helper need no changes. The mocking pattern
many tests use, vi.spyOn(LicenseService.getInstance(), 'getTier'),
keeps working because LicenseService.getInstance() and
getEntitlementProvider() return the same singleton in Phase 1.
scheduler-service.test.ts is the only test that does not use
setupTestDb but exercises tier-gating; it now mocks
entitlements/registry alongside its existing LicenseService mock.
Adds a smoke test for CommunityEntitlementProvider so the Phase 2
fallback class stays covered.
Adds an architecture doc at
docs/internal/architecture/entitlement-provider.md covering the
runtime registry, bootstrap order invariants, and the Phase 1 vs
Phase 2 binding table.
Test results: 89/89 backend test files pass, 1657 passing tests, 5
pre-existing skips. The pre-existing database-metrics > handles
1000+ metrics stress test continues to flake under parallel load
and pass when re-run solo, same flake observed in PRs #862, #863.
* chore(backend): drop unused entitlement type imports from LicenseService
Phase 1 of the EntitlementProvider extraction left five type imports
(ActivationResult, BillingPortalError, BillingPortalResult,
DeactivationResult, ValidationResult) unreferenced after the runtime
methods that produced them began inferring their result shapes via the
EntitlementProvider interface contract. ESLint's no-unused-vars rule
flagged them as errors and failed the lint step in CI.
|
||
|
|
4e5ba17710 |
refactor(backend): sanitize user input before logging to close CRLF injection (#807)
* refactor(backend): sanitize user input before logging to close CRLF injection
Adds a small sanitizeForLog helper that strips CR, LF, tab, and ASCII
control characters (0x00-0x1F, 0x7F) from a value before it is embedded
in a console.log/warn/error/debug call. Wraps every call site where a
user-controlled value (req.params, req.body, req.query, or a value
derived from them) flows into a log message.
Closes the bulk of the open CodeQL alerts in this family:
- 96 js/log-injection
- 28 js/tainted-format-string
The helper is in backend/src/utils/safeLog.ts. Routes still pre-validate
input at the request boundary; this is the second line of defense and
gives static analyzers a sanitizer they can trace through. JSON
responses, Docker filter labels, and other non-log call sites are
intentionally left unwrapped.
* refactor(backend): printf-style format strings for tainted-log call sites
CodeQL's js/tainted-format-string rule flags template literals in the first
arg of console.X when any interpolated value is user-controlled, regardless
of whether each value is sanitized inline. The canonical mitigation is to
use a static format string and pass values as positional args.
Converts the 28 flagged template literals to printf-style ("%s") format
strings, with sanitizeForLog applied to each positional arg. Also fills in
the log-injection wraps on 9 sites where a user-controlled value was
missed in the first sweep (agents, fleet, gitSources, imageUpdates,
GitSourceService).
No behavior change at runtime. Node's util.format substitutes %s tokens
identically to template-literal interpolation.
* fix(backend): wrap nodeId/snapshotId in fleet restore debug log
CodeQL flagged the unwrapped numeric args even though they cannot
contain control chars in practice. Apply the sanitizer for taint-flow
recognition.
|
||
|
|
add3abaece |
refactor(backend): replica guard helper for security routes (#797)
* refactor(backend): extract replica guard helper for security routes
Adds blockIfReplica(res, resource) in middleware/fleetSyncGuards.ts and
replaces six inline FleetSyncService.getRole() === 'replica' checks
across the security policies and CVE suppressions endpoints.
Error responses now use a uniform shape:
403 { error: 'Cannot modify <resource> on a replica instance.
Connect to the primary.', code: 'REPLICA_READ_ONLY' }
The new code field gives callers a stable discriminator without
matching prose.
Closes #750
* test(suppressions): match stable REPLICA_READ_ONLY code instead of prose
The replica guard helper exposes a stable code field for callers to
discriminate without grepping the human-readable error string. Switch
the replica-rejection assertion to use that code so the test no longer
breaks when the unified error template wording is tuned.
|
||
|
|
8460ae9ede |
refactor(backend): hoist severity Sets to utils/severity.ts (#795)
Replaces five inline new Set([...]) literals scattered across routes/security.ts and routes/fleet.ts with two shared constants (FINDING_SEVERITIES, POLICY_SEVERITIES) exported from utils/severity.ts. Pure refactor: no error response or status code changes. Closes #749 |
||
|
|
1a6ae8309d |
refactor(backend): extract security router (Trivy, scans, SBOM, policies, suppressions, compare) (phase 4c-3) (#741)
Move the entire /api/security/* surface out of index.ts: - Trivy lifecycle: status, install, uninstall, update-check, update, auto-update toggle - Scanning: POST /scan (image), POST /scan/stack (compose) - Scan queries: list, get, vulnerabilities, secrets, misconfigs, image-summaries, SARIF export - SBOM generation - Scan policies CRUD (fleet-replicated; replica writes rejected) - CVE suppressions CRUD (fleet-replicated; replica writes rejected) - GET /compare: diff two scans Handlers moved verbatim. Local CVE_ID_RE, parseScannersInput, and shapeScanForResponse helpers move with the router. The previously-closure fetchAll inside the SARIF handler is hoisted to module scope as fetchAllPages so it is not re-allocated per request. Removes nine now-unused imports from index.ts (parsePolicyEvaluation, VulnerabilityScan, FleetSyncService, requireAdmiral, trivyInstallLimiter, SbomFormat, TrivyInstaller, validateImageRef, applySuppressions, generateSarif). index.ts drops from 2130 to 1437 lines. Remaining inline groups: containers, stacks, and nodes. |