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
+};