mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +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:
@@ -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);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user