Skip to content

Commit be0acb0

Browse files
committed
Security Enforcement for Trusted Registries: Only allow module restore from truested registries
1 parent 213a26e commit be0acb0

File tree

10 files changed

+834
-7
lines changed

10 files changed

+834
-7
lines changed

src/Bicep.Cli.IntegrationTests/RestoreCommandTests.cs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,126 @@ public async Task Restore_bicepparam_should_fail_with_error_diagnostics_for_regi
655655
result.Stderr.Should().Contain("main.bicepparam(1,7) : Error BCP192: Unable to restore the artifact with reference \"br:mockregistry.io/parameters/basic:v1\": Mock registry request failure.");
656656
}
657657

658+
// ── Trusted Registry (BCP446 / BCP447) tests ─────────────────────────
659+
660+
[TestMethod]
661+
public async Task Restore_UntrustedRegistry_EmitsBcp446_ExitCodeOne()
662+
{
663+
// Use an arbitrary hostname that is NOT in the built-in trusted list and NOT in the module's bicepconfig.json
664+
var registry = "untrusted.example.com";
665+
var repository = "mymodule";
666+
667+
// The clientFactory is not expected to be called because enforcement blocks before network I/O.
668+
var clientFactory = StrictMock.Of<IContainerRegistryClientFactory>();
669+
var templateSpecRepositoryFactory = StrictMock.Of<ITemplateSpecRepositoryFactory>();
670+
671+
var tempDirectory = FileHelper.GetUniqueTestOutputPath(TestContext);
672+
Directory.CreateDirectory(tempDirectory);
673+
674+
var bicepFilePath = Path.Combine(tempDirectory, "main.bicep");
675+
File.WriteAllText(bicepFilePath, $"module mod 'br:{registry}/{repository}:v1' = {{ name: 'mod' }}");
676+
677+
var settings = new InvocationSettings(new(TestContext, RegistryEnabled: true), clientFactory.Object, templateSpecRepositoryFactory.Object);
678+
var (output, error, result) = await Bicep(settings, "restore", bicepFilePath);
679+
680+
using (new AssertionScope())
681+
{
682+
result.Should().Be(1);
683+
output.Should().BeEmpty();
684+
error.Should().Contain("BCP446");
685+
error.Should().Contain(registry);
686+
}
687+
}
688+
689+
[TestMethod]
690+
public async Task Restore_UntrustedRegistry_AddedToTrustedRegistries_Succeeds()
691+
{
692+
var registry = "mycompany.example.com";
693+
var repository = "mymodule";
694+
var registryUri = new Uri($"https://{registry}");
695+
696+
// Publish a real (mock) module so restore can succeed
697+
var client = new FakeRegistryBlobClient();
698+
var clientFactory = StrictMock.Of<IContainerRegistryClientFactory>();
699+
clientFactory
700+
.Setup(m => m.CreateAuthenticatedBlobClient(It.IsAny<CloudConfiguration>(), registryUri, repository))
701+
.Returns(client);
702+
703+
var tempDirectory = FileHelper.GetUniqueTestOutputPath(TestContext);
704+
Directory.CreateDirectory(tempDirectory);
705+
706+
var publishedBicepFilePath = Path.Combine(tempDirectory, "module.bicep");
707+
File.WriteAllText(publishedBicepFilePath, "output hello string = 'world'");
708+
709+
var templateSpecRepositoryFactory = BicepTestConstants.TemplateSpecRepositoryFactory;
710+
var publishSettings = new InvocationSettings(new(TestContext, RegistryEnabled: true), clientFactory.Object, templateSpecRepositoryFactory);
711+
712+
// Publish the module
713+
var (_, _, publishResult) = await Bicep(publishSettings, "publish", publishedBicepFilePath, "--target", $"br:{registry}/{repository}:v1");
714+
publishResult.Should().Be(0);
715+
716+
// Write a bicepconfig.json that adds the registry to trustedRegistries
717+
var bicepConfigPath = Path.Combine(tempDirectory, "bicepconfig.json");
718+
File.WriteAllText(bicepConfigPath, $$"""
719+
{
720+
"security": {
721+
"trustedRegistries": ["{{registry}}"]
722+
}
723+
}
724+
""");
725+
726+
var bicepFilePath = Path.Combine(tempDirectory, "main.bicep");
727+
File.WriteAllText(bicepFilePath, $"module mod 'br:{registry}/{repository}:v1' = {{ name: 'mod' }}");
728+
729+
var restoreSettings = new InvocationSettings(new(TestContext, RegistryEnabled: true), clientFactory.Object, templateSpecRepositoryFactory);
730+
var (output, error, result) = await Bicep(restoreSettings, "restore", bicepFilePath);
731+
732+
using (new AssertionScope())
733+
{
734+
result.Should().Be(0, $"restore should succeed when registry is in trustedRegistries; stderr was: {error}");
735+
output.Should().BeEmpty();
736+
error.Should().BeEmpty();
737+
}
738+
}
739+
740+
[TestMethod]
741+
public async Task Restore_InvalidTrustedRegistriesPattern_EmitsBcp447_ExitCodeOne()
742+
{
743+
// Use a registry hostname that IS trusted by default but the bicepconfig has an invalid pattern
744+
var registry = "contoso.azurecr.io";
745+
var repository = "mymodule";
746+
747+
var clientFactory = StrictMock.Of<IContainerRegistryClientFactory>();
748+
var templateSpecRepositoryFactory = StrictMock.Of<ITemplateSpecRepositoryFactory>();
749+
750+
var tempDirectory = FileHelper.GetUniqueTestOutputPath(TestContext);
751+
Directory.CreateDirectory(tempDirectory);
752+
753+
// Write a bicepconfig.json with an invalid pattern (single-label wildcard *.io is rejected)
754+
var bicepConfigPath = Path.Combine(tempDirectory, "bicepconfig.json");
755+
File.WriteAllText(bicepConfigPath, """
756+
{
757+
"security": {
758+
"trustedRegistries": ["*.io"]
759+
}
760+
}
761+
""");
762+
763+
var bicepFilePath = Path.Combine(tempDirectory, "main.bicep");
764+
File.WriteAllText(bicepFilePath, $"module mod 'br:{registry}/{repository}:v1' = {{ name: 'mod' }}");
765+
766+
var settings = new InvocationSettings(new(TestContext, RegistryEnabled: true), clientFactory.Object, templateSpecRepositoryFactory.Object);
767+
var (output, error, result) = await Bicep(settings, "restore", bicepFilePath);
768+
769+
using (new AssertionScope())
770+
{
771+
result.Should().Be(1);
772+
output.Should().BeEmpty();
773+
error.Should().Contain("BCP447");
774+
error.Should().Contain("*.io");
775+
}
776+
}
777+
658778
private static IEnumerable<object[]> GetAllDataSetsWithPublishSource()
659779
{
660780
foreach (DataSet ds in DataSets.AllDataSets)

src/Bicep.Core.Samples/Files/baselines/Registry_LF/bicepconfig.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,14 @@
1515
"modulePath": "demo"
1616
}
1717
}
18+
},
19+
"security": {
20+
"trustedRegistries": [
21+
"mock-registry-one.invalid",
22+
"mock-registry-two.invalid",
23+
"localhost",
24+
"127.0.0.1",
25+
"[::1]"
26+
]
1827
}
1928
}

src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ public void GetBuiltInConfiguration_NoParameter_ReturnsBuiltInConfigurationWithA
121121
"insertFinalNewline": true,
122122
"indentSize": 2,
123123
"width": 120
124+
},
125+
"security": {
126+
"trustedRegistries": []
124127
}
125128
}
126129
""");
@@ -205,6 +208,9 @@ public void GetBuiltInConfiguration_DisableAllAnalyzers_ReturnsBuiltInConfigurat
205208
"insertFinalNewline": true,
206209
"indentSize": 2,
207210
"width": 120
211+
},
212+
"security": {
213+
"trustedRegistries": []
208214
}
209215
}
210216
""");
@@ -311,6 +317,9 @@ public void GetBuiltInConfiguration_DisableAnalyzers_ReturnsBuiltInConfiguration
311317
"insertFinalNewline": true,
312318
"indentSize": 2,
313319
"width": 120
320+
},
321+
"security": {
322+
"trustedRegistries": []
314323
}
315324
}
316325
""");
@@ -483,6 +492,9 @@ public void GetBuiltInConfiguration_EnableExperimentalFeature_ReturnsBuiltInConf
483492
"insertFinalNewline": true,
484493
"indentSize": 2,
485494
"width": 120
495+
},
496+
"security": {
497+
"trustedRegistries": []
486498
}
487499
}
488500
""");
@@ -835,6 +847,9 @@ public void GetConfiguration_ValidCustomConfiguration_OverridesBuiltInConfigurat
835847
"insertFinalNewline": true,
836848
"indentSize": 2,
837849
"width": 80
850+
},
851+
"security": {
852+
"trustedRegistries": []
838853
}
839854
}
840855
""");

0 commit comments

Comments
 (0)