#39123·wazuh

Settle the TLS resolution ladder: the unanchored default, the system fallback, and the explicit-none latch

Author: jpcerroneCreated Sep 10, 2026Updated Sep 18, 2026
Labelstype/enhancementmodule/agentlevel/task

Component: client-agent (config.c), https_client (moduleConfig.cpp) Depends on: the MVP branch feat/39021-poc-agent-https-tier2 Rough size: M

Description

What an agent does when it has nothing to verify with — and what it does when it has something but is told not to use it. Both are settled in w_agent_resolve_ssl_posture() (src/client-agent/src/config.c), and on the MVP branch both differ from the design.

This issue was originally scoped to a single systemfull fallback. Commit 24d9dd7b3a ("simplify verification mode none scenarios") changed the ground under that: the ladder's last rung is no longer system, so there is no longer a system default to fall back from. The scope is now the rung itself, the fallback, and the latch — they are the same function and cannot be decided apart.

The ladder as it stands:

explicit <verification_mode>                 -> honoured, including 'none' with an anchor present
explicit <certificate_authorities>, no mode  -> certificate
anchor file present, no mode                 -> full   (the anchor is injected as the CA)
nothing at all                               -> none   <- design says system

1. The last rung: none or system

An agent with no CA, no anchor and no explicit mode connects unverified. That is the defect #38940 exists to remove, reachable on a stock install. Branch 5.0.0 resolves that case to system.

The reason for the change is real, and it is why this is not a one-line revert: system cannot validate a self-signed remoted.pem, so resolving there makes a stock install against a private-CA manager fail to connect rather than connect unverified. Trading a silent downgrade for a dead agent is not obviously the better default, and 24d9dd7b3a chose the connection.

The designed answer to that trade is the fallback below — system first, the local anchor when the OS store cannot do the job, and a hard failure only when neither can. That is what makes system affordable as the last rung, and it is why the two halves have to be decided together.

  • Decide the last rung, with the fallback in hand rather than before it.
  • Whichever is chosen, make the <ssl> block in the packaged ossec.conf and the WPK gates' log text say the same thing — both gates currently hard-code the branch's answer.

2. The system → local-anchor fallback

When the OS trust store cannot verify the manager, resolve to full against a certificate under etc/certs and log a warning; when neither works, log critical and fail closed.

  • Decide what "cannot verify" means. os_find_ca_bundle() is a stat() over five hardcoded paths — it fires only on a host with no CA bundle at all, not on the case operators hit, which is a bundle that exists and has never heard of the deployment's CA. Detecting the latter needs a live verified probe or treating the first handshake failure as the trigger. pkg_installer.sh's probe_server_verified() already does exactly that for WPK upgrades and pins DEFAULT_CA_FILE on failure — same policy, same path, already in the tree.
  • Make the three enforcement points agree: w_agent_resolve_ssl_posture(), w_agent_validate_ssl_ca() and moduleConfig.cpp's validateTls(). A fallback in one and not the others gives a daemon that passes the startup gate and dies at module init.
  • Decide what happens to (4120)system with an explicit certificate_authorities is a hard refusal today. If a local certificate is now a legitimate fallback under system, an operator who configured one is arguably asking for it.
  • Windows and macOS cannot introspect their native stores at validation time, so trigger (a) does not exist there. State what each platform does rather than leaving it implicit.

The reverse (fullsystem) — proposed, and not recommended

The two directions are not symmetric. systemfull narrows trust to one pinned, deployment-specific CA. fullsystem widens it to every CA in the OS store, including any traffic-inspection CA, so a certificate the pin correctly refused would succeed instead. It also contradicts §2.3's requirement that the fetched CA be the sole anchor, "not an addition to the system store, not one candidate among several", and the benign case is already covered — no anchor at all resolves down the ladder anyway, so the reverse only ever fires when someone asked for pinning and it failed.

  • If it is built anyway: missing-file case only, at startup, never in response to a verification or handshake failure, logged at error.

3. The latch: does an explicit none survive a present anchor?

The branch honours it, warning (4122) with text naming the anchor and the line to remove. Item 33, §2.7 limit 3 and §3.1 say the opposite — keep verifying, log the ignored downgrade at error, never refuse to start.

Both are defensible. For honouring: an explicit setting is an instruction, and the warning is not silent. For the design: "explicitly written" is a poor proxy for "deliberately chosen" here, because §4.1 records that every current Ansible role and Puppet module ships verification_mode=none as its default. The concrete failure is an agent that bootstraps with a token, pins an anchor, enrols verified — and then a configuration-management converge re-templates ossec.conf with the role's default, after which every request goes unverified against a manager it could verify. The design's escape hatch is deliberately the anchor file rather than ossec.conf, because that is the file automation does not rewrite.

So the question is which file is the off switch, not whether explicit means explicit.

  • Decide, and document the off switch either way.
  • Either way, remove the dead branch. The upgrade-to-full path is guarded by !verification_mode_explicit, and that flag is set wherever Read_Agent_SSL() parses the tag (client-config.c:1099), while an unparsed mode arrives as AGENT_VERIFY_UNSET and is consumed by the first rung — so nothing in production reaches it. It is exercised only by a unit test that sets the flag by hand. A latch that cannot fire should not look like one.

Definition of Done

  • A stock install with no token, no CA and no anchor lands on the decided posture, and the packaged configuration, both WPK gates and the documentation all state the same one.
  • An agent set to system on a host whose OS store cannot verify the manager, with a valid local certificate, connects verified and logs one warning saying it fell back.
  • The same agent with no usable local certificate logs critical and does not start — no silent downgrade, no daemon left running with a dead transport.
  • An agent whose OS store does verify behaves exactly as today, with no warning and no fallback.
  • The latch behaves as decided, with a test for the token-install-then-converge sequence specifically.
  • No unreachable branch is left behind in the resolver.
  • The startup gate and the module agree on every combination.

Related: #38940, #39021