mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +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', () => {
|
describe('POST /api/fleet/role/demote', () => {
|
||||||
it('returns 401 without auth', async () => {
|
it('returns 401 without auth', async () => {
|
||||||
const res = await request(app).post('/api/fleet/role/demote').send({ confirm: true });
|
const res = await request(app).post('/api/fleet/role/demote').send({ confirm: true });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const {
|
|||||||
mockReplaceReplicatedCveSuppressions,
|
mockReplaceReplicatedCveSuppressions,
|
||||||
mockClearOrphanPolicyEvaluations,
|
mockClearOrphanPolicyEvaluations,
|
||||||
mockClearReplicatedRows,
|
mockClearReplicatedRows,
|
||||||
|
mockInsertAuditLog,
|
||||||
mockRecordFleetSyncSuccess,
|
mockRecordFleetSyncSuccess,
|
||||||
mockRecordFleetSyncFailure,
|
mockRecordFleetSyncFailure,
|
||||||
mockGetSystemState,
|
mockGetSystemState,
|
||||||
@@ -29,6 +30,7 @@ const {
|
|||||||
mockReplaceReplicatedCveSuppressions: vi.fn(),
|
mockReplaceReplicatedCveSuppressions: vi.fn(),
|
||||||
mockClearOrphanPolicyEvaluations: vi.fn(),
|
mockClearOrphanPolicyEvaluations: vi.fn(),
|
||||||
mockClearReplicatedRows: vi.fn(),
|
mockClearReplicatedRows: vi.fn(),
|
||||||
|
mockInsertAuditLog: vi.fn(),
|
||||||
mockRecordFleetSyncSuccess: vi.fn(),
|
mockRecordFleetSyncSuccess: vi.fn(),
|
||||||
mockRecordFleetSyncFailure: vi.fn(),
|
mockRecordFleetSyncFailure: vi.fn(),
|
||||||
mockGetSystemState: vi.fn().mockReturnValue(null),
|
mockGetSystemState: vi.fn().mockReturnValue(null),
|
||||||
@@ -48,6 +50,7 @@ vi.mock('../services/DatabaseService', () => ({
|
|||||||
replaceReplicatedCveSuppressions: mockReplaceReplicatedCveSuppressions,
|
replaceReplicatedCveSuppressions: mockReplaceReplicatedCveSuppressions,
|
||||||
clearOrphanPolicyEvaluations: mockClearOrphanPolicyEvaluations,
|
clearOrphanPolicyEvaluations: mockClearOrphanPolicyEvaluations,
|
||||||
clearReplicatedRows: mockClearReplicatedRows,
|
clearReplicatedRows: mockClearReplicatedRows,
|
||||||
|
insertAuditLog: mockInsertAuditLog,
|
||||||
recordFleetSyncSuccess: mockRecordFleetSyncSuccess,
|
recordFleetSyncSuccess: mockRecordFleetSyncSuccess,
|
||||||
recordFleetSyncFailure: mockRecordFleetSyncFailure,
|
recordFleetSyncFailure: mockRecordFleetSyncFailure,
|
||||||
getSystemState: mockGetSystemState,
|
getSystemState: mockGetSystemState,
|
||||||
@@ -501,3 +504,93 @@ describe('FleetSyncService.getControlIdentity', () => {
|
|||||||
expect(FleetSyncService.getControlIdentity()).toBe('');
|
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]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ function validateScanPolicyRow(row: unknown): string | null {
|
|||||||
if (typeof r.name !== 'string' || r.name.length === 0 || r.name.length > 200) return 'name must be a non-empty string';
|
if (typeof r.name !== 'string' || r.name.length === 0 || r.name.length > 200) return 'name must be a non-empty string';
|
||||||
if (typeof r.max_severity !== 'string' || !POLICY_SEVERITIES.has(r.max_severity)) return 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW';
|
if (typeof r.max_severity !== 'string' || !POLICY_SEVERITIES.has(r.max_severity)) return 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW';
|
||||||
if (r.stack_pattern !== null && typeof r.stack_pattern !== 'string') return 'stack_pattern must be a string or null';
|
if (r.stack_pattern !== null && typeof r.stack_pattern !== 'string') return 'stack_pattern must be a string or null';
|
||||||
if (typeof r.stack_pattern === 'string' && r.stack_pattern.length > 200) return 'stack_pattern is too long';
|
if (typeof r.stack_pattern === 'string') {
|
||||||
|
const patternError = validateStackPatternForRedos(r.stack_pattern);
|
||||||
|
if (patternError) return patternError;
|
||||||
|
}
|
||||||
if (typeof r.node_identity !== 'string') return 'node_identity must be a string';
|
if (typeof r.node_identity !== 'string') return 'node_identity must be a string';
|
||||||
if (r.node_identity.length > 500) return 'node_identity is too long';
|
if (r.node_identity.length > 500) return 'node_identity is too long';
|
||||||
if (!isIntFlag(r.block_on_deploy)) return 'block_on_deploy must be 0 or 1';
|
if (!isIntFlag(r.block_on_deploy)) return 'block_on_deploy must be 0 or 1';
|
||||||
@@ -56,6 +59,24 @@ function validateScanPolicyRow(row: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject `stack_pattern` inputs that would compile to a backtracking-prone
|
||||||
|
* regex. The matcher in `getMatchingPolicy` substitutes `*` with `.*`, so a
|
||||||
|
* pattern like `***...` becomes a chain of adjacent `.*` runs that exhibit
|
||||||
|
* catastrophic backtracking on long inputs.
|
||||||
|
*
|
||||||
|
* Caps mirror the limit in routes/security.ts so a control creating a policy
|
||||||
|
* sees the same error as a replica receiving one. Length is gated at 200 by
|
||||||
|
* the surrounding row validator.
|
||||||
|
*/
|
||||||
|
export function validateStackPatternForRedos(pattern: string): string | null {
|
||||||
|
if (pattern.length > 200) return 'stack_pattern is too long';
|
||||||
|
const stars = (pattern.match(/\*/g) ?? []).length;
|
||||||
|
if (stars > 8) return 'stack_pattern has too many wildcards (max 8)';
|
||||||
|
if (/\*{4,}/.test(pattern)) return 'stack_pattern must not contain 4+ consecutive wildcards';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function validateCveSuppressionRow(row: unknown): string | null {
|
function validateCveSuppressionRow(row: unknown): string | null {
|
||||||
if (!row || typeof row !== 'object') return 'row must be an object';
|
if (!row || typeof row !== 'object') return 'row must be an object';
|
||||||
const r = row as Record<string, unknown>;
|
const r = row as Record<string, unknown>;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { sanitizeForLog } from '../utils/safeLog';
|
|||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
import { isDebugEnabled } from '../utils/debug';
|
import { isDebugEnabled } from '../utils/debug';
|
||||||
import { blockIfReplica } from '../middleware/fleetSyncGuards';
|
import { blockIfReplica } from '../middleware/fleetSyncGuards';
|
||||||
|
import { validateStackPatternForRedos } from './fleet';
|
||||||
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
|
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
|
||||||
|
|
||||||
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
|
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
|
||||||
@@ -423,13 +424,20 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response):
|
|||||||
if (!POLICY_SEVERITIES.has(max_severity)) {
|
if (!POLICY_SEVERITIES.has(max_severity)) {
|
||||||
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
|
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
|
||||||
}
|
}
|
||||||
|
const normalizedPattern = stack_pattern ? String(stack_pattern) : null;
|
||||||
|
if (normalizedPattern !== null) {
|
||||||
|
const patternError = validateStackPatternForRedos(normalizedPattern);
|
||||||
|
if (patternError) {
|
||||||
|
res.status(400).json({ error: patternError }); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const resolvedNodeId = node_id != null ? Number(node_id) : null;
|
const resolvedNodeId = node_id != null ? Number(node_id) : null;
|
||||||
const policy = DatabaseService.getInstance().createScanPolicy({
|
const policy = DatabaseService.getInstance().createScanPolicy({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
node_id: resolvedNodeId,
|
node_id: resolvedNodeId,
|
||||||
node_identity: FleetSyncService.resolveIdentityForNodeId(resolvedNodeId),
|
node_identity: FleetSyncService.resolveIdentityForNodeId(resolvedNodeId),
|
||||||
stack_pattern: stack_pattern ? String(stack_pattern) : null,
|
stack_pattern: normalizedPattern,
|
||||||
max_severity,
|
max_severity,
|
||||||
block_on_deploy: block_on_deploy ? 1 : 0,
|
block_on_deploy: block_on_deploy ? 1 : 0,
|
||||||
enabled: enabled === false ? 0 : 1,
|
enabled: enabled === false ? 0 : 1,
|
||||||
@@ -459,7 +467,16 @@ securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response
|
|||||||
updates.node_id = resolvedNodeId;
|
updates.node_id = resolvedNodeId;
|
||||||
updates.node_identity = FleetSyncService.resolveIdentityForNodeId(resolvedNodeId);
|
updates.node_identity = FleetSyncService.resolveIdentityForNodeId(resolvedNodeId);
|
||||||
}
|
}
|
||||||
if (body.stack_pattern !== undefined) updates.stack_pattern = body.stack_pattern ? String(body.stack_pattern) : null;
|
if (body.stack_pattern !== undefined) {
|
||||||
|
const normalizedPattern = body.stack_pattern ? String(body.stack_pattern) : null;
|
||||||
|
if (normalizedPattern !== null) {
|
||||||
|
const patternError = validateStackPatternForRedos(normalizedPattern);
|
||||||
|
if (patternError) {
|
||||||
|
res.status(400).json({ error: patternError }); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates.stack_pattern = normalizedPattern;
|
||||||
|
}
|
||||||
if (body.max_severity !== undefined) {
|
if (body.max_severity !== undefined) {
|
||||||
if (!POLICY_SEVERITIES.has(body.max_severity)) {
|
if (!POLICY_SEVERITIES.has(body.max_severity)) {
|
||||||
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
|
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
|
||||||
|
|||||||
@@ -2097,6 +2097,7 @@ export class DatabaseService {
|
|||||||
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
|
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
|
||||||
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
|
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
|
||||||
this.deleteRoleAssignmentsByResource('node', String(id));
|
this.deleteRoleAssignmentsByResource('node', String(id));
|
||||||
|
this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id);
|
||||||
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
|
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ export class FleetSyncService {
|
|||||||
|
|
||||||
private static cachedControlIdentity: string | null = null;
|
private static cachedControlIdentity: string | null = null;
|
||||||
|
|
||||||
|
/** Node ids already warned about as pilot-agent skip candidates. */
|
||||||
|
private static readonly warnedPilotAgents = new Set<number>();
|
||||||
|
|
||||||
private nextPushedAt(): number {
|
private nextPushedAt(): number {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const next = now > FleetSyncService.lastPushedAt ? now : FleetSyncService.lastPushedAt + 1;
|
const next = now > FleetSyncService.lastPushedAt ? now : FleetSyncService.lastPushedAt + 1;
|
||||||
@@ -191,8 +194,21 @@ export class FleetSyncService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const db = DatabaseService.getInstance();
|
const db = DatabaseService.getInstance();
|
||||||
const nodes = db.getNodes().filter((n): n is Node & { id: number } => {
|
const allNodes = db.getNodes();
|
||||||
return n.type === 'remote' && Boolean(n.api_url) && Boolean(n.api_token) && n.id != null;
|
// Pilot-agent nodes do not accept fleet-sync HTTP pushes; security
|
||||||
|
// rules for them are out of scope until pilot tunneling is built. Warn
|
||||||
|
// once per node id so the operator knows their pilot remote will not
|
||||||
|
// receive replicated policies.
|
||||||
|
for (const n of allNodes) {
|
||||||
|
if (n.mode === 'pilot_agent' && n.id != null && !FleetSyncService.warnedPilotAgents.has(n.id)) {
|
||||||
|
console.warn(
|
||||||
|
`[FleetSync] Skipping pilot-agent node "${n.name}" (id=${n.id}); fleet sync over pilot tunnel is out of scope.`,
|
||||||
|
);
|
||||||
|
FleetSyncService.warnedPilotAgents.add(n.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nodes = allNodes.filter((n): n is Node & { id: number } => {
|
||||||
|
return n.type === 'remote' && n.mode !== 'pilot_agent' && Boolean(n.api_url) && Boolean(n.api_token) && n.id != null;
|
||||||
});
|
});
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
if (isDebugEnabled()) {
|
if (isDebugEnabled()) {
|
||||||
@@ -299,6 +315,21 @@ export class FleetSyncService {
|
|||||||
}
|
}
|
||||||
db.setSystemState(SYNC_STATE_KEYS.fleetRole, 'replica');
|
db.setSystemState(SYNC_STATE_KEYS.fleetRole, 'replica');
|
||||||
if (targetIdentity) {
|
if (targetIdentity) {
|
||||||
|
const cachedSelf = db.getSystemState(SYNC_STATE_KEYS.fleetSelfIdentity);
|
||||||
|
if (cachedSelf && cachedSelf !== targetIdentity) {
|
||||||
|
// Operator changed how the control sees this node (e.g. an
|
||||||
|
// api_url switch). Notify so they can audit any
|
||||||
|
// identity-scoped policies that may need re-targeting.
|
||||||
|
void NotificationService.getInstance()
|
||||||
|
.dispatchAlert(
|
||||||
|
'warning',
|
||||||
|
'system',
|
||||||
|
`Fleet self-identity changed from "${cachedSelf}" to "${targetIdentity}". Identity-scoped policies are reapplied on the next sync.`,
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[FleetSync] Failed to dispatch identity-drift alert:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
db.setSystemState(SYNC_STATE_KEYS.fleetSelfIdentity, targetIdentity);
|
db.setSystemState(SYNC_STATE_KEYS.fleetSelfIdentity, targetIdentity);
|
||||||
}
|
}
|
||||||
if (resource === 'scan_policies') {
|
if (resource === 'scan_policies') {
|
||||||
@@ -306,6 +337,20 @@ export class FleetSyncService {
|
|||||||
} else if (resource === 'cve_suppressions') {
|
} else if (resource === 'cve_suppressions') {
|
||||||
db.replaceReplicatedCveSuppressions(rows as Array<Omit<CveSuppression, 'id'>>);
|
db.replaceReplicatedCveSuppressions(rows as Array<Omit<CveSuppression, 'id'>>);
|
||||||
}
|
}
|
||||||
|
// F4: persist an audit-log entry for the operator on the replica
|
||||||
|
// side. Without this, mirrored security-rule changes happen
|
||||||
|
// silently from the replica's perspective. Username 'system' and
|
||||||
|
// ip 'control' make the source unambiguous in the audit panel.
|
||||||
|
db.insertAuditLog({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: 'system',
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/fleet/sync/${resource}`,
|
||||||
|
status_code: 200,
|
||||||
|
node_id: 0,
|
||||||
|
ip_address: 'control',
|
||||||
|
summary: `Replicated ${resource} from ${controlIdentity || 'legacy control'}: replaced ${rows.length} row(s)`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,16 +549,33 @@ export class FleetSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatError(err: unknown): string {
|
private formatError(err: unknown): string {
|
||||||
|
let raw: string;
|
||||||
if (err instanceof AxiosError) {
|
if (err instanceof AxiosError) {
|
||||||
if (err.response) {
|
if (err.response) {
|
||||||
const data = err.response.data;
|
const data = err.response.data;
|
||||||
const detail = typeof data === 'object' && data && 'error' in data
|
const detail = typeof data === 'object' && data && 'error' in data
|
||||||
? String((data as { error: unknown }).error)
|
? String((data as { error: unknown }).error)
|
||||||
: err.response.statusText;
|
: err.response.statusText;
|
||||||
return `HTTP ${err.response.status}: ${detail}`;
|
raw = `HTTP ${err.response.status}: ${detail}`;
|
||||||
|
} else {
|
||||||
|
raw = err.message;
|
||||||
}
|
}
|
||||||
return err.message;
|
} else {
|
||||||
|
raw = err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
return err instanceof Error ? err.message : String(err);
|
return FleetSyncService.redactSensitive(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip Bearer tokens and JWT-shaped substrings from an error message
|
||||||
|
* before it lands on disk in `fleet_sync_status.last_error` (visible in the
|
||||||
|
* UI sync-status panel) or in any log line. Caps the result at 500 chars
|
||||||
|
* to bound storage growth on pathological remote responses.
|
||||||
|
*/
|
||||||
|
private static redactSensitive(message: string): string {
|
||||||
|
const redacted = message
|
||||||
|
.replace(/Bearer\s+[A-Za-z0-9\-._~+/=]+/gi, 'Bearer [redacted]')
|
||||||
|
.replace(/[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted-jwt]');
|
||||||
|
return redacted.length > 500 ? redacted.slice(0, 497) + '...' : redacted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user