| 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:
-
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.
-
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:
-
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.
-
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.
7e935b0f95.0.0-06openai_compatiblescratch endpoint (local capture listener)Summary
With privacy mode ON, field names in the digest — both the
columnsschema hint and the keys ofsamplesrows — are treated as hostnames by the value-shape scan and replaced withHOST_n. The model is handed a table whose columns are namedHOST_1,HOST_2,HOST_3.This contradicts an invariant the code states about itself.
applyFieldPolicy:applyFieldPolicyhonours that. A later layer does not.Wire evidence
search_wazuh_dataoverwazuh-events-v5-*, same query twice.privacy OFF (request 41):
privacy ON (request 38):
Four field names were minted as hostnames:
related.hostsHOST_1related.ipHOST_2related.userHOST_3wazuh.cluster.nodeHOST_4Also observed: the index pattern
wazuh-alerts-5.xminted asHOST_1in another turn, and the filenamecontainerd.logminted asHOST_1in aget_threat_intel_componentsresult.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" viaisFieldPathToken: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:
wazuh.cluster.namewazuh,cluster,nameclusterfrom thewazuh.cluster.*row,namefromwazuh.agent.namewazuh.cluster.nodewazuh,cluster,nodenodeappears in noWAZUH_FIELDvalue and no default policy rowHOST_nrelated.hostsrelated,hostshost.hostnamecontributeshost, nothosts)HOST_nSo 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:
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.The wildcard contributes only its literal prefix.
wazuh.cluster.*yields the segmentswazuhandcluster, 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.
columnsand thesampleskeys 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
columnslist 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 thatHOST_1meansrelated.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.tagsvalues (attack.execution,attack.persistence) consumed 14 of 19HOST_nslots. 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:check_idstill does not filter andcheck.remediationis still absent from the projection, with no pseudonymization anywhere in the turn.CV-042with privacy OFF is equally clear — the model's second call does specify the projection explicitly: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 —
allowvalues are scanned too, and the selectivity is invertedThe same FQDN shape scan also mangles values, and it does so on fields whose policy action explicitly promises the opposite.
wazuh.rule.tagsisallow.privacy.js's contract forallowis "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:Deterministic, 3 of 3 runs. In run 1's accounting, 14 of 19
HOST_nslots were spent on MITRE vendor taxonomy.Two conclusions follow, and both matter more than the wasted slots:
The
allowcontract is not honoured. Something downstream scansallowvalues anyway. That is arguably a good thing for safety — it is the very mitigationprivacy.jscites forwazuh.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.The scan's selectivity is exactly inverted. In the same campaign, on the same build:
attack.executionwazuh.cluster.node(a field name)AI-QA-AGENT-WINai-qa-aio-nodeEverything 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.
columnsentries andsampleskeys are schema, not data —applyFieldPolicyalready 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_WORDSfrom the livesettings.fieldPolicyas well as the compile-time defaults, and add the ECSrelated.*family. That is a narrower fix and leaves the next unrecognized field name to be found the same way.