Add registry allowlist support to image builds (#41211)

beena352 committed Aug 7, 2026 at 11:55 UTC 02e46db9308022f354b804f62372be33c363b83e
9 files changed +406 -42
localization/strings/en-US/Resources.resw
+2 -2
@@ -998,8 +998,8 @@ Falling back to NAT networking.</value>
998 <value>The container image registry '{}' is blocked by the computer policy.</value>
999 <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
1000 </data>
1001 - <data name="MessageImageBuildBlockedByPolicy" xml:space="preserve">
1002 - <value>Building container images is blocked because a container registry allowlist is configured by the computer policy. Image builds may pull base images from arbitrary registries and cannot be reliably restricted to the allowlist.</value>
1001 + <data name="MessageRegistryAllowlistPolicyInvalid" xml:space="preserve">
1002 + <value>The container registry allowlist policy is invalid. Verify the WSLContainerRegistryAllowlist values under Software\Policies\WSL are configured correctly.</value>
1003 </data>
1004 <data name="MessageUpgradeToWSL2" xml:space="preserve">
1005 <value>Please run 'wsl.exe --set-version {} 2' to upgrade to WSL2.</value>
src/linux/init/WSLCInit.cpp
+35 -1
@@ -896,6 +896,40 @@ void HandleMessageImpl(wsl::shared::SocketChannel& Channel, wsl::shared::Transac
896 Transaction.SendResultMessage<int32_t>(result);
897 }
898
899 +void HandleMessageImpl(
900 + wsl::shared::SocketChannel& Channel, wsl::shared::Transaction& Transaction, const WSLC_WRITE_FILE& Message, const gsl::span<gsl::byte>& Buffer)
901 +{
902 + if (Message.PathIndex >= Buffer.size() || Message.ContentIndex > Buffer.size() ||
903 + Message.ContentLength > Buffer.size() - Message.ContentIndex)
904 + {
905 + Transaction.SendResultMessage<int32_t>(EINVAL);
906 + return;
907 + }
908 +
909 + const auto* path = wsl::shared::string::FromSpan(Buffer, Message.PathIndex);
910 + const auto content = Buffer.subspan(Message.ContentIndex, Message.ContentLength);
911 +
912 + int result = 0;
913 + if (UtilMkdirPath(path, 0755, true) < 0)
914 + {
915 + result = errno;
916 + }
917 + else
918 + {
919 + wil::unique_fd fd{open(path, Message.OpenFlags, Message.Permissions)};
920 + if (!fd)
921 + {
922 + result = errno;
923 + }
924 + else if (UtilWriteBuffer(fd.get(), content) != static_cast<ssize_t>(content.size()))
925 + {
926 + result = errno;
927 + }
928 + }
929 +
930 + Transaction.SendResultMessage<int32_t>(result);
931 +}
932 +
933 void HandleMessageImpl(
934 wsl::shared::SocketChannel& Channel, wsl::shared::Transaction& Transaction, const WSLC_DETACH& Message, const gsl::span<gsl::byte>& Buffer)
935 {
@@ -1045,7 +1079,7 @@ void ProcessMessage(wsl::shared::SocketChannel& Channel, wsl::shared::Transactio
1079 {
1080 try
1081 {
1048 - HandleMessage<WSLC_GET_DISK, WSLC_MOUNT, WSLC_MOUNT_VIRTIOFS, WSLC_EXEC, WSLC_FORK, WSLC_CONNECT, WSLC_SIGNAL, WSLC_TTY_RELAY, WSLC_PORT_RELAY, WSLC_UNMOUNT, WSLC_DETACH, WSLC_ACCEPT, WSLC_WATCH_PROCESSES, WSLC_UNIX_CONNECT, WSLC_GET_GUEST_CAPABILITIES, WSLC_LISTDIR>(
1082 + HandleMessage<WSLC_GET_DISK, WSLC_MOUNT, WSLC_MOUNT_VIRTIOFS, WSLC_EXEC, WSLC_FORK, WSLC_CONNECT, WSLC_SIGNAL, WSLC_TTY_RELAY, WSLC_PORT_RELAY, WSLC_UNMOUNT, WSLC_DETACH, WSLC_ACCEPT, WSLC_WATCH_PROCESSES, WSLC_UNIX_CONNECT, WSLC_GET_GUEST_CAPABILITIES, WSLC_LISTDIR, WSLC_WRITE_FILE>(
1083 Channel, Transaction, Type, Buffer);
1084 }
1085 catch (...)
src/shared/inc/lxinitshared.h
+20
@@ -414,6 +414,7 @@ typedef enum _LX_MESSAGE_TYPE
414 LxMessageWSLCListDir,
415 LxMessageWSLCListDirResult,
416 LxMessageWSLCMountVirtioFs,
417 + LxMessageWSLCWriteFile,
418 } LX_MESSAGE_TYPE,
419 *PLX_MESSAGE_TYPE;
420
@@ -529,6 +530,7 @@ inline auto ToString(LX_MESSAGE_TYPE messageType)
530 X(LxMessageWSLCListDir)
531 X(LxMessageWSLCListDirResult)
532 X(LxMessageWSLCMountVirtioFs)
533 + X(LxMessageWSLCWriteFile)
534
535 default:
536 return "<unexpected LX_MESSAGE_TYPE>";
@@ -1926,6 +1928,24 @@ struct WSLC_GET_GUEST_CAPABILITIES
1928 PRETTY_PRINT(FIELD(Header));
1929 };
1930
1931 +struct WSLC_WRITE_FILE
1932 +{
1933 + static inline auto Type = LxMessageWSLCWriteFile;
1934 + using TResponse = RESULT_MESSAGE<int32_t>;
1935 +
1936 + DECLARE_MESSAGE_CTOR(WSLC_WRITE_FILE);
1937 + MESSAGE_HEADER Header;
1938 + unsigned int PathIndex;
1939 + unsigned int ContentIndex;
1940 + unsigned int ContentLength;
1941 + int OpenFlags;
1942 + int Permissions;
1943 + char Buffer[];
1944 +
1945 + // Buffer content excluded from PRETTY_PRINT so callers can pass sensitive payloads.
1946 + PRETTY_PRINT(FIELD(Header), FIELD(OpenFlags), FIELD(Permissions));
1947 +};
1948 +
1949 typedef struct _LX_MINI_INIT_IMPORT_RESULT
1950 {
1951 static inline auto Type = LxMiniInitMessageImportResult;
src/windows/inc/wslpolicies.h
+71 -1
@@ -200,4 +200,74 @@ inline bool HasRegistryAllowlist(HKEY policiesKey)
200 return subKey && !EnumerateRegistryAllowlist(subKey.get()).empty();
201 }
202
203 -} // namespace wsl::windows::policies
\ No newline at end of file
203 +// Snapshot of the WSLContainerRegistryAllowlist policy captured in a single read. Callers use
204 +// State to distinguish an unconfigured policy (fail open) from a policy that is present.
205 +enum class RegistryAllowlistState
206 +{
207 + NotConfigured,
208 + Configured
209 +};
210 +
211 +struct RegistryAllowlistSnapshot
212 +{
213 + RegistryAllowlistState State{RegistryAllowlistState::NotConfigured};
214 + std::vector<std::wstring> Hosts{};
215 +};
216 +
217 +// Reads the allowlist in one shot. Empty entries are skipped (matches EnumerateRegistryAllowlist).
218 +// Throws with an "invalid policy" user error when the sub-key exists but can't be read (bad ACL,
219 +// corrupted values, etc.) so the caller fails closed.
220 +inline RegistryAllowlistSnapshot ReadRegistryAllowlistSnapshot(HKEY policiesKey)
221 +{
222 + if (policiesKey == nullptr)
223 + {
224 + return {};
225 + }
226 +
227 + wil::unique_hkey subKey;
228 + const auto openResult = RegOpenKeyExW(policiesKey, c_wslContainerRegistryAllowlist, 0, KEY_READ, &subKey);
229 + if (openResult == ERROR_PATH_NOT_FOUND || openResult == ERROR_FILE_NOT_FOUND)
230 + {
231 + return {};
232 + }
233 +
234 + THROW_HR_WITH_USER_ERROR_IF(
235 + HRESULT_FROM_WIN32(openResult), wsl::shared::Localization::MessageRegistryAllowlistPolicyInvalid(), openResult != ERROR_SUCCESS);
236 +
237 + RegistryAllowlistSnapshot snapshot;
238 + for (auto& [name, value] : wsl::windows::common::registry::EnumStringValues(subKey.get()))
239 + {
240 + if (value.empty())
241 + {
242 + continue;
243 + }
244 +
245 + snapshot.Hosts.emplace_back(std::move(value));
246 + }
247 +
248 + if (!snapshot.Hosts.empty())
249 + {
250 + snapshot.State = RegistryAllowlistState::Configured;
251 + }
252 +
253 + return snapshot;
254 +}
255 +
256 +// Convenience for callers with no open policies key. Throws MessageRegistryAllowlistPolicyInvalid
257 +// when the policies key can't be opened.
258 +inline RegistryAllowlistSnapshot ReadRegistryAllowlistSnapshotFromPoliciesRoot()
259 +{
260 + wil::unique_hkey policiesKey;
261 + const auto openResult = RegOpenKeyExW(HKEY_LOCAL_MACHINE, c_registryKey, 0, KEY_READ, &policiesKey);
262 + if (openResult == ERROR_PATH_NOT_FOUND || openResult == ERROR_FILE_NOT_FOUND)
263 + {
264 + return {};
265 + }
266 +
267 + THROW_HR_WITH_USER_ERROR_IF(
268 + HRESULT_FROM_WIN32(openResult), wsl::shared::Localization::MessageRegistryAllowlistPolicyInvalid(), openResult != ERROR_SUCCESS);
269 +
270 + return ReadRegistryAllowlistSnapshot(policiesKey.get());
271 +}
272 +
273 +} // namespace wsl::windows::policies
src/windows/service/exe/HcsVirtualMachine.cpp
+1
@@ -14,6 +14,7 @@ Abstract:
14
15 #include "HcsVirtualMachine.h"
16 #include <format>
17 +#include <fstream>
18 #include <string>
19 #include <string_view>
20 #include "hcs_schema.h"
src/windows/wslcsession/WSLCSession.cpp
+11 -12
@@ -971,15 +971,6 @@ try
971 "Invalid flags: 0x%x",
972 Options->Flags);
973
974 - // Image builds shell out to `docker build` inside the VM, which fetches FROM
975 - // base images directly through the in-VM docker daemon and bypasses the
976 - // per-pull registry policy gate. When an allowlist is configured, refuse the
977 - // build outright since we cannot reliably attribute its registry traffic.
978 - if (wsl::windows::policies::HasRegistryAllowlist(wsl::windows::policies::OpenPoliciesKey().get()))
979 - {
980 - THROW_HR_WITH_USER_ERROR(WSLC_E_REGISTRY_BLOCKED_BY_POLICY, Localization::MessageImageBuildBlockedByPolicy());
981 - }
982 -
974 auto buildFileHandle = OpenUserHandle(Options->DockerfileHandle);
975
976 std::optional<UserCOMCallback> comCall;
@@ -992,6 +983,8 @@ try
983
984 THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_runtime.HasVm());
985
986 + const auto policyState = runtime.Vm().GetBuildKitPolicyState();
987 +
988 // Track every Windows folder we mount into the VM during this build so a single scope_exit
989 // unmounts them all on success or on any throw partway through the loop below.
990 std::vector<std::string> mountedPaths;
@@ -1018,12 +1011,18 @@ try
1011 // one parent directory per file secret are mounted.
1012 mountedPaths.reserve(static_cast<size_t>(3) + Options->Secrets.Count);
1013
1021 - auto mountPath = mountInVm(Options->ContextPath, TRUE);
1022 -
1023 - std::vector<std::string> buildArgs{"/usr/bin/docker", "buildx", "build", "--builder", "default", "--progress=rawjson"};
1014 // Environment for the docker process. Env/in-memory secrets are delivered as variables here so their
1015 // values never touch disk; kept off telemetry (only buildArgs is logged).
1016 std::vector<std::string> buildEnv;
1017 +
1018 + if (policyState == WSLCVirtualMachine::BuildKitPolicyState::Configured)
1019 + {
1020 + buildEnv.emplace_back(std::string{"EXPERIMENTAL_BUILDKIT_SOURCE_POLICY="} + WSLCVirtualMachine::c_buildKitPolicyPath);
1021 + }
1022 +
1023 + auto mountPath = mountInVm(Options->ContextPath, TRUE);
1024 +
1025 + std::vector<std::string> buildArgs{"/usr/bin/docker", "buildx", "build", "--builder", "default", "--progress=rawjson"};
1026 if (WI_IsFlagSet(Options->Flags, WSLCBuildImageFlagsNoCache))
1027 {
1028 buildArgs.push_back("--no-cache");
src/windows/wslcsession/WSLCVirtualMachine.cpp
+94
@@ -15,9 +15,11 @@ Abstract:
15
16 --*/
17
18 +#include "precomp.h"
19 #include "WSLCVirtualMachine.h"
20 #include <format>
21 #include <filesystem>
22 +#include <nlohmann/json.hpp>
23 #include "ServiceProcessLauncher.h"
24 #include "wslutil.h"
25 #include "lxinitshared.h"
@@ -34,6 +36,53 @@ constexpr auto CONTAINER_PORT_RANGE = std::pair<uint16_t, uint16_t>(20002, 65535
36
37 static_assert(c_ephemeralPortRange.second < CONTAINER_PORT_RANGE.first);
38
39 +namespace {
40 +
41 +// Escapes regex metacharacters in `input` so a literal hostname can be embedded into a BuildKit
42 +// source-policy regex identifier (e.g. `myreg:5000` stays a literal match).
43 +std::string EscapeRegexMetacharacters(std::string_view input)
44 +{
45 + static constexpr std::string_view c_metacharacters = R"(\.+*?()|[]{}^$)";
46 + std::string escaped;
47 + escaped.reserve(input.size());
48 + for (const char ch : input)
49 + {
50 + if (c_metacharacters.find(ch) != std::string_view::npos)
51 + {
52 + escaped.push_back('\\');
53 + }
54 + escaped.push_back(ch);
55 + }
56 + return escaped;
57 +}
58 +
59 +// DENY-all first, then per-host ALLOW: BuildKit evaluates rules in order and last match wins.
60 +// Hosts are lowercased because BuildKit normalises identifiers before regex matching.
61 +// https://github.com/moby/buildkit/blob/master/docs/sourcepolicy.md
62 +std::string BuildBuildKitSourcePolicyJson(const std::vector<std::string>& allowedHosts)
63 +{
64 + nlohmann::json rules = nlohmann::json::array();
65 + rules.push_back({{"action", "DENY"}, {"selector", {{"identifier", "docker-image://.*"}, {"match_type", "REGEX"}}}});
66 +
67 + for (const auto& host : allowedHosts)
68 + {
69 + std::string lowered;
70 + lowered.reserve(host.size());
71 + std::transform(host.begin(), host.end(), std::back_inserter(lowered), [](unsigned char ch) {
72 + return static_cast<char>(std::tolower(ch));
73 + });
74 +
75 + const auto identifier = "docker-image://" + EscapeRegexMetacharacters(lowered) + "/.*";
76 + rules.push_back({{"action", "ALLOW"}, {"selector", {{"identifier", identifier}, {"match_type", "REGEX"}}}});
77 + }
78 +
79 + nlohmann::json document;
80 + document["rules"] = std::move(rules);
81 + return document.dump();
82 +}
83 +
84 +} // namespace
85 +
86 VmPortAllocation::VmPortAllocation(uint16_t port, int family, int protocol, std::weak_ptr<VmPortReservations> reservations) :
87 m_port(port), m_family(family), m_protocol(protocol), m_reservations(std::move(reservations))
88 {
@@ -324,6 +373,11 @@ void WSLCVirtualMachine::Initialize()
373 // Configure GPU mounts if enabled
374 MountGpuLibraries(c_gpuLibrariesPath, c_gpuDriversPath);
375
376 + // Snapshot the container-registry allowlist and, if configured, hand the BuildKit source-policy
377 + // JSON to init. Done at boot rather than per build so a compromised user process cannot bypass
378 + // enforcement by racing the write.
379 + ConfigureBuildKitPolicy();
380 +
381 // Configure networking. This must happen after all filesystems are mounted since /gns needs to access /sys.
382 ConfigureNetworking();
383 }
@@ -444,6 +498,46 @@ void WSLCVirtualMachine::ReadGuestCapabilities()
498 THROW_IF_FAILED(m_vm->ApplyGuestCapabilities(&capabilities));
499 }
500
501 +void WSLCVirtualMachine::ConfigureBuildKitPolicy()
502 +{
503 + const auto snapshot = wsl::windows::policies::ReadRegistryAllowlistSnapshotFromPoliciesRoot();
504 +
505 + if (snapshot.State == wsl::windows::policies::RegistryAllowlistState::NotConfigured)
506 + {
507 + m_buildKitPolicyState = BuildKitPolicyState::NotConfigured;
508 + return;
509 + }
510 +
511 + std::vector<std::string> hosts;
512 + hosts.reserve(snapshot.Hosts.size());
513 + std::ranges::transform(snapshot.Hosts, std::back_inserter(hosts), [](const std::wstring& host) {
514 + return wsl::shared::string::WideToMultiByte(host);
515 + });
516 +
517 + const auto policyJson = BuildBuildKitSourcePolicyJson(hosts);
518 +
519 + // Linux <fcntl.h> flags for open().
520 + constexpr int c_lxOWriteOnly = 0x1;
521 + constexpr int c_lxOCreate = 0x40;
522 + constexpr int c_lxOTruncate = 0x200;
523 + constexpr int c_lxOCloseOnExec = 0x80000;
524 + constexpr int c_lxONoFollow = 0x20000;
525 +
526 + auto message = wsl::shared::MessageWriter<WSLC_WRITE_FILE>{};
527 + message.WriteString(message->PathIndex, c_buildKitPolicyPath);
528 + message->ContentLength = static_cast<unsigned int>(policyJson.size());
529 + gsl::copy(
530 + gsl::as_bytes(gsl::make_span(policyJson.data(), policyJson.size())),
531 + message.InsertBuffer(message->ContentIndex, policyJson.size()));
532 + message->OpenFlags = c_lxOWriteOnly | c_lxOCreate | c_lxOTruncate | c_lxOCloseOnExec | c_lxONoFollow;
533 + message->Permissions = 0644;
534 +
535 + const auto& response = m_initChannel.Transaction<WSLC_WRITE_FILE>(message.Span(), nullptr, m_initChannelTimeout);
536 + THROW_HR_IF_MSG(E_FAIL, response.Result != 0, "Guest failed to write %hs: %d", c_buildKitPolicyPath, response.Result);
537 +
538 + m_buildKitPolicyState = BuildKitPolicyState::Configured;
539 +}
540 +
541 bool WSLCVirtualMachine::FeatureEnabled(WSLCFeatureFlags Value) const
542 {
543 return static_cast<ULONG>(m_featureFlags) & static_cast<ULONG>(Value);
src/windows/wslcsession/WSLCVirtualMachine.h
+26
@@ -124,6 +124,19 @@ public:
124 static inline const char* c_gpuLibrariesPath = "/usr/lib/wsl/lib";
125 static inline const char* c_gpuDriversPath = "/usr/lib/wsl/drivers";
126
127 + // Path where the guest init writes the BuildKit source-policy JSON when the
128 + // WSLContainerRegistryAllowlist policy is configured. /run is tmpfs, so the file
129 + // disappears on VM shutdown.
130 + static inline const char* c_buildKitPolicyPath = "/run/wsl/buildkit-policy.json";
131 +
132 + // Snapshot of the WSLContainerRegistryAllowlist policy taken at VM boot. A read failure
133 + // throws from Initialize; NotConfigured/Configured are the only states BuildImage sees.
134 + enum class BuildKitPolicyState
135 + {
136 + NotConfigured,
137 + Configured
138 + };
139 +
140 struct ConnectedSocket
141 {
142 int Fd = -1;
@@ -149,6 +162,12 @@ public:
162
163 HRESULT MountWindowsFolder(_In_ LPCWSTR WindowsPath, _In_ LPCSTR LinuxPath, _In_ BOOL ReadOnly);
164 HRESULT UnmountWindowsFolder(_In_ LPCSTR LinuxPath);
165 +
166 + BuildKitPolicyState GetBuildKitPolicyState() const
167 + {
168 + return m_buildKitPolicyState;
169 + }
170 +
171 void Signal(_In_ LONG Pid, _In_ int Signal);
172
173 void OnProcessReleased(int Pid);
@@ -212,6 +231,11 @@ private:
231 // Called after the root filesystem is mounted.
232 void ReadGuestCapabilities();
233
234 + // Reads the WSLContainerRegistryAllowlist policy from the registry and, when configured,
235 + // hands the BuildKit source-policy JSON to the guest init for materialisation. Cached in
236 + // m_buildKitPolicyState for BuildImage to consult per build.
237 + void ConfigureBuildKitPolicy();
238 +
239 static void Mount(wsl::shared::SocketChannel& Channel, LPCSTR Source, _In_ LPCSTR Target, _In_ LPCSTR Type, _In_ LPCSTR Options, _In_ ULONG Flags);
240 static void MountVirtioFsChild(
241 wsl::shared::SocketChannel& Channel, _In_ LPCSTR Source, _In_ LPCSTR ChildName, _In_ LPCSTR Target, _In_ LPCSTR Options, _In_ ULONG Flags);
@@ -279,6 +303,8 @@ private:
303 uint64_t m_hvPciSwiotlbBase = 0;
304 uint64_t m_hvPciSwiotlbSize = 0;
305
306 + BuildKitPolicyState m_buildKitPolicyState{BuildKitPolicyState::NotConfigured};
307 +
308 // Job object that terminates child processes (wslrelay.exe) when the VM shuts down.
309 // Declared before the port relay pipes so it is destroyed after them: any remaining
310 // wslrelay.exe is given the chance to exit via the closed pipes / signaled terminating
test/windows/PolicyTests.cpp
+146 -26
@@ -474,49 +474,114 @@ class PolicyTest
474 auto [stdoutText, stderrText, exitCode] = LxsstuLaunchCommandAndCaptureOutputWithResult(cmd.data(), nullptr, nullptr);
475
476 VERIFY_ARE_NOT_EQUAL(0, exitCode);
477 - const std::wstring combined = stdoutText + stderrText;
478 - if (combined.find(L"docker.io") == std::wstring::npos || combined.find(L"blocked by the computer policy") == std::wstring::npos)
477 + VERIFY_ARE_EQUAL(L"", stdoutText);
478 +
479 + const auto expected = wsl::shared::Localization::MessageRegistryBlockedByPolicy(L"docker.io") +
480 + L"\r\nError code: WSLC_E_REGISTRY_BLOCKED_BY_POLICY\r\n";
481 + VERIFY_ARE_EQUAL(expected, stderrText);
482 + }
483 +
484 + // Verifies WSLContainerRegistryAllowlist blocks `wslc image build` when the FROM base image
485 + // isn't in the allowlist. Matches the `RegistryAllowlistDenies` pull test.
486 + WSLC_TEST_METHOD(RegistryAllowlistBlocksImageBuild)
487 + {
488 + auto revert = SetRegistryAllowlist({L"mcr.microsoft.com"});
489 +
490 + auto [exitCode, output] = RunImageBuild(L"FROM docker.io/library/alpine:latest\n", L"wsl-policy-build-blocked");
491 +
492 + VERIFY_ARE_NOT_EQUAL(0, exitCode);
493 + if (output.find(L"docker.io") == std::wstring::npos || output.find(L"denied by policy") == std::wstring::npos)
494 + {
495 + LogError("Expected BuildKit source-policy denial mentioning docker.io, got: '%ls'", output.c_str());
496 + VERIFY_FAIL();
497 + }
498 + }
499 +
500 + // Positive path: build must proceed when FROM is on the allowlist.
501 + WSLC_TEST_METHOD(RegistryAllowlistAllowsImageBuild)
502 + {
503 + auto revert = SetRegistryAllowlist({L"mcr.microsoft.com"});
504 +
505 + auto [exitCode, output] =
506 + RunImageBuild(L"FROM mcr.microsoft.com/cbl-mariner/base/core:2.0\n", L"wsl-policy-build-allowed");
507 +
508 + if (exitCode != 0)
509 + {
510 + LogError("Expected build against allowlisted registry to succeed, got exit=%d output: '%ls'", exitCode, output.c_str());
511 + VERIFY_FAIL();
512 + }
513 + }
514 +
515 + // Case regression: allowlist entries stored uppercase must still match lowercased FROM.
516 + WSLC_TEST_METHOD(RegistryAllowlistImageBuildIsCaseInsensitive)
517 + {
518 + auto revert = SetRegistryAllowlist({L"MCR.MICROSOFT.COM"});
519 +
520 + auto [exitCode, output] = RunImageBuild(L"FROM mcr.microsoft.com/cbl-mariner/base/core:2.0\n", L"wsl-policy-build-case");
521 +
522 + if (exitCode != 0)
523 {
480 - LogError(
481 - "Expected blocked-by-policy for docker.io when allowlist is mcr.microsoft.com, got stdout: '%ls' stderr: '%ls'",
482 - stdoutText.c_str(),
483 - stderrText.c_str());
524 + LogError("Expected uppercase allowlist entry to match lowercase FROM, got: '%ls'", output.c_str());
525 VERIFY_FAIL();
526 }
527 }
528
488 - // Verifies that `wslc image build` is rejected outright when an allowlist is configured,
489 - // since the in-VM docker daemon would fetch FROM base images directly and bypass the
490 - // per-pull registry gate.
491 - WSLC_TEST_METHOD(RegistryAllowlistRejectsImageBuild)
529 + // Multi-stage regression: `COPY --from=<image>` must also be gated, not just top-level FROM.
530 + WSLC_TEST_METHOD(RegistryAllowlistBlocksImageBuildCopyFrom)
531 {
532 auto revert = SetRegistryAllowlist({L"mcr.microsoft.com"});
533 + const auto dockerfile =
534 + L"FROM mcr.microsoft.com/cbl-mariner/base/core:2.0\n"
535 + L"COPY --from=docker.io/library/alpine:latest /etc/os-release /tmp/os-release\n";
536
495 - // Set up a minimal build context with a one-line Dockerfile in TEMP.
496 - const auto contextDir = std::filesystem::temp_directory_path() / L"wsl-policy-build-test";
537 + auto [exitCode, output] = RunImageBuild(dockerfile, L"wsl-policy-build-copyfrom");
538 +
539 + VERIFY_ARE_NOT_EQUAL(0, exitCode);
540 + if (output.find(L"docker.io") == std::wstring::npos || output.find(L"denied by policy") == std::wstring::npos)
541 + {
542 + LogError("Expected COPY --from=docker.io/... to be blocked, got: '%ls'", output.c_str());
543 + VERIFY_FAIL();
544 + }
545 + }
546 +
547 + WSLC_TEST_METHOD(RegistryAllowlistBlocksImageBuildImplicitDockerIo)
548 + {
549 + auto revert = SetRegistryAllowlist({L"mcr.microsoft.com"});
550 +
551 + auto [exitCode, output] = RunImageBuild(L"FROM alpine:latest\n", L"wsl-policy-build-implicit");
552 +
553 + VERIFY_ARE_NOT_EQUAL(0, exitCode);
554 + if (output.find(L"denied by policy") == std::wstring::npos ||
555 + (output.find(L"docker.io") == std::wstring::npos && output.find(L"alpine") == std::wstring::npos))
556 + {
557 + LogError("Expected bare `FROM alpine:latest` to be blocked, got: '%ls'", output.c_str());
558 + VERIFY_FAIL();
559 + }
560 + }
561 +
562 + // Runs `wslc image build` with the supplied Dockerfile content and returns the exit code
563 + // plus combined stdout/stderr. Extracted to keep the allowlist matrix above readable.
564 + static std::tuple<int, std::wstring> RunImageBuild(std::wstring_view dockerfile, std::wstring_view folder)
565 + {
566 + // Terminate any existing session so ConfigureBuildKitPolicy re-snapshots the registry.
567 + {
568 + std::wstring terminateCmd = L"\"" + GetWslcExePath() + L"\" system session terminate";
569 + LxsstuLaunchCommandAndCaptureOutputWithResult(terminateCmd.data(), nullptr, nullptr);
570 + }
571 +
572 + const auto contextDir = std::filesystem::temp_directory_path() / folder;
573 std::error_code ec;
574 std::filesystem::remove_all(contextDir, ec);
575 std::filesystem::create_directories(contextDir);
576 auto cleanup = wil::scope_exit([&] { std::filesystem::remove_all(contextDir, ec); });
501 -
577 {
578 std::ofstream df(contextDir / L"Dockerfile");
579 VERIFY_IS_TRUE(df.is_open());
505 - df << "FROM scratch\n";
580 + df << wsl::shared::string::WideToMultiByte(std::wstring{dockerfile});
581 }
507 -
582 std::wstring cmd = L"\"" + GetWslcExePath() + L"\" image build \"" + contextDir.wstring() + L"\"";
583 auto [stdoutText, stderrText, exitCode] = LxsstuLaunchCommandAndCaptureOutputWithResult(cmd.data(), nullptr, nullptr);
510 -
511 - VERIFY_ARE_NOT_EQUAL(0, exitCode);
512 - const std::wstring combined = stdoutText + stderrText;
513 - if (combined.find(L"Building container images is blocked") == std::wstring::npos ||
514 - combined.find(L"computer policy") == std::wstring::npos)
515 - {
516 - LogError(
517 - "Expected image-build to be blocked by policy, got stdout: '%ls' stderr: '%ls'", stdoutText.c_str(), stderrText.c_str());
518 - VERIFY_FAIL();
519 - }
584 + return {exitCode, stdoutText + stderrText};
585 }
586
587 // Pure-function tests for the registry-allowlist policy evaluator. These don't talk to the
@@ -582,4 +647,59 @@ class PolicyTest
647 VERIFY_IS_TRUE(HasRegistryAllowlist(policiesKey.get()));
648 }
649 }
585 -};
\ No newline at end of file
650 +
651 + // Pure-function tests for ReadRegistryAllowlistSnapshot (used by `wslc image build` to
652 + // decide between fail-open-no-policy, generate-source-policy, and fail-closed paths).
653 + TEST_METHOD(ReadRegistryAllowlistSnapshot_Logic)
654 + {
655 + // Null policies key -> NotConfigured, no hosts.
656 + {
657 + const auto snapshot = ReadRegistryAllowlistSnapshot(nullptr);
658 + VERIFY_IS_TRUE(snapshot.State == RegistryAllowlistState::NotConfigured);
659 + VERIFY_IS_TRUE(snapshot.Hosts.empty());
660 + }
661 +
662 + const auto policiesKey = OpenPoliciesKey();
663 + VERIFY_IS_TRUE(!!policiesKey);
664 +
665 + // No sub-key -> NotConfigured.
666 + {
667 + const auto snapshot = ReadRegistryAllowlistSnapshot(policiesKey.get());
668 + VERIFY_IS_TRUE(snapshot.State == RegistryAllowlistState::NotConfigured);
669 + VERIFY_IS_TRUE(snapshot.Hosts.empty());
670 + }
671 +
672 + // Sub-key with only empty entries -> NotConfigured (defensive: stray blank GP list
673 + // items must not silently deny every registry).
674 + {
675 + auto revert = SetRegistryAllowlist({L"", L""});
676 + const auto snapshot = ReadRegistryAllowlistSnapshot(policiesKey.get());
677 + VERIFY_IS_TRUE(snapshot.State == RegistryAllowlistState::NotConfigured);
678 + VERIFY_IS_TRUE(snapshot.Hosts.empty());
679 + }
680 +
681 + // Sub-key with hosts -> Configured, hosts populated in order.
682 + {
683 + auto revert = SetRegistryAllowlist({L"mcr.microsoft.com", L"Docker.IO"});
684 + const auto snapshot = ReadRegistryAllowlistSnapshot(policiesKey.get());
685 + VERIFY_IS_TRUE(snapshot.State == RegistryAllowlistState::Configured);
686 + VERIFY_ARE_EQUAL(size_t{2}, snapshot.Hosts.size());
687 + }
688 + }
689 +
690 + TEST_METHOD(ReadRegistryAllowlistSnapshotFromPoliciesRoot_Logic)
691 + {
692 + {
693 + const auto snapshot = ReadRegistryAllowlistSnapshotFromPoliciesRoot();
694 + VERIFY_IS_TRUE(snapshot.State == RegistryAllowlistState::NotConfigured);
695 + VERIFY_IS_TRUE(snapshot.Hosts.empty());
696 + }
697 +
698 + {
699 + auto revert = SetRegistryAllowlist({L"mcr.microsoft.com"});
700 + const auto snapshot = ReadRegistryAllowlistSnapshotFromPoliciesRoot();
701 + VERIFY_IS_TRUE(snapshot.State == RegistryAllowlistState::Configured);
702 + VERIFY_ARE_EQUAL(size_t{1}, snapshot.Hosts.size());
703 + }
704 + }
705 +};