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:
Anso
2026-05-07 13:41:10 -04:00
committed by GitHub
parent f8c75aa6cd
commit 4007709590
7 changed files with 303 additions and 8 deletions
+1
View File
@@ -2097,6 +2097,7 @@ export class DatabaseService {
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.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);
})();
}
+67 -5
View File
@@ -175,6 +175,9 @@ export class FleetSyncService {
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 {
const now = Date.now();
const next = now > FleetSyncService.lastPushedAt ? now : FleetSyncService.lastPushedAt + 1;
@@ -191,8 +194,21 @@ export class FleetSyncService {
return;
}
const db = DatabaseService.getInstance();
const nodes = db.getNodes().filter((n): n is Node & { id: number } => {
return n.type === 'remote' && Boolean(n.api_url) && Boolean(n.api_token) && n.id != null;
const allNodes = db.getNodes();
// 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 (isDebugEnabled()) {
@@ -299,6 +315,21 @@ export class FleetSyncService {
}
db.setSystemState(SYNC_STATE_KEYS.fleetRole, 'replica');
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);
}
if (resource === 'scan_policies') {
@@ -306,6 +337,20 @@ export class FleetSyncService {
} else if (resource === 'cve_suppressions') {
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 {
let raw: string;
if (err instanceof AxiosError) {
if (err.response) {
const data = err.response.data;
const detail = typeof data === 'object' && data && 'error' in data
? String((data as { error: unknown }).error)
: 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;
}
}