Skip to content

Privacy mode ON: field names in the digest are replaced by HOST_n pseudonyms, corrupting the schema hint the model reasons over #1529

Description

@MARCOSD4
Field Value
Build SHA 7e935b0f9
Plugin versions all four Wazuh plugins 5.0.0-06
Environment profile A (AIO + 5 agents)
Provider used openai_compatible scratch endpoint (local capture listener)
Privacy mode ON
Where found Area 6a leftover spot checks
Proposed severity MAJOR — not a privacy leak, but it degrades every privacy-on answer that touches an unrecognized field
Known-issue match none

Summary

With privacy mode ON, field names in the digest — both the columns schema hint and the keys of samples rows — are treated as hostnames by the value-shape scan and replaced with HOST_n. The model is handed a table whose columns are named HOST_1, HOST_2, HOST_3.

This contradicts an invariant the code states about itself. applyFieldPolicy:

sampleKey is what the digest stays KEYED by (never rewritten — the model's view of the digest shape must not change); field is only what the policy is resolved against.

applyFieldPolicy honours that. A later layer does not.

Wire evidence

search_wazuh_data over wazuh-events-v5-*, same query twice.

privacy OFF (request 41):

"samples": [{"related.hosts":["ai-qa-agent-amazon2023"],
             "related.ip":["100.53.8.44"],
             "related.user":["ec2-user"],
             "wazuh.agent.name":"ai-qa-agent-amazon2023",
             "wazuh.cluster.node":"ai-qa-aio-node"}]

privacy ON (request 38):

"samples": [{"wazuh.agent.name":"HOST_5","HOST_4":"ai-qa-aio-node"}],
"columns": ["HOST_1","HOST_2","HOST_3","wazuh.agent.name","HOST_4"]

Four field names were minted as hostnames:

Real name Sent as
related.hosts HOST_1
related.ip HOST_2
related.user HOST_3
wazuh.cluster.node HOST_4

Also observed: the index pattern wazuh-alerts-5.x minted as HOST_1 in another turn, and the filename containerd.log minted as HOST_1 in a get_threat_intel_components result.

Root cause — and the exact rule for which names survive

prescanAndMint's FQDN pass excludes a token that "reads as the user NAMING A FIELD" via isFieldPathToken:

const FIELD_PATH_WORDS = new Set(
  [...Object.values(WAZUH_FIELD), ...FIELD_POLICY_DEFAULTS.map(e => e.field)]
    .map(f => f.split('/').pop())
    .flatMap(f => f.replace(/\.\*$/, '').split('.'))
    .map(s => s.toLowerCase()).filter(s => s.length > 0));

function isFieldPathToken(token) {
  return token.split('.').every(seg => FIELD_PATH_WORDS.has(seg.toLowerCase()));
}

The guard is a set of path segments, and it requires every segment of the token to be known. That explains the otherwise-baffling split observed on the wire, where two sibling fields behave differently:

Token Segments In vocabulary? Result
wazuh.cluster.name wazuh, cluster, name all three — cluster from the wazuh.cluster.* row, name from wazuh.agent.name survives
wazuh.cluster.node wazuh, cluster, node node appears in no WAZUH_FIELD value and no default policy row minted HOST_n
related.hosts related, hosts neither (host.hostname contributes host, not hosts) minted HOST_n

So the surviving set is not "field names" — it is "field names assembled entirely from words the plugin already ships". Any ECS field outside the curated vocabulary, and any Wazuh field whose leaf word is new, is mangled.

Two consequences worth calling out separately:

  1. The guard reads FIELD_POLICY_DEFAULTS, not the live policy. It is a module-scope constant built from the compile-time defaults. An admin who adds a policy row for a new field at runtime does not get that field's name recognized — the row will govern the value while the name is still mangled. The source comment says "Self-updating: a field added to either source is automatically recognized here — no separate list to hand-maintain." That holds for a field added in code; it does not hold for one added through the settings UI.

  2. The wildcard contributes only its literal prefix. wazuh.cluster.* yields the segments wazuh and cluster, so it cannot vouch for any leaf under it — which is exactly how a field covered by an explicit policy row still gets its name mangled.

Expected vs got

Expected — with privacy ON, values are pseudonymized and field names are not. columns and the samples keys arrive intact so the model can tell what each column holds.

Got — three of five column names arrive as opaque pseudonyms, and a sample row reads {"HOST_4": "ai-qa-aio-node"}.

Why this matters

The digest's columns list exists to tell the model what it is looking at. When it arrives as ["HOST_1","HOST_2","HOST_3","wazuh.agent.name","HOST_4"] the model has no way to know that HOST_1 means related.hosts. Its options are all bad: ignore the columns, guess, or describe them to the user by their pseudonyms.

It also burns pseudonym slots on non-identities — the same effect recorded in Area 4 Observation 3, where wazuh.rule.tags values (attack.execution, attack.persistence) consumed 14 of 19 HOST_n slots. Both come from the same shape scan treating dotted vocabulary as hostnames.

Note the asymmetry with DEFECT-5: in the one sample row {"HOST_4": "ai-qa-aio-node"}, privacy mode substituted the part that carried no secret and preserved the part that did.

Relationship to DEFECT-3 — tested, and it is NOT the cause

DEFECT-3's two witnesses both feature the assistant requesting a field explicitly and the call being rejected as an invalid field name, which made this a plausible shared root cause. It was tested and ruled out, so DEFECT-3 should be triaged on its own merits.

Both DEFECT-3 questions were re-run against the Anthropic provider with privacy OFF and ON (ai-qa-results/area-6a/leftovers/probe_defect3.json).

With privacy OFF, DEFECT-3 reproduces in full. CV-053:

tool_call: get_sca_checks {"check_id": "35509"}
  columns: ['check.id','check.name','check.result','check.rationale']
  279 checks returned, truncated to 20, sample covers 35500-35504
tool_call: find_document_by_field {"index_pattern":"wazuh-states-*","values":["35509"]}
  columns: []           <- zero rows
answer:    "I can't give you the remediation text for check 35509 ..."

check_id still does not filter and check.remediation is still absent from the projection, with no pseudonymization anywhere in the turn.

CV-042 with privacy OFF is equally clear — the model's second call does specify the projection explicitly:

"_source": ["wazuh.agent.name","network.type","network.dhcp","network.gateway","network.metric","observer…"]

and the answer still reports "my follow-up query for the protocol fields failed on a bad field name". Privacy was off, so no field name in that turn was ever replaced by a pseudonym.

Conclusion: the invalid-field-name rejection in DEFECT-3 has a different cause. The two issues are independent and neither blocks the other.

Second symptom of the same mechanism — allow values are scanned too, and the selectivity is inverted

The same FQDN shape scan also mangles values, and it does so on fields whose policy action explicitly promises the opposite.

wazuh.rule.tags is allow. privacy.js's contract for allow is "the provider receives the real value, completely unscanned". Yet across all three Area 4 long-conversation runs, every privacy-on turn minted the same pseudonyms for that field's values:

attack.execution → HOST_1        attack.persistence → HOST_2
attack.t1543     → HOST_3        attack.defense-evasion → HOST_4
attack.impact    → HOST_5        attack.t1489 → HOST_6

Deterministic, 3 of 3 runs. In run 1's accounting, 14 of 19 HOST_n slots were spent on MITRE vendor taxonomy.

Two conclusions follow, and both matter more than the wasted slots:

  1. The allow contract is not honoured. Something downstream scans allow values anyway. That is arguably a good thing for safety — it is the very mitigation privacy.js cites for wazuh.rule.title's residual risk — but the documented contract and the implementation disagree, and the tracking issue for DEFECT-1 rests on which of the two is true.

  2. The scan's selectivity is exactly inverted. In the same campaign, on the same build:

    Value Shape Sensitive? Outcome
    attack.execution dotted no — MITRE taxonomy pseudonymized
    wazuh.cluster.node (a field name) dotted no — schema pseudonymized
    AI-QA-AGENT-WIN bare yes — real hostname sent in clear (DEFECT-1)
    ai-qa-aio-node bare yes — real node name sent in clear (DEFECT-5)

Everything dotted is treated as a hostname; nothing bare is. Real host identifiers in this estate — and in most Wazuh estates — are bare single labels, so the scan catches the harmless class and misses the sensitive one.

This is why the structural fix below is preferable to widening the vocabulary list: the underlying problem is that "contains a dot" is being used as the test for "is a hostname", in a payload that is mostly dotted field paths and dotted taxonomy.

Evidence

capture_directed.log

REPORT.md

REPORT.md

Suggested fix

Do not run the value-shape scan over structural positions at all. columns entries and samples keys are schema, not data — applyFieldPolicy already treats them that way, and the whole-JSON text pass downstream does not know the difference. Scrubbing tool content as a flat string is what erases that distinction; walking the digest structurally and scanning only leaf values would fix this and DEFECT-5's key-rewrite half in one change.

Failing that, seed FIELD_PATH_WORDS from the live settings.fieldPolicy as well as the compile-time defaults, and add the ECS related.* family. That is a narrower fix and leaves the next unrecognized field name to be found the same way.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions