mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
fix(nodes): close capability-gating gaps in node compatibility (#1261)
* fix(nodes): close capability-gating gaps in node compatibility Vulnerability scanning is now gated correctly on whether the active node advertises support for it: - A node without the Trivy binary stops advertising the scanning capability. Previously the capability was toggled only on a state change, so a node that booted without Trivy kept advertising scanning it could not perform. - The control node's own capability list now reflects features disabled at runtime, matching what it advertises to peers. - The scan history surface shows a clear "not available on this node" card, with its header actions hidden, instead of attempting a request that fails. A node's version and capability metadata now refreshes immediately after a connection test or a completed update, rather than waiting out the cache. Capability gates fail closed to the unavailable card when a node's metadata request errors, instead of staying open until the next fetch. Adds a test that fails if the frontend and backend capability lists drift, plus coverage for the metadata error path, the runtime-disabled local meta, the scanning capability sync, and the metadata cache invalidation paths. * fix(nodes): refresh node metadata client-side after a connection test A connection test dropped the server-side metadata cache, but the dashboard kept its own cached copy until the client TTL expired, so version and capability gates could stay stale in the browser. The test now forces a client-side metadata refresh for that node, so the version pill and gates reflect the node's current state immediately. Also strips any URL userinfo before logging the metadata fetch target, and makes the scanning-capability detection test deterministically exercise the no-binary disable path rather than depending on whether the runner has Trivy.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Guards against frontend/backend capability-registry drift.
|
||||
*
|
||||
* The capability list is maintained as two hand-written constants:
|
||||
* backend/src/services/CapabilityRegistry.ts and frontend/src/lib/capabilities.ts.
|
||||
* They MUST be identical, because the frontend gates features on the flags the
|
||||
* backend advertises. A capability present on only one side either makes a gate
|
||||
* impossible to express (frontend missing it) or advertises a feature the
|
||||
* frontend never gates (backend missing it). This test fails the moment the two
|
||||
* lists diverge so the drift is caught at CI time, not in production.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { CAPABILITIES } from '../services/CapabilityRegistry';
|
||||
|
||||
function extractCapabilities(source: string): string[] {
|
||||
const start = source.indexOf('CAPABILITIES = [');
|
||||
if (start === -1) throw new Error('Could not locate CAPABILITIES array in frontend source');
|
||||
const end = source.indexOf(']', start);
|
||||
if (end === -1) throw new Error('Could not locate end of CAPABILITIES array in frontend source');
|
||||
const block = source.slice(start, end);
|
||||
return [...block.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
describe('capability list parity (frontend <-> backend)', () => {
|
||||
it('frontend lib/capabilities.ts matches backend CapabilityRegistry exactly', () => {
|
||||
const frontendPath = path.resolve(__dirname, '../../../frontend/src/lib/capabilities.ts');
|
||||
const frontendSource = fs.readFileSync(frontendPath, 'utf8');
|
||||
const frontendCaps = extractCapabilities(frontendSource);
|
||||
|
||||
expect([...frontendCaps].sort()).toEqual([...CAPABILITIES].sort());
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { RemoteMeta } from '../services/CapabilityRegistry';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
|
||||
const LOOPBACK = 'http://127.0.0.1:54322';
|
||||
|
||||
@@ -233,3 +234,51 @@ describe('POST /api/fleet/update-all (pilot-agent mixed fleet)', () => {
|
||||
expect(systemUpdateCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/update-status (remote-meta cache invalidation)', () => {
|
||||
// Satisfy getCompareTarget's GitHub lookup with a benign response so the
|
||||
// route does not make a real network call during the test.
|
||||
function mockCompareTargetFetch() {
|
||||
mockFetch(() =>
|
||||
new Response(JSON.stringify({ tag_name: 'v0.99.0' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('drops the cached meta when a node transitions to completed', async () => {
|
||||
mockTargetForPilot();
|
||||
mockCompareTargetFetch();
|
||||
// Remote now reports a different version than before the update (signal 1).
|
||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true });
|
||||
|
||||
const tracker = FleetUpdateTrackerService.getInstance();
|
||||
tracker.set(proxyNodeId, tracker.create('updating', '0.83.0', null));
|
||||
|
||||
const invalidateSpy = vi.spyOn(CacheService.getInstance(), 'invalidate');
|
||||
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.status).toBe('completed');
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(`remote-meta:${proxyNodeId}`);
|
||||
});
|
||||
|
||||
it('does not drop the cache on a steady-state completed poll (transition guard)', async () => {
|
||||
mockTargetForPilot();
|
||||
mockCompareTargetFetch();
|
||||
mockMeta({ version: '0.99.0', capabilities: ['stacks'], startedAt: 2, updateError: null, online: true });
|
||||
|
||||
// Already completed before this poll: no transition, so no invalidation.
|
||||
const tracker = FleetUpdateTrackerService.getInstance();
|
||||
tracker.set(proxyNodeId, tracker.create('completed', '0.83.0', null));
|
||||
|
||||
const invalidateSpy = vi.spyOn(CacheService.getInstance(), 'invalidate');
|
||||
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', authHeader);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(invalidateSpy).not.toHaveBeenCalledWith(`remote-meta:${proxyNodeId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Tests for node management API - focusing on api_url validation (SSRF fix C2).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { disableCapability, enableCapability } from '../services/CapabilityRegistry';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
@@ -79,6 +82,44 @@ describe('POST /api/nodes - api_url SSRF validation (C2 fix)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/nodes/:id/meta - local meta honors runtime-disabled capabilities', () => {
|
||||
it('omits a capability that has been disabled at runtime', async () => {
|
||||
const list = await request(app).get('/api/nodes').set('Authorization', authHeader);
|
||||
const local = (list.body as Array<{ id: number; type: string }>).find((n) => n.type === 'local');
|
||||
expect(local).toBeTruthy();
|
||||
|
||||
disableCapability('vulnerability-scanning');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.get(`/api/nodes/${local!.id}/meta`)
|
||||
.set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.capabilities).toContain('stacks');
|
||||
expect(res.body.capabilities).not.toContain('vulnerability-scanning');
|
||||
} finally {
|
||||
enableCapability('vulnerability-scanning');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/nodes/:id/test - invalidates remote-meta cache', () => {
|
||||
it('drops the cached meta so the next read rebuilds version and capabilities live', async () => {
|
||||
const testSpy = vi
|
||||
.spyOn(NodeRegistry.getInstance(), 'testConnection')
|
||||
.mockResolvedValue({ success: true });
|
||||
const invalidateSpy = vi.spyOn(CacheService.getInstance(), 'invalidate');
|
||||
try {
|
||||
const res = await request(app).post('/api/nodes/7/test').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(testSpy).toHaveBeenCalledWith(7);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith('remote-meta:7');
|
||||
} finally {
|
||||
testSpy.mockRestore();
|
||||
invalidateSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stack name validation on GET routes (H3 fix)', () => {
|
||||
it('rejects path traversal in GET /api/stacks/:stackName', async () => {
|
||||
const res = await request(app)
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
* highest-severity rollup, duplicate scan prevention, and graceful handling
|
||||
* when the binary is not available.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import TrivyService, { parseTrivyOutput } from '../services/TrivyService';
|
||||
import TrivyInstaller from '../services/TrivyInstaller';
|
||||
import { getActiveCapabilities, enableCapability } from '../services/CapabilityRegistry';
|
||||
|
||||
describe('TrivyService', () => {
|
||||
let svc: TrivyService;
|
||||
@@ -15,6 +17,13 @@ describe('TrivyService', () => {
|
||||
svc = TrivyService.getInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// detectTrivy() toggles a process-global capability flag. Restore the
|
||||
// default (enabled) so this suite cannot leak a disabled state into another
|
||||
// suite sharing the worker.
|
||||
enableCapability('vulnerability-scanning');
|
||||
});
|
||||
|
||||
describe('isTrivyAvailable', () => {
|
||||
it('returns false when binary has not been detected', () => {
|
||||
// Service default state: available=false until initialize() runs
|
||||
@@ -37,6 +46,45 @@ describe('TrivyService', () => {
|
||||
await svc.detectTrivy();
|
||||
expect(svc.getDetectionTimestamp()).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it('keeps the vulnerability-scanning capability in lockstep with detected availability', async () => {
|
||||
// Regression guard: detection used to toggle the capability only on a
|
||||
// state transition, so a process that boots without Trivy (source starts
|
||||
// at 'none', wasAvailable === false) never disabled it and kept
|
||||
// advertising scanning on a node that cannot scan. Start from the enabled
|
||||
// state (the buggy starting point) so that on a Trivy-less runner this
|
||||
// proves the disable branch fired; the advertised capability must equal
|
||||
// the detected availability after every detection.
|
||||
enableCapability('vulnerability-scanning');
|
||||
const result = await svc.detectTrivy();
|
||||
const advertised = getActiveCapabilities().includes('vulnerability-scanning');
|
||||
expect(advertised).toBe(result.available);
|
||||
});
|
||||
|
||||
it('disables the capability from the enabled state when no binary is found (deterministic)', async () => {
|
||||
// Force every detection candidate to miss regardless of the runner: bogus
|
||||
// managed path, bogus TRIVY_BIN, and an emptied PATH so a bare `trivy`
|
||||
// cannot resolve. This reproduces the boot-without-Trivy case the fix
|
||||
// targets and proves the disable branch fires even starting from enabled.
|
||||
const installerSpy = vi
|
||||
.spyOn(TrivyInstaller.getInstance(), 'binaryPath')
|
||||
.mockReturnValue('/nonexistent/managed/trivy');
|
||||
const prevTrivyBin = process.env.TRIVY_BIN;
|
||||
const prevPath = process.env.PATH;
|
||||
process.env.TRIVY_BIN = '/nonexistent/env/trivy';
|
||||
process.env.PATH = '';
|
||||
enableCapability('vulnerability-scanning');
|
||||
try {
|
||||
const result = await svc.detectTrivy();
|
||||
expect(result.available).toBe(false);
|
||||
expect(getActiveCapabilities()).not.toContain('vulnerability-scanning');
|
||||
} finally {
|
||||
installerSpy.mockRestore();
|
||||
if (prevTrivyBin === undefined) delete process.env.TRIVY_BIN;
|
||||
else process.env.TRIVY_BIN = prevTrivyBin;
|
||||
process.env.PATH = prevPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanImage', () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { formatNoTargetError } from '../utils/remoteTarget';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cacheInvalidation';
|
||||
import { containerActionForStack } from './stacks';
|
||||
import { activeBulkActions } from './labels';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
@@ -702,6 +702,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
const results = await Promise.allSettled(
|
||||
nodes.map(async (node) => {
|
||||
const tracker = updateTracker.get(node.id);
|
||||
const statusBeforeResolve = tracker?.status;
|
||||
|
||||
let version: string | null = null;
|
||||
let remoteStartedAt: number | null = null;
|
||||
@@ -799,6 +800,17 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
}
|
||||
|
||||
const currentTracker = updateTracker.get(node.id);
|
||||
// A node that just finished updating runs new code with a possibly
|
||||
// different version and capability set. Drop its cached /api/meta on
|
||||
// the completion transition so the dashboard reflects the new state
|
||||
// immediately instead of waiting out the remote-meta TTL.
|
||||
if (
|
||||
node.type === 'remote' &&
|
||||
statusBeforeResolve !== 'completed' &&
|
||||
currentTracker?.status === 'completed'
|
||||
) {
|
||||
invalidateRemoteMetaCache(node.id);
|
||||
}
|
||||
return {
|
||||
nodeId: node.id,
|
||||
name: node.name,
|
||||
|
||||
@@ -9,8 +9,8 @@ import { enrollmentLimiter } from '../middleware/rateLimiters';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
import { REMOTE_META_NAMESPACE } from '../helpers/cacheInvalidation';
|
||||
import { CAPABILITIES, getSenchoVersion, type RemoteMeta } from '../services/CapabilityRegistry';
|
||||
import { REMOTE_META_NAMESPACE, invalidateRemoteMetaCache } from '../helpers/cacheInvalidation';
|
||||
import { getActiveCapabilities, getSenchoVersion, type RemoteMeta } from '../services/CapabilityRegistry';
|
||||
import { PilotTunnelManager } from '../services/PilotTunnelManager';
|
||||
import { PilotCloseCode } from '../pilot/protocol';
|
||||
import { MeshProxyTunnelDialer } from '../services/MeshProxyTunnelDialer';
|
||||
@@ -476,6 +476,10 @@ nodesRouter.post('/:id/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const result = await NodeRegistry.getInstance().testConnection(id);
|
||||
// An explicit test is the operator asking for fresh truth: drop the cached
|
||||
// /api/meta so the next read rebuilds version and capabilities live rather
|
||||
// than serving a value up to the remote-meta TTL old.
|
||||
invalidateRemoteMetaCache(id);
|
||||
res.json(result);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Connection test failed';
|
||||
@@ -493,7 +497,7 @@ nodesRouter.get('/:id/meta', authMiddleware, async (req: Request, res: Response)
|
||||
}
|
||||
|
||||
if (node.type === 'local') {
|
||||
res.json({ version: getSenchoVersion(), capabilities: CAPABILITIES });
|
||||
res.json({ version: getSenchoVersion(), capabilities: getActiveCapabilities() });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import semver from 'semver';
|
||||
import { SENCHO_VERSION } from '../generated/version';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
/**
|
||||
* Static registry of capabilities supported by THIS Sencho instance.
|
||||
@@ -125,23 +126,38 @@ export const OFFLINE_META: RemoteMeta = {
|
||||
online: false,
|
||||
};
|
||||
|
||||
/** Strip any `user:pass@` userinfo from a URL so credentials never reach the logs. */
|
||||
function redactUrlCredentials(url: string): string {
|
||||
return url.replace(/(\/\/)[^/@]*@/, '$1');
|
||||
}
|
||||
|
||||
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
|
||||
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
|
||||
const safeUrl = redactUrlCredentials(baseUrl);
|
||||
try {
|
||||
const res = await axios.get(`${baseUrl.replace(/\/$/, '')}/api/meta`, {
|
||||
headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
|
||||
timeout: 5000,
|
||||
});
|
||||
const rawVersion: string | undefined = res.data.version;
|
||||
return {
|
||||
const meta: RemoteMeta = {
|
||||
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,
|
||||
};
|
||||
if (isDebugEnabled()) {
|
||||
// Diagnostic aid for "why is this feature gated?": log the resolved version
|
||||
// and capability count (not the full list) at the one boundary that decides
|
||||
// gating. The URL is logged with any userinfo credentials stripped.
|
||||
console.log(
|
||||
`[CapabilityRegistry:diag] meta ok from ${safeUrl}: version=${meta.version ?? 'null'} capabilities=${meta.capabilities.length}`,
|
||||
);
|
||||
}
|
||||
return meta;
|
||||
} catch (err) {
|
||||
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${baseUrl}:`, (err as Error).message);
|
||||
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${safeUrl}:`, (err as Error).message);
|
||||
return { ...OFFLINE_META };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,13 +409,20 @@ class TrivyService {
|
||||
this.detectionTimestamp - started
|
||||
}`,
|
||||
);
|
||||
if (isAvailable && !wasAvailable) {
|
||||
// Sync the capability unconditionally so a node that boots without Trivy
|
||||
// (the common case) stops advertising vulnerability-scanning. A transition-only
|
||||
// toggle missed this: source starts at 'none', so wasAvailable is false on the
|
||||
// first detection and the disable branch never fired. Set add/delete is idempotent.
|
||||
if (isAvailable) {
|
||||
enableCapability('vulnerability-scanning');
|
||||
} else {
|
||||
disableCapability('vulnerability-scanning');
|
||||
}
|
||||
if (isAvailable && !wasAvailable) {
|
||||
console.log(
|
||||
`[Trivy] Binary detected (source=${this.source}); vulnerability scanning enabled (version ${this.version})`,
|
||||
);
|
||||
} else if (!isAvailable && wasAvailable) {
|
||||
disableCapability('vulnerability-scanning');
|
||||
console.warn('[Trivy] Binary no longer detected; vulnerability scanning disabled');
|
||||
}
|
||||
return { available: isAvailable, version: this.version, source: this.source };
|
||||
|
||||
Reference in New Issue
Block a user