mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +00:00
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.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Pins the fleet_sync_status cascade behavior of DatabaseService.deleteNode.
|
||||
*
|
||||
* Without this cleanup, deleting a node from Settings → Nodes leaves orphaned
|
||||
* sync-status rows behind that the UI then renders as ghost entries.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('deleteNode fleet_sync_status cascade', () => {
|
||||
it('removes fleet_sync_status rows for the deleted node', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.addNode({
|
||||
name: 'cascade-target',
|
||||
type: 'remote',
|
||||
compose_dir: '/app/compose',
|
||||
is_default: false,
|
||||
api_url: 'https://cascade.example',
|
||||
api_token: 'tok',
|
||||
mode: 'proxy',
|
||||
});
|
||||
|
||||
// Sibling node so we can confirm its rows survive.
|
||||
const siblingId = db.addNode({
|
||||
name: 'cascade-sibling',
|
||||
type: 'remote',
|
||||
compose_dir: '/app/compose',
|
||||
is_default: false,
|
||||
api_url: 'https://sibling.example',
|
||||
api_token: 'tok',
|
||||
mode: 'proxy',
|
||||
});
|
||||
|
||||
db.recordFleetSyncFailure(nodeId, 'scan_policies', 'timeout');
|
||||
db.recordFleetSyncFailure(nodeId, 'cve_suppressions', 'timeout');
|
||||
db.recordFleetSyncSuccess(siblingId, 'scan_policies');
|
||||
|
||||
const before = db.getFleetSyncStatuses();
|
||||
expect(before.filter((s) => s.node_id === nodeId)).toHaveLength(2);
|
||||
expect(before.filter((s) => s.node_id === siblingId)).toHaveLength(1);
|
||||
|
||||
db.deleteNode(nodeId);
|
||||
|
||||
const after = db.getFleetSyncStatuses();
|
||||
expect(after.filter((s) => s.node_id === nodeId)).toHaveLength(0);
|
||||
expect(after.filter((s) => s.node_id === siblingId)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -274,6 +274,47 @@ describe('POST /api/fleet/role/reanchor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/sync/:resource stack_pattern ReDoS guard', () => {
|
||||
it('rejects 4+ consecutive wildcards in a stack_pattern', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({
|
||||
rows: [{
|
||||
name: 'redos', node_identity: '', stack_pattern: 'foo****bar',
|
||||
max_severity: 'CRITICAL', block_on_deploy: 0, enabled: 1,
|
||||
}],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/4\+ consecutive wildcards/);
|
||||
});
|
||||
|
||||
it('rejects more than 8 wildcards anywhere in a stack_pattern', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({
|
||||
rows: [{
|
||||
name: 'too-many-stars', node_identity: '',
|
||||
stack_pattern: '*a*b*c*d*e*f*g*h*i*',
|
||||
max_severity: 'CRITICAL', block_on_deploy: 0, enabled: 1,
|
||||
}],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/too many wildcards/);
|
||||
});
|
||||
|
||||
it('regex compiled from a max-allowed pattern matches in under 50ms on a worst-case input', () => {
|
||||
const pattern = 'a*b*c*d*e*f*g*h'; // 7 stars, allowed.
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
||||
const re = new RegExp('^' + escaped + '$');
|
||||
const target = 'a' + 'a'.repeat(2000);
|
||||
const start = Date.now();
|
||||
re.test(target);
|
||||
expect(Date.now() - start).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/role/demote', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/fleet/role/demote').send({ confirm: true });
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
mockReplaceReplicatedCveSuppressions,
|
||||
mockClearOrphanPolicyEvaluations,
|
||||
mockClearReplicatedRows,
|
||||
mockInsertAuditLog,
|
||||
mockRecordFleetSyncSuccess,
|
||||
mockRecordFleetSyncFailure,
|
||||
mockGetSystemState,
|
||||
@@ -29,6 +30,7 @@ const {
|
||||
mockReplaceReplicatedCveSuppressions: vi.fn(),
|
||||
mockClearOrphanPolicyEvaluations: vi.fn(),
|
||||
mockClearReplicatedRows: vi.fn(),
|
||||
mockInsertAuditLog: vi.fn(),
|
||||
mockRecordFleetSyncSuccess: vi.fn(),
|
||||
mockRecordFleetSyncFailure: vi.fn(),
|
||||
mockGetSystemState: vi.fn().mockReturnValue(null),
|
||||
@@ -48,6 +50,7 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
replaceReplicatedCveSuppressions: mockReplaceReplicatedCveSuppressions,
|
||||
clearOrphanPolicyEvaluations: mockClearOrphanPolicyEvaluations,
|
||||
clearReplicatedRows: mockClearReplicatedRows,
|
||||
insertAuditLog: mockInsertAuditLog,
|
||||
recordFleetSyncSuccess: mockRecordFleetSyncSuccess,
|
||||
recordFleetSyncFailure: mockRecordFleetSyncFailure,
|
||||
getSystemState: mockGetSystemState,
|
||||
@@ -501,3 +504,93 @@ describe('FleetSyncService.getControlIdentity', () => {
|
||||
expect(FleetSyncService.getControlIdentity()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService incoming-sync side effects', () => {
|
||||
it('writes a system audit log entry on every applied sync', () => {
|
||||
FleetSyncService.getInstance().applyIncomingSync(
|
||||
'scan_policies',
|
||||
[],
|
||||
'https://me.example',
|
||||
1_700_000_000_000,
|
||||
'fingerprint-aaa',
|
||||
);
|
||||
expect(mockInsertAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
username: 'system',
|
||||
method: 'POST',
|
||||
path: '/api/fleet/sync/scan_policies',
|
||||
ip_address: 'control',
|
||||
summary: expect.stringContaining('Replicated scan_policies from fingerprint-aaa'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('emits an identity-drift notification when the targetIdentity changes', () => {
|
||||
mockGetSystemState.mockImplementation((key: string) => {
|
||||
if (key === 'fleet_self_identity') return 'https://old.example';
|
||||
return null;
|
||||
});
|
||||
FleetSyncService.getInstance().applyIncomingSync(
|
||||
'scan_policies',
|
||||
[],
|
||||
'https://new.example',
|
||||
);
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
'system',
|
||||
expect.stringContaining('Fleet self-identity changed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit identity-drift when there is no prior identity (first sync)', () => {
|
||||
mockGetSystemState.mockImplementation(() => null);
|
||||
FleetSyncService.getInstance().applyIncomingSync(
|
||||
'scan_policies',
|
||||
[],
|
||||
'https://first.example',
|
||||
);
|
||||
const driftCalls = mockDispatchAlert.mock.calls.filter((c) =>
|
||||
typeof c[2] === 'string' && c[2].includes('self-identity changed'),
|
||||
);
|
||||
expect(driftCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService.pushResource pilot-agent skip', () => {
|
||||
it('skips pilot-agent nodes and logs warn-once per node id', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 50, type: 'remote', mode: 'pilot_agent', api_url: '', api_token: '', name: 'PilotNode' },
|
||||
]);
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
const pilotWarns = warnSpy.mock.calls.filter((c) =>
|
||||
typeof c[0] === 'string' && c[0].includes('pilot-agent') && c[0].includes('PilotNode'),
|
||||
);
|
||||
expect(pilotWarns).toHaveLength(1);
|
||||
expect(mockAxiosPost).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService.formatError redaction', () => {
|
||||
it('redacts Bearer tokens and JWT-shaped values from error messages', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 51, type: 'remote', api_url: 'https://leak.example', api_token: 'tok', name: 'leak' },
|
||||
]);
|
||||
mockGetLocalScanPolicies.mockReturnValue([]);
|
||||
mockAxiosPost.mockImplementation(async () => {
|
||||
const { AxiosError } = await import('axios');
|
||||
const err = new AxiosError('Request failed');
|
||||
(err as unknown as { response: unknown }).response = {
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
data: { error: 'Authorization: Bearer abc.def-_~+/=secrettoken denied; jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signaturePart' },
|
||||
};
|
||||
throw err;
|
||||
});
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
const failure = mockRecordFleetSyncFailure.mock.calls[0];
|
||||
expect(failure[2]).not.toMatch(/Bearer\s+[A-Za-z0-9]/);
|
||||
expect(failure[2]).toContain('[redacted]');
|
||||
expect(failure[2]).toContain('[redacted-jwt]');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user