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
+22 -1
View File
@@ -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.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 (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 (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';
@@ -56,6 +59,24 @@ function validateScanPolicyRow(row: unknown): string | 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 {
if (!row || typeof row !== 'object') return 'row must be an object';
const r = row as Record<string, unknown>;
+19 -2
View File
@@ -14,6 +14,7 @@ import { sanitizeForLog } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { blockIfReplica } from '../middleware/fleetSyncGuards';
import { validateStackPatternForRedos } from './fleet';
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
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)) {
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 {
const resolvedNodeId = node_id != null ? Number(node_id) : null;
const policy = DatabaseService.getInstance().createScanPolicy({
name: name.trim(),
node_id: resolvedNodeId,
node_identity: FleetSyncService.resolveIdentityForNodeId(resolvedNodeId),
stack_pattern: stack_pattern ? String(stack_pattern) : null,
stack_pattern: normalizedPattern,
max_severity,
block_on_deploy: block_on_deploy ? 1 : 0,
enabled: enabled === false ? 0 : 1,
@@ -459,7 +467,16 @@ securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response
updates.node_id = 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 (!POLICY_SEVERITIES.has(body.max_severity)) {
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;