Files
sencho/backend/src/services/CapabilityRegistry.ts
T
Anso dc8370f5a4 fix(security): harden Trivy scan lifecycle, logging, and docs (#639)
* fix(security): harden Trivy scan lifecycle, logging, and docs

- Call TrivyService.initialize() at startup so capability state is
  accurate before first request; add periodic re-detect to the scheduler
  so newly installed Trivy binaries light up without a restart.
- Add markStaleScansAsFailed sweep (+ idx_vuln_scans_status index) to
  recover any scan row left in_progress after a crash or timeout; sweep
  runs before the paid-tier gate so every tier self-heals.
- Split scanImage persistence into beginScan/finishScan so the manual
  scan route owns a single code path and can return a scanId synchronously
  while work continues asynchronously.
- Validate image refs on /api/security/scan and /sbom via new utility;
  defense-in-depth against shell-metacharacter payloads.
- Dispatch a warning-level alert when a post-deploy scan fails so the
  operator has a user-visible path to the failure instead of a silent log.
- Share DIGEST_CACHE_TTL_MS and severity ordering across service and
  route layers; remove dead invalidateDetection().
- Add [Trivy:diag] logging gated behind developer_mode for support
  diagnostics; production logs unchanged.
- Frontend: defensive toast fallback chain, sr-only SheetDescription,
  and a truncation badge when the 500-item detail fetch is capped.
- Tests: extend trivy-service and vulnerability-db suites; add
  image-ref and severity unit tests.
- Docs: expand vulnerability-scanning troubleshooting with recovery,
  re-detect, and diagnostic-log guidance; link Dockerfile comment to
  trivy-setup.

* fix(security): drop unnecessary escape in image-ref forbidden-char regex
2026-04-16 20:32:38 -04:00

119 lines
3.9 KiB
TypeScript

import axios from 'axios';
import path from 'path';
import fs from 'fs';
import semver from 'semver';
import { SENCHO_VERSION } from '../generated/version';
/**
* Static registry of capabilities supported by THIS Sencho instance.
* Append-only: when a new feature ships, add its capability string here.
* The frontend uses these flags (not semver comparisons) to gate features
* on nodes that may be running older versions.
*/
export const CAPABILITIES = [
'stacks',
'containers',
'resources',
'templates',
'global-logs',
'system-stats',
'fleet',
'auto-updates',
'labels',
'webhooks',
'network-topology',
'notifications',
'notification-routing',
'host-console',
'container-exec',
'audit-log',
'scheduled-ops',
'sso',
'api-tokens',
'users',
'registries',
'self-update',
'vulnerability-scanning',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Returns true when the string is a usable semver version. */
export function isValidVersion(v: string | null | undefined): v is string {
return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v);
}
// Resolved once per process at import time, then cached.
function resolveVersion(): string | null {
// Primary: walk up to find the root package.json (always authoritative).
// The generated SENCHO_VERSION constant can be stale when a branch falls
// behind a release-please version bump, so we prefer the live value.
let dir = __dirname;
for (let i = 0; i < 5; i++) {
const candidate = path.join(dir, 'package.json');
try {
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
if (pkg.name === 'sencho') return pkg.version;
} catch { /* not found, keep walking */ }
dir = path.dirname(dir);
}
// Fallback: build-time constant (may be stale in dev, but correct in Docker)
if (SENCHO_VERSION !== '0.0.0-dev') return SENCHO_VERSION;
console.warn('[CapabilityRegistry] Could not resolve Sencho version from any source');
return null;
}
const cachedVersion = resolveVersion();
export function getSenchoVersion(): string | null {
return cachedVersion;
}
export interface RemoteMeta {
version: string | null;
capabilities: string[];
startedAt: number | null;
/** Error message from a failed self-update attempt on the remote node. */
updateError: string | null;
/** True when the /api/meta request succeeded (node is reachable). */
online: boolean;
}
// Runtime capability overrides — services call disableCapability() during init
const disabledCapabilities = new Set<Capability>();
export function disableCapability(c: Capability): void {
disabledCapabilities.add(c);
}
export function enableCapability(c: Capability): void {
disabledCapabilities.delete(c);
}
/** Returns capabilities this instance actually supports at runtime. */
export function getActiveCapabilities(): readonly string[] {
if (disabledCapabilities.size === 0) return CAPABILITIES;
return CAPABILITIES.filter(c => !disabledCapabilities.has(c));
}
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
try {
const res = await axios.get(`${baseUrl.replace(/\/$/, '')}/api/meta`, {
headers: { Authorization: `Bearer ${apiToken}` },
timeout: 5000,
});
const rawVersion: string | undefined = res.data.version;
return {
version: isValidVersion(rawVersion) ? rawVersion : null,
capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [],
startedAt: typeof res.data.startedAt === 'number' ? res.data.startedAt : null,
updateError: typeof res.data.updateError === 'string' ? res.data.updateError : null,
online: true,
};
} catch (err) {
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${baseUrl}:`, (err as Error).message);
return { version: null, capabilities: [], startedAt: null, updateError: null, online: false };
}
}