Audit-hardening pass for fleet-replicated CVE suppressions (#976)

* perf(security): bucket CVE suppressions by id at read time

applySuppressions now builds a Map<cve_id, suppression[]> once before the
per-finding loop, dropping per-finding work to O(matching-cve-suppressions)
rather than O(all-suppressions). At a fleet-wide cap of 10000 rows against
a multi-thousand-finding scan, the prior linear-per-finding shape drifted
into tens of millions of comparisons per render.

Public API of findSuppression and applySuppressions is unchanged.
Specificity scoring, expiry handling, and image-glob matching are
preserved bit-for-bit. Adds a regression guard that pins a 10000x2000
workload under 1.5s and asserts at least one match was actually returned.

* feat(security): record audit-log entries on control-side CVE suppression CRUD

The replica receive path already wrote an audit-log entry on apply; the
control-side POST/PUT/DELETE handlers did not. Operators reading the
audit panel saw mirrored security-rule changes from the replica view but
could not see who originated them on the control. Symmetric logging
closes that gap.

The summary records the CVE id and pinned scope (pkg, image) but never
the suppression's reason text. Reasons are free-form admin input that
replicate fleet-wide and may carry incident-tracker IDs or vendor
context the operator did not intend to broadcast.

The summary is also sanitised before emission so an operator-supplied
package name or image pattern carrying a smuggled newline plus a forged
"cve_suppression.delete:" prefix cannot inject a fake row into the audit
panel. Control characters become "?" and the field is capped to its
validator length.

Adds a Control-side audit log block to suppression-routes.test.ts that
asserts the privacy contract on each verb (scope present, reason absent),
plus a log-injection guard test, plus a regression that GET still works
when the local instance is a replica.

* test(security): cover cve_suppressions fleet-sync receive path

The existing fleet-sync route tests covered the protocol mechanics
(auth, anchor, stale push, reanchor, demote) for cve_suppressions only
with empty-rows payloads. The suppression-specific concerns were
unverified end-to-end:

  - actual rows replace prior replicated rows on a fresh push
  - the receive path writes an audit-log entry naming the source
    fingerprint and row count
  - a malformed suppression row is rejected at the validator before any
    DB write

Adds a focused block exercising those three properties against the real
Express app, real SQLite, and the real apply transaction.

* docs(features): refresh CVE suppressions troubleshooting and field guidance

Converts the troubleshooting section to Mintlify Accordion blocks so
each entry is foldable and the page stays scannable. Adds entries for:

  - control-identity mismatch on a replica (anchor-aware reanchor flow)
  - mirrored rules persisting after a control demote
  - the 10000-row truncation cap on a fleet sync push

Adds a privacy note to the Reason field guidance: do not paste
credentials, tokens, or vendor secrets there, since the field replicates
fleet-wide and surfaces on every node's suppressions panel.
This commit is contained in:
Anso
2026-05-07 16:18:19 -04:00
committed by GitHub
parent d8e8d500c9
commit 4b1de35dda
6 changed files with 426 additions and 23 deletions
@@ -274,6 +274,111 @@ describe('POST /api/fleet/role/reanchor', () => {
});
});
describe('POST /api/fleet/sync/cve_suppressions row apply', () => {
// Existing tests in this file cover the protocol mechanics (auth, anchor,
// stale push, reanchor, demote) for cve_suppressions with empty rows arrays.
// These tests pin the suppression-specific pieces: actual rows replace prior
// replicated rows, the receive path writes an audit-log entry, and a malformed
// suppression row is rejected at the validator before any DB write.
async function freshAnchor(): Promise<void> {
const reanchor = await request(app)
.post('/api/fleet/role/reanchor')
.set('Authorization', adminAuthHeader)
.send({ override: true });
expect(reanchor.status).toBe(200);
}
it('replaces prior replicated rows on a fresh push and records an audit entry', async () => {
await freshAnchor();
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
// Flush prior tests' buffered audit writes BEFORE the wipe so they cannot
// land in the table after the DELETE.
db.flushAuditLogBuffer();
db.getDb().prepare('DELETE FROM audit_log').run();
const firstPush = await request(app)
.post('/api/fleet/sync/cve_suppressions')
.set('Authorization', nodeProxyAuthHeader)
.send({
rows: [
{
cve_id: 'CVE-2024-7001', pkg_name: null, image_pattern: null,
reason: 'first push', created_by: 'control-admin',
created_at: Date.now(), expires_at: null,
},
{
cve_id: 'CVE-2024-7002', pkg_name: 'openssl', image_pattern: null,
reason: 'first push 2', created_by: 'control-admin',
created_at: Date.now(), expires_at: null,
},
],
targetIdentity: 'https://me.example',
controlIdentity: 'fingerprint-rowapply',
pushedAt: 1_900_000_000_000,
});
expect(firstPush.status).toBe(200);
expect(firstPush.body.applied).toBe(2);
let stored = db.getCveSuppressions().filter((s) => s.replicated_from_control === 1);
expect(stored.map((s) => s.cve_id).sort()).toEqual(['CVE-2024-7001', 'CVE-2024-7002']);
// Second push replaces, not appends.
const secondPush = await request(app)
.post('/api/fleet/sync/cve_suppressions')
.set('Authorization', nodeProxyAuthHeader)
.send({
rows: [
{
cve_id: 'CVE-2024-7003', pkg_name: null, image_pattern: null,
reason: 'second push', created_by: 'control-admin',
created_at: Date.now(), expires_at: null,
},
],
targetIdentity: 'https://me.example',
controlIdentity: 'fingerprint-rowapply',
pushedAt: 1_900_000_000_001,
});
expect(secondPush.status).toBe(200);
stored = db.getCveSuppressions().filter((s) => s.replicated_from_control === 1);
expect(stored.map((s) => s.cve_id)).toEqual(['CVE-2024-7003']);
db.flushAuditLogBuffer();
const auditRows = db.getDb()
.prepare("SELECT username, path, summary FROM audit_log WHERE path = '/api/fleet/sync/cve_suppressions' ORDER BY id")
.all() as Array<{ username: string; path: string; summary: string }>;
expect(auditRows.length).toBeGreaterThanOrEqual(2);
expect(auditRows[0].username).toBe('system');
expect(auditRows[0].summary).toContain('cve_suppressions');
expect(auditRows[0].summary).toContain('fingerprint-rowapply');
});
it('rejects a malformed suppression row before touching the DB', async () => {
await freshAnchor();
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const before = db.getCveSuppressions().filter((s) => s.replicated_from_control === 1).length;
const res = await request(app)
.post('/api/fleet/sync/cve_suppressions')
.set('Authorization', nodeProxyAuthHeader)
.send({
rows: [
{ cve_id: 'not-a-cve', reason: 'invalid', created_by: 'x', created_at: Date.now() },
],
targetIdentity: 'https://me.example',
controlIdentity: 'fingerprint-malformed',
pushedAt: 1_950_000_000_000,
});
expect(res.status).toBe(400);
const after = db.getCveSuppressions().filter((s) => s.replicated_from_control === 1).length;
expect(after).toBe(before);
});
});
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)
@@ -162,4 +162,28 @@ describe('applySuppressions', () => {
it('returns an empty array for empty input', () => {
expect(applySuppressions([], 'nginx:1.25', [], NOW)).toEqual([]);
});
// Regression guard for the cve_id bucketing optimization. A naive O(N*M)
// implementation drifts into the tens of millions of comparisons at this
// scale; the bucketed implementation lands in low-tens of milliseconds on
// commodity hardware. The 1500ms ceiling is generous to keep the test
// stable across CI runners and slow Windows VMs while still catching a
// regression to the quadratic shape (which would blow well past 1.5s).
it('processes 10k suppressions x 2k findings in well under 1.5s', () => {
const suppressions: CveSuppression[] = Array.from({ length: 10_000 }, (_, i) =>
makeSuppression({ id: i, cve_id: `CVE-2024-${10_000 + i}`, pkg_name: i % 3 === 0 ? 'openssl' : null }),
);
const findings = Array.from({ length: 2_000 }, (_, i) => ({
vulnerability_id: `CVE-2024-${10_000 + (i * 5) % 10_000}`,
pkg_name: i % 2 === 0 ? 'openssl' : 'glibc',
}));
const start = Date.now();
const result = applySuppressions(findings, 'nginx:1.25', suppressions, NOW);
const elapsed = Date.now() - start;
expect(result).toHaveLength(2_000);
// At least one finding must actually match a suppression; otherwise a
// regression where bucketing silently drops every match could pass.
expect(result.some((r) => r.suppressed)).toBe(true);
expect(elapsed).toBeLessThan(1500);
});
});
@@ -297,3 +297,160 @@ describe('DELETE /api/security/suppressions/:id', () => {
expect(res.status).toBe(403);
});
});
describe('GET /api/security/suppressions on a replica', () => {
it('still serves the list when the local instance is a replica', async () => {
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('replica');
const db = DatabaseService.getInstance();
db.createCveSuppression({
cve_id: 'CVE-2024-5500',
pkg_name: null,
image_pattern: null,
reason: 'mirrored from control',
created_by: 'control-admin',
created_at: Date.now(),
expires_at: null,
replicated_from_control: 1,
});
const res = await request(app).get('/api/security/suppressions').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].replicated_from_control).toBe(1);
});
});
describe('Control-side audit log entries (L2)', () => {
// The receive path on a replica writes an audit-log row when applying a sync.
// Control-side writes need the same so the operator's actions show up in the
// audit panel; otherwise the trail looks like it begins on the replica.
// The assertions below also pin a privacy contract: the suppression's free-form
// `reason` field never lands in the audit log, even though it is part of the
// stored row. Reasons can carry incident-tracker IDs or vendor context the
// operator did not intend to broadcast fleet-wide.
function findSuppressionAuditEntries(): Array<{ summary: string; method: string }> {
const db = DatabaseService.getInstance();
db.flushAuditLogBuffer();
return (db.getDb()
.prepare("SELECT summary, method FROM audit_log WHERE summary LIKE 'cve_suppression.%' ORDER BY id")
.all() as Array<{ summary: string; method: string }>);
}
beforeEach(() => {
// Flush prior tests' buffered audit writes BEFORE the wipe; otherwise the
// setTimeout flush could materialize them after the DELETE and pollute
// this block's assertions.
const db = DatabaseService.getInstance();
db.flushAuditLogBuffer();
db.getDb().prepare('DELETE FROM audit_log').run();
});
it('records a create entry without leaking the reason text', async () => {
const secret = 'INC-12345 vendor-only do-not-broadcast';
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send({
cve_id: 'CVE-2024-6001',
pkg_name: 'openssl',
image_pattern: 'nginx*',
reason: secret,
});
expect(res.status).toBe(201);
const entries = findSuppressionAuditEntries();
expect(entries).toHaveLength(1);
expect(entries[0].method).toBe('POST');
expect(entries[0].summary).toContain('cve_suppression.create');
expect(entries[0].summary).toContain('CVE-2024-6001');
expect(entries[0].summary).toContain('pkg=openssl');
expect(entries[0].summary).toContain('image=nginx*');
expect(entries[0].summary).not.toContain(secret);
expect(entries[0].summary).not.toContain('INC-12345');
});
it('records an update entry with the changed field names but not the new reason', async () => {
const db = DatabaseService.getInstance();
const created = db.createCveSuppression({
cve_id: 'CVE-2024-6002',
pkg_name: null,
image_pattern: null,
reason: 'original',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: null,
replicated_from_control: 0,
});
const sensitive = 'CONFIDENTIAL: internal ticket OPS-9911';
const res = await request(app)
.put(`/api/security/suppressions/${created.id}`)
.set('Authorization', adminAuthHeader)
.send({ reason: sensitive });
expect(res.status).toBe(200);
const entries = findSuppressionAuditEntries();
expect(entries).toHaveLength(1);
expect(entries[0].method).toBe('PUT');
expect(entries[0].summary).toContain('cve_suppression.update');
expect(entries[0].summary).toContain(`id=${created.id}`);
expect(entries[0].summary).toContain('fields=[reason]');
expect(entries[0].summary).not.toContain('CONFIDENTIAL');
expect(entries[0].summary).not.toContain('OPS-9911');
});
it('records a delete entry that names the CVE rather than the bare id', async () => {
const db = DatabaseService.getInstance();
const created = db.createCveSuppression({
cve_id: 'CVE-2024-6003',
pkg_name: 'glibc',
image_pattern: null,
reason: 'TICKET-7777 do not log',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: null,
replicated_from_control: 0,
});
const res = await request(app)
.delete(`/api/security/suppressions/${created.id}`)
.set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
const entries = findSuppressionAuditEntries();
expect(entries).toHaveLength(1);
expect(entries[0].method).toBe('DELETE');
expect(entries[0].summary).toContain('cve_suppression.delete');
expect(entries[0].summary).toContain('CVE-2024-6003');
expect(entries[0].summary).toContain('pkg=glibc');
expect(entries[0].summary).not.toContain('TICKET-7777');
});
it('strips newlines and control chars from pkg/image so the audit row cannot be forged', async () => {
// The route validators check length but not charset; a pattern with an
// embedded newline could otherwise smuggle a fake `cve_suppression.delete:`
// entry into the audit panel.
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send({
cve_id: 'CVE-2024-6004',
pkg_name: 'evil\npkg',
image_pattern: 'img\r\ncve_suppression.delete: id=999 (forged)',
reason: 'log-injection guard',
});
expect(res.status).toBe(201);
const entries = findSuppressionAuditEntries();
expect(entries).toHaveLength(1);
expect(entries[0].summary).not.toContain('\n');
expect(entries[0].summary).not.toContain('\r');
// The forged prefix the attacker tried to inject must not appear as a new
// logical entry — the sanitiser turns control chars into `?` so the
// string still appears, but on a single line, framed inside the real entry.
expect(entries[0].summary).toContain('cve_suppression.create');
});
it('does not record an audit entry on validation failure', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send({ cve_id: 'not-a-cve', reason: 'whatever' });
expect(res.status).toBe(400);
expect(findSuppressionAuditEntries()).toHaveLength(0);
});
});
+62 -1
View File
@@ -19,6 +19,50 @@ import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
// Strip control characters and cap length so an operator-supplied pkg or image
// pattern cannot inject a fake audit row by smuggling a newline plus a forged
// `cve_suppression.delete:` prefix. Validators on the route reject overlength
// pkg/image strings but do not constrain the charset.
function sanitiseScopeFragment(value: string, max: number): string {
// eslint-disable-next-line no-control-regex
const stripped = value.replace(/[\x00-\x1f\x7f]/g, '?');
return stripped.length > max ? stripped.slice(0, max) + '…' : stripped;
}
// Summarise a suppression for the audit log without leaking the reason text.
// Reason is free-form and could carry incident-tracker IDs or vendor secrets;
// scope (CVE id, pkg, image) is non-sensitive. The fields list emitted on
// update operations does name `reason` when it changed, which lets an audit
// reader see *when* a reason was rotated even though the contents stay private.
function describeSuppressionScope(s: { cve_id: string; pkg_name: string | null; image_pattern: string | null }): string {
const pinned: string[] = [];
if (s.pkg_name) pinned.push(`pkg=${sanitiseScopeFragment(s.pkg_name, 200)}`);
if (s.image_pattern) pinned.push(`image=${sanitiseScopeFragment(s.image_pattern, 300)}`);
return pinned.length > 0 ? `${s.cve_id} (${pinned.join(', ')})` : s.cve_id;
}
function recordSuppressionAudit(
req: Request,
res: Response,
action: 'create' | 'update' | 'delete',
summary: string,
): void {
try {
DatabaseService.getInstance().insertAuditLog({
timestamp: Date.now(),
username: req.user?.username ?? 'unknown',
method: req.method,
path: req.originalUrl,
status_code: res.statusCode,
node_id: null,
ip_address: req.ip || 'unknown',
summary: `cve_suppression.${action}: ${summary}`,
});
} catch (err) {
console.warn('[Security] Suppression audit log write failed:', getErrorMessage(err, 'unknown'));
}
}
function parseScannersInput(raw: unknown): readonly ('vuln' | 'secret')[] | undefined | null {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw) || raw.length === 0) return null;
@@ -561,6 +605,7 @@ securityRouter.post('/suppressions', authMiddleware, (req: Request, res: Respons
});
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
res.status(201).json(suppression);
recordSuppressionAudit(req, res, 'create', describeSuppressionScope(suppression));
} catch (error) {
const message = (error as Error).message || '';
if (message.includes('UNIQUE')) {
@@ -607,6 +652,13 @@ securityRouter.put('/suppressions/:id', authMiddleware, (req: Request, res: Resp
}
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
res.json(suppression);
const changed = Object.keys(updates);
recordSuppressionAudit(
req,
res,
'update',
`id=${id} ${describeSuppressionScope(suppression)} fields=[${changed.join(',')}]`,
);
});
securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
@@ -616,9 +668,18 @@ securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: R
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid suppression id' }); return;
}
DatabaseService.getInstance().deleteCveSuppression(id);
const db = DatabaseService.getInstance();
// Snapshot before delete so the audit summary names the CVE rather than the bare id.
const existing = db.getCveSuppression(id);
db.deleteCveSuppression(id);
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
res.json({ success: true });
recordSuppressionAudit(
req,
res,
'delete',
existing ? `id=${id} ${describeSuppressionScope(existing)}` : `id=${id} (not found)`,
);
});
securityRouter.get('/compare', authMiddleware, (req: Request, res: Response): void => {
+57 -13
View File
@@ -33,9 +33,42 @@ function isActive(suppression: CveSuppression, now: number): boolean {
return suppression.expires_at === null || suppression.expires_at > now;
}
function specificityScore(s: CveSuppression): number {
return (s.pkg_name ? 2 : 0) + (s.image_pattern ? 1 : 0);
}
/**
* Pick the highest-specificity active suppression from a candidate bucket
* already filtered to a single CVE. Returns null when no candidate matches the
* finding's pkg/image constraints.
*/
function pickFromBucket(
bucket: CveSuppression[],
finding: SuppressibleFinding,
imageRef: string,
now: number,
): CveSuppression | null {
let best: CveSuppression | null = null;
let bestScore = -1;
for (const s of bucket) {
if (!isActive(s, now)) continue;
if (s.pkg_name !== null && s.pkg_name !== finding.pkg_name) continue;
if (!matchesImagePattern(s.image_pattern, imageRef)) continue;
const score = specificityScore(s);
if (score > bestScore) {
best = s;
bestScore = score;
}
}
return best;
}
/**
* Find the most specific active suppression matching a single finding.
* Specificity: entries that pin a specific pkg or image beat wildcard entries.
*
* For one-shot lookups. When enriching many findings, prefer applySuppressions,
* which builds the cve_id bucket once and amortizes the lookup.
*/
export function findSuppression(
finding: SuppressibleFinding,
@@ -43,22 +76,22 @@ export function findSuppression(
suppressions: CveSuppression[],
now: number = Date.now(),
): CveSuppression | null {
const matches = suppressions.filter((s) => {
if (!isActive(s, now)) return false;
if (s.cve_id !== finding.vulnerability_id) return false;
if (s.pkg_name !== null && s.pkg_name !== finding.pkg_name) return false;
if (!matchesImagePattern(s.image_pattern, imageRef)) return false;
return true;
});
if (matches.length === 0) return null;
const score = (s: CveSuppression): number =>
(s.pkg_name ? 2 : 0) + (s.image_pattern ? 1 : 0);
matches.sort((a, b) => score(b) - score(a));
return matches[0];
const bucket: CveSuppression[] = [];
for (const s of suppressions) {
if (s.cve_id === finding.vulnerability_id) bucket.push(s);
}
if (bucket.length === 0) return null;
return pickFromBucket(bucket, finding, imageRef, now);
}
/**
* Enrich a list of findings with suppression decisions. Does not mutate inputs.
*
* Suppressions are bucketed by cve_id once before the per-finding scan, so the
* per-finding work is O(matching-cve-suppressions) rather than O(suppressions).
* This matters: a fleet-wide suppression list capped at MAX_SYNC_ROWS combined
* with a multi-thousand-finding scan would otherwise drift into the tens of
* millions of comparisons per render.
*/
export function applySuppressions<T extends SuppressibleFinding>(
findings: T[],
@@ -66,8 +99,19 @@ export function applySuppressions<T extends SuppressibleFinding>(
suppressions: CveSuppression[],
now: number = Date.now(),
): Array<T & SuppressionDecision> {
if (findings.length === 0) return [];
const buckets = new Map<string, CveSuppression[]>();
for (const s of suppressions) {
const existing = buckets.get(s.cve_id);
if (existing) {
existing.push(s);
} else {
buckets.set(s.cve_id, [s]);
}
}
return findings.map((f) => {
const match = findSuppression(f, imageRef, suppressions, now);
const bucket = buckets.get(f.vulnerability_id);
const match = bucket ? pickFromBucket(bucket, f, imageRef, now) : null;
if (!match) return { ...f, suppressed: false };
return {
...f,
+21 -9
View File
@@ -32,7 +32,7 @@ Go to **Settings → Security** and scroll to **CVE Suppressions**, then click *
| **CVE ID** | The identifier of the finding. Accepts both `CVE-YYYY-NNNN` and GitHub advisory IDs like `GHSA-xxxx-xxxx-xxxx`. |
| **Package** | Optional. Leave empty to suppress every occurrence of this CVE regardless of package, or set it to a specific package name (e.g. `openssl`) to narrow the scope. |
| **Image pattern** | Optional glob against image references (e.g. `registry.example.com/app*`). Leave empty to apply fleet-wide. |
| **Reason** | Required. A short note explaining why this CVE is accepted. Surfaced on every suppressed row. |
| **Reason** | Required. A short note explaining why this CVE is accepted. Surfaced on every suppressed row. Do not paste credentials, tokens, or vendor secrets here — reasons replicate fleet-wide and surface on every node's suppressions panel. |
| **Expires in** | Optional. Number of days after which the suppression stops applying. Useful for "patched in the next release" acknowledgements. Leave empty for an indefinite suppression. |
<Frame>
@@ -77,21 +77,33 @@ To change a suppression's scope (for example, to narrow an image pattern or exte
## Troubleshooting
### I suppressed a CVE but the count on the badge is unchanged
<Accordion title="I suppressed a CVE but the count on the badge is unchanged">
Badge counts reflect the raw findings so they remain meaningful for alerting and policy evaluation. The filter is applied in the scan drawer, the comparison sheet, and every read surface, but the stored totals do not change. Open the scan drawer to confirm the row is dimmed with a shield-off icon.
</Accordion>
### A suppression I added on the control is not visible on a remote
<Accordion title="A suppression I added on the control is not visible on a remote">
Replication runs on every write. If the push failed (network blip, remote restart), check the fleet sync status on the control under **Fleet → Sync status**. The remote picks up the latest state on the next successful push.
</Accordion>
### I see suppressions on a remote but cannot edit them
<Accordion title="I see suppressions on a remote but cannot edit them">
Remote Sencho instances are read-only for security rules. Sign in to the control instance to add, edit, or delete suppressions. Changes sync automatically.
</Accordion>
### A suppression does not match a finding I expect it to
<Accordion title="A suppression does not match a finding I expect it to">
Two common causes:
- **Image pattern mismatch.** The pattern uses glob syntax where `*` matches any sequence. `nginx*` matches `nginx:1.25` but not `docker.io/library/nginx:1.25`; use `*nginx*` for a broader match.
- **Expired rule.** If **Expires in** was set, the suppression stops applying after the deadline. The row shows an "expired" badge; edit it or create a fresh rule.
</Accordion>
<Accordion title="A remote refuses pushes from my control with a 'control identity mismatch' error">
Each replica anchors to the first control fingerprint it sees. If the same replica is later pushed from a different control (for example, after rebuilding the control instance from a snapshot or migrating to a new server), the replica rejects the new control until it is reanchored. On the replica, open **Fleet → Sync status** and use **Reanchor** to clear the cached fingerprint so the next push from the new control establishes a fresh anchor. See [Fleet Sync](/features/fleet-sync) for the full reanchor flow.
</Accordion>
<Accordion title="I demoted the control and the old replica still shows the mirrored suppressions">
Demoting a control to a standalone instance only affects that one node. Replicas that were following it keep the last set of replicated rows until either a new control pushes to them or you reanchor the replica. From the replica's **Fleet → Sync status**, use **Reanchor** to drop the mirrored rules; the suppressions panel returns to a clean local-only state.
</Accordion>
<Accordion title="My suppression list is enormous and the fleet sync warns about truncation">
The fleet sync wire protocol caps a single push at 10,000 rows so a misconfigured control cannot wedge a replica with an unbounded payload. If your local list exceeds that cap, the warning appears in the control logs and only the first 10,000 rows are pushed. Trim expired or unused entries from the suppressions panel, or split scopes across distinct rules so each remains meaningful, before relying on fleet replication for the full set.
</Accordion>