Repository navigation
[PRODENG-3935] Warn on insecure WinRM with an inline password - #682
Conversation
winRM.insecure makes rig accept any server certificate, so a host with useHTTPS: true and insecure: true has an encrypted but unauthenticated channel: an on-path attacker can impersonate the host, recover the password and read every command sent to it. ClusterConfig.Validate now warns once per affected host. Validate is the seam rather than Host.UnmarshalYAML because it runs once per config load (pkg/product/mke/mke.go:37), while UnmarshalYAML re-runs on migrations and round-trips and would emit duplicate warnings for one apply. The trigger is UseHTTPS && Insecure && Password != "". It is a warning, not an error, so an operator who accepts the risk can still apply. The message names the host address and the two settings, never the credential, its length, or a digest of it. docs/usage/getting-started.md gains guidance on sourcing credentials from the environment, covering the envsubst rules that apply: an unset variable fails the load rather than substituting empty, a literal "$" must be escaped as "$$", and "$" followed by a digit stays literal. Covers acceptance criteria 2 and 3. Criterion 1, scrubbing the untracked working-tree launchpad.yaml and rotating the referenced credentials, is not addressed here. Written by AI: claude-opus-5 Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
There was a problem hiding this comment.
🟡 Changes recommended
The insecure WinRM warning misses HTTPS implicitly enabled by port 5986, and the documentation omits YAML-escaping limitations that can alter environment-sourced passwords or cause parsing errors.
2 open issues
What changed in this PR
This PR adds a configuration-time warning for risky WinRM settings in Launchpad and documents safer credential handling. It warns operators without blocking cluster operations.
Changes:
- Warns per host when HTTPS, insecure certificate verification, and a non-empty WinRM password are configured.
- Adds tests for warning conditions, per-host reporting, and password omission from logs.
- Documents environment-variable substitution and WinRM certificate risks.
| File | Description |
|---|---|
pkg/product/mke/config/cluster.go |
Adds the WinRM warning during configuration validation. |
pkg/product/mke/config/cluster_test.go |
Tests warning behavior and credential-safe logging. |
docs/usage/getting-started.md |
Adds credential-sourcing and WinRM TLS guidance. |
Review effort: Balanced
Two findings from the automated review on PR #682. Finding on docs/usage/getting-started.md: an environment-sourced value is substituted into the configuration text, which is then parsed as YAML, so metacharacters in the value are interpreted. Confirmed against the built binary: WINRM_PASSWORD='abc: def' aborts the load with "yaml: line 11: mapping values are not allowed in this context", while a plain value loads. Quoting the placeholder in the source file does not help, because pkg/config/config.go re-marshals the configuration before substitution and that discards the quoting. The guidance now documents the re-parse, the truncation of an unquoted "#", and two ways to avoid it. Finding on pkg/product/mke/config/cluster.go: the claim was that a host with port 5986, insecure and a password, but no explicit useHTTPS, escapes the warning. It does not. rig's winrm.Config.SetDefaults sets UseHTTPS for port 5986, and creasty/defaults invokes it through the Setter interface from Host.UnmarshalYAML (defaults.go:42,164), so UseHTTPS is already true when Validate runs. Verified by running the binary against such a config: the warning fires. No code change; a test case pins the behaviour so a future change to the defaults chain cannot regress it silently. Written by AI: claude-opus-5 Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
…g fields Two items from the review of PR #682. The warning told the user to keep the password out of the configuration file by using an environment variable. Substitution happens at pkg/config/config.go:79, before parsing, so Password is populated identically whether the value was inlined or came from ${WINRM_PASSWORD}. A user who had already followed the guidance was told to follow it. The message now leads with the certificate problem, which is actionable in every case, and names winRM.caCertPath as the fix. That field name is confirmed against protocol/winrm/config.go:24, not taken from the review. The tests asserted the password never appears in entry.Message but did not check entry.Data, so a change that moved the value into a logrus field would have passed. Both are now asserted. Written by AI: claude-opus-5 Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
Added by user ruling, beyond the two findings named in the ticket. rig installs a WinRM transport decorator only for NTLM, or for HTTPS with a loaded client certificate (protocol/winrm/connection.go:242-250). With useHTTPS false and useNTLM false, both of which are the defaults (protocol/winrm/config.go:21,23), neither applies, so go-winrm's default transport calls req.SetBasicAuth (masterzen/winrm/http.go:96) and the password crosses the network base64-encoded in an Authorization header with no encryption. The two exposures are reported as separate messages rather than one blended warning, because the remedy differs and a reader must be able to tell which applies: plain HTTP says the password is sent in clear, insecure HTTPS says the certificate is not verified. A host configured with useNTLM is not reported, since NTLM negotiates its own message-level encryption. warnInsecureWinRM is renamed warnWinRMPasswordExposure to match what it now covers. Tests assert the distinguishing phrase of each message, assert the other message's phrase is absent so the two cannot blend, and keep the existing negative cases and the entry.Data leak assertion. Written by AI: claude-opus-5 Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
Added by user ruling, beyond the two findings named in the ticket. ClusterConfig.Validate now runs rig's CompositeConfig.Validate for every host. rig already requires a user and a password together (protocol/winrm/config.go:66-68, and the SSH equivalent), but launchpad never reached that guard: it connects through CompositeConfig.Connection, which does not validate, so the check was dead code on this path. This closes a hole in the warning shipped earlier on this branch. The warning fires where the password survives, and was silent where it vanished entirely: an empty Password reads as "no password configured", so a value truncated during YAML parsing produced no warning and launchpad then sent an HTTP Basic header with an empty password, measured as scheme=Basic user_len=13 password_len=0 against a server whose certificate it had not verified. Two configuration shapes that load today stop loading. A winRM host with a user and no password, which includes a host relying on the Administrator default, now fails with "user and password must both be set for password authentication". A host entry with no connection block fails with "no protocol configuration". Neither could authenticate before; the observable change is load-time rejection rather than connect-time failure. Certificate authentication is unaffected. TestHostWinRMDefaults gains a password rather than having its assertion inverted. git log -L shows the function predates the rig refactor, so its require.NoError was written when Validate did not consult connection configuration and never asserted anything about authentication. Inverting it would convert a defaults test into a validation test and drop the default coverage. .changes/PRODENG-3935.md records the breaking change, using the fragment convention from PRODENG-3595. Written by AI: claude-opus-5 Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
Resolves the cluster.go and cluster_test.go conflicts with PRODENG-3931, merged as e095a0b. Both sides are additive and both survive. cluster.go: the conflict boundary fell inside warnWinRMPasswordExposure, so combining the two sides dropped its closing brace. Restored, and the registration block verified by reading rather than by the build: Validate calls warnWinRMPasswordExposure, registers cluster_name with isPathSafeName and calls validateConnections. A dropped registration compiles and silently stops validating. cluster_test.go: the two sides interleaved rather than appending, so taking both wholesale would have produced a test function assembled from halves of each. Our side was kept for both blocks and PRODENG-3931's TestClusterNameMustBeASinglePathElement was re-added verbatim from origin/main. Every test verified by execution rather than by presence, since a dropped test function leaves a green suite: --- PASS: TestClusterNameMustBeASinglePathElement --- PASS: TestWinRMPasswordExposureWarning --- PASS: TestWinRMPasswordExposureWarningIsPerHost --- PASS: TestEmptyWinRMPasswordIsRejected --- PASS: TestHostWithNoProtocolIsRejected --- PASS: TestHostWinRMDefaults Written by AI: claude-opus-5 Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
james-nesbitt
left a comment
There was a problem hiding this comment.
Should this fix be considered a workaround?
Perhaps what we need is to submit an upstream change that better handles insecure winrm checking?
Signed-off-by: James Nesbitt <jnesbitt@mirantis.com> # Conflicts: # pkg/product/mke/config/cluster.go # pkg/product/mke/config/cluster_test.go
Signed-off-by: James Nesbitt <jnesbitt@mirantis.com> # Conflicts: # pkg/product/mke/config/cluster.go
|
Partly, and the measurement changed the answer mid-investigation — so here is what is actually true rather than what we assumed. The condition is invisible to rig today. But rig cannot carry the warning, and not for the reason we expected. So the warning stays here, and it is not a workaround: it is the only place the message can reach the operator. What is worth sending upstream is the no-op logger injection itself, which looks like a plain bug independent of this ticket — a caller who injects a logger silently does not get one on the config. The same line exists at One thing this PR is not a workaround for either: Written by AI: claude-opus-5 |

What
ClusterConfig.Validatewarns once per host that combineswinRM.useHTTPS: true,winRM.insecure: trueand a non-emptywinRM.password.docs/usage/getting-started.mdgains two sections: sourcing credentials from the environment via envsubst, and the risk ofwinRM.insecure.Why
PRODENG-3935, finding
secf_ad4234c93364c313eba5a63f.winRM.insecureis rig's accept-any-certificate option (protocol/winrm/config.go:22), so the channel is encrypted but the peer is unauthenticated. An on-path attacker can impersonate the host, recover the password and read every command sent to it.How
Validateis the seam, notHost.UnmarshalYAML:Validateruns once per config load (pkg/product/mke/mke.go:37), whileUnmarshalYAMLre-runs on migrations,Initround-trips and tests, emitting duplicate warnings for a single apply. The existing WinRM port fix athost.go:169lives inUnmarshalYAMLbecause it must mutate the struct before rig reads it; a log line has no such constraint.UseHTTPS && Insecure && Password != "", the finding as written. Field names taken fromprotocol/winrm/config.go:20-22, not from the ticket's YAML spelling.Testing
TestInsecureWinRMWarning(5 cases) andTestInsecureWinRMWarningIsPerHost: fail before the change, pass after. Negative cases cover insecure+HTTPS with no password, HTTPS+password withinsecure: false, insecure+password without HTTPS, and a bare host. Every case asserts the password string never reaches the log.make lint: 0 issues.make unit-test: all packages ok.make localand ranapplyagainst a throwaway config with a fake password. Warning printed at config load, before the Open Remote Connection phase, with no credential in it.variable ${WINRM_PASSWORD} not set;abc$deffails withvariable ${def} not set;abc$5986is left literal.Links
https://mirantis.jira.com/browse/PRODENG-3935
Checklist
Acceptance criteria status
All three criteria are satisfied.
Criterion 2 (envsubst guidance) and criterion 3 (warning at config load) are delivered by this PR.
Criterion 1 is complete, and was settled outside this PR:
launchpad.yamlwas untracked in the root checkout (.gitignore:19 **/launchpad.yaml) and never reachable from a worktree. Its four credential values were replaced in place with${WINRM_PASSWORD}and${MKE_ADMIN_PASSWORD}placeholders, the same pattern the new documentation teaches.launchpad.yamlwas never committed on any branch (git log --all --diff-filter=Areturns nothing for it). Two other paths existed historically and are both removed:test/launchpad.yaml(added inf7c06ed) andexamples/footloose/launchpad.yaml(added incae9335). Each holds exactly one credential key,--admin-password=, carrying the long-standingorcaorcaorcaMKE test string against127.0.0.1loopback addresses in footloose fixtures - a dummy consistent with those examples' own conventions, not a real credential. The private key those fixtures reference was never committed either.Review findings
6b302cc: environment-sourced values are substituted into the configuration text and then re-parsed as YAML, so a value containing#or:is truncated or aborts the load. The guidance documents this, replacing an earlier claim that such values are substituted verbatim.6b302cc: the report thatport: 5986without an explicituseHTTPSescapes the warning did not reproduce.creasty/defaultsinvokes rig'swinrm.Config.SetDefaultsfromHost.UnmarshalYAML, soUseHTTPSis already true whenValidateruns. A test case pins that behaviour.80216f5: the warning previously advised keeping the password out of the file by using an environment variable. Substitution atpkg/config/config.go:79precedes parsing, so that advice also reached users who had already done exactly that. The message now leads with the certificate problem and nameswinRM.caCertPath(confirmed againstprotocol/winrm/config.go:24).80216f5: the tests asserted the password never appears inentry.Message;entry.Datais now asserted too, closing a leak route through structured log fields.Scope added by user ruling
The plain-HTTP warning is a widening beyond the two findings named in the ticket, added by user ruling. A reviewer checking acceptance criteria against the diff will not find it in the ticket text; it is intentional, not scope creep.
winRM.useHTTPSandwinRM.useNTLMboth default tofalse(protocol/winrm/config.go:21,23). rig installs a transport decorator only for NTLM, or for HTTPS with a loaded client certificate (connection.go:242-250), so with the defaults neither applies and go-winrm's default transport callsreq.SetBasicAuth(masterzen/winrm/http.go:96). The password therefore crosses the network base64-encoded in anAuthorizationheader with no encryption — arguably worse than the unverified-certificate case the ticket named, since there is no TLS at all.The two exposures are reported as separate messages rather than blended into one, because the remedies differ:
useHTTPS: false, no NTLM, password setuseHTTPS: trueplusinsecure: true, password setuseHTTPS: false,useNTLM: trueAC-1 is unaffected by this addition and remains complete.
Empty-password rejection — added by user ruling
The third widening beyond this ticket's two named findings, added by user ruling and not present in the ticket text. A reviewer checking acceptance criteria against the diff will not find it there; it is intentional.
The hole it closes. The warning this PR already shipped fires where the password survives. It was silent where the password vanished entirely, because an empty
Passwordreads as "no password configured". A value truncated during YAML parsing — a password beginning with#, consumed as a comment — produced no warning, and launchpad then authenticated as the configured user with no password at all. Measured against a local TLS listener:scheme=Basic user_len=13 password_len=0, against a server whose certificate it had not verified.ClusterConfig.Validatenow runs rig'sCompositeConfig.Validatefor every host. rig already enforces that a password-authenticated connection has both a user and a password (protocol/winrm/config.go:66-68, and the SSH equivalent), but launchpad never reached it: it connects throughCompositeConfig.Connection(), which does not validate, so the guard was dead code on this path.Configurations that load today will stop loading. Two shapes:
winRMhost with auserand nopassword— including one relying on theAdministratordefaultuser and password must both be set for password authenticationno protocol configurationNeither could authenticate before this change: the first sent an HTTP Basic header with an empty password and failed at connect. The observable difference is load-time rejection instead of connect-time failure. Certificate authentication (
certPathpluskeyPath) is unaffected and still validates.No passwordless WinRM authentication mode is lost.
validateAuthis the complete exemption set, and there is no Kerberos, Negotiate or credential-from-environment path in rig's WinRM protocol; NTLM uses the same user and password.TestHostWinRMDefaultsgained a password rather than an inverted assertion.git log -Lon that function shows it predates the rig refactor, so itsrequire.NoErrorwas written whenValidatedid not consult connection configuration at all and never asserted anything about authentication. Inverting it to expect an error would convert a defaults test into a validation test and drop the default coverage it exists for.Context, not a change in this PR:
windows_passworddefaults to""inexamples/terraform/aws-simpleandaws-complete, so our own examples can generate a WinRM host with an empty password — exactly what this validator now rejects. The generator is fixed separately in #689. A reviewer seeing only this diff could not otherwise tell whether we ship configuration we reject.AC-1 is not reopened. It remains complete per the rotation ruling.
The breaking change is recorded in
.changes/PRODENG-3935.md, using the fragment convention introduced in #692.Testing gaps
make functional-testwas not run: it fails fleet-wide on an unauthenticated GitHub API rate limit and reproduces on untouchedmainatce4a91d. The Aikido scan gate was not run; it is parked by a separate ruling and blocks nothing here.Written by AI: claude-opus-5
LOCAL VALIDATION, not a CI smoke result
Run on 2026-11-06 against the binary built from
bfa5ee1in this worktree. Re-run after mergingmainate095a0b(PRODENG-3931), which changedClusterConfig.Validate()— the function both of these cases exercise. The earlier run at1df53c9was stale and has been replaced rather than carried forward. No AWS, no terraform, no hosts. Config load completes inpkg/configbefore any host is contacted, so these cases are decided and the process exits before provisioning. CI's smoke harness cannot produce them: it asserts an apply succeeds, and two of these three are reject paths.The distinction asserted throughout is failed at load versus failed at connect — not merely "it errored". Accept cases use the unreachable address
198.51.100.10, so reachingRunning phaseproves the config was accepted.useHTTPS: true+insecure: true+ inline passwordRunning phase×2 — accepted and warned#secretvia${V682PW}Running phase×2Verbatim, case 2:
Case 3 emitted no
winRM.insecureline and noFATA, reaching==> Running phase: Check For Upgradesand==> Running phase: Open Remote Connection.Written by AI: claude-opus-5