diff --git a/README.md b/README.md index 8228b8a..ef51c3f 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ winget-intune package {PackageId} [--version {version}] [--source winget] --pack > The `packageId` is case sensitive, so make sure you use the correct casing. Tip: Copy it from the result of the `winget search {name}` command. This command will download the [content-prep-tool](https://github.com/Microsoft/Microsoft-Win32-Content-Prep-Tool) automatically, and use it to create the `intunewin` file. -In a future version this might be replaced with a custom implementation, but for now this works. +In a future version this might be replaced with a custom implementation, but for now this works. The SHA265 hash of the installer is checked and compared to the one in the `winget` manifest, to make sure you won't package a tampered installer. ### Publish @@ -104,6 +104,11 @@ I'm planning to release the actual intune specific code as a separate library, s If you want to contribute to this project, please check out the [contributing](https://github.com/svrooij/WingetIntune/blob/main/CONTRIBUTING.md) page and the [Code of Conduct](https://github.com/svrooij/WingetIntune/blob/main/CODE_OF_CONDUCT.md). +## Usefull information + +- [Microsoft-Win32-Content-Prep-Tool](https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool) +- [Blog articles on Intune](https://svrooij.io/tags/intune/) + [badge_blog]: https://img.shields.io/badge/blog-svrooij.io-blue?style=for-the-badge [badge_linkedin]: https://img.shields.io/badge/LinkedIn-stephanvanrooij-blue?style=for-the-badge&logo=linkedin [badge_mastodon]: https://img.shields.io/mastodon/follow/109502876771613420?domain=https%3A%2F%2Fdotnet.social&label=%40svrooij%40dotnet.social&logo=mastodon&logoColor=white&style=for-the-badge diff --git a/src/WingetIntune/Implementations/AzCopyAzureUploader.cs b/src/WingetIntune/Implementations/AzCopyAzureUploader.cs index 2853b14..24749c4 100644 --- a/src/WingetIntune/Implementations/AzCopyAzureUploader.cs +++ b/src/WingetIntune/Implementations/AzCopyAzureUploader.cs @@ -27,7 +27,7 @@ private async Task DownloadAzCopyIfNeeded(CancellationToken cancellationToken) logger.LogInformation("Downloading AzCopy to {azCopyPath}", azCopyPath); var azCopyDownloadUrl = "https://aka.ms/downloadazcopy-v10-windows"; var downloadPath = Path.GetTempFileName(); - await fileManager.DownloadFileAsync(azCopyDownloadUrl, downloadPath, throwOnFailure: true, overrideFile: true, cancellationToken); + await fileManager.DownloadFileAsync(azCopyDownloadUrl, downloadPath, throwOnFailure: true, overrideFile: true, cancellationToken: cancellationToken); var extractFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); logger.LogInformation("Extracting AzCopy to {path}", extractFolder); @@ -41,7 +41,7 @@ private async Task DownloadAzCopyIfNeeded(CancellationToken cancellationToken) public async Task UploadFileToAzureAsync(string filename, Uri sasUri, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNullOrEmpty(filename); + ArgumentException.ThrowIfNullOrEmpty(filename); ArgumentNullException.ThrowIfNull(sasUri); await DownloadAzCopyIfNeeded(cancellationToken); var args = $"copy \"{filename}\" \"{sasUri}\" --output-type \"json\""; diff --git a/src/WingetIntune/Implementations/DefaultFileManager.cs b/src/WingetIntune/Implementations/DefaultFileManager.cs index 098974d..6e53ab1 100644 --- a/src/WingetIntune/Implementations/DefaultFileManager.cs +++ b/src/WingetIntune/Implementations/DefaultFileManager.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using System.Security.Cryptography; namespace WingetIntune.Os; @@ -47,7 +48,7 @@ public void DeleteFileOrFolder(string path) } } - public async Task DownloadFileAsync(string url, string path, bool throwOnFailure = true, bool overrideFile = false, CancellationToken cancellationToken = default) + public async Task DownloadFileAsync(string url, string path, string? expectedHash = null, bool throwOnFailure = true, bool overrideFile = false, CancellationToken cancellationToken = default) { if (overrideFile || !File.Exists(path)) { @@ -61,10 +62,39 @@ public async Task DownloadFileAsync(string url, string path, bool throwOnFailure } result.EnsureSuccessStatusCode(); var data = await result.Content.ReadAsByteArrayAsync(cancellationToken); + using var sha256 = SHA256.Create(); + using var stream = new MemoryStream(data); + var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken); + var hash = BitConverter.ToString(hashBytes).Replace("-", ""); + if (!string.IsNullOrEmpty(expectedHash)) + { + if (!hash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) + { + var ex = new CryptographicException($"Hash mismatch for {url}. Expected {expectedHash} but got {hash}"); + logger.LogError(ex, "Hash mismatch for {url}. Expected {expectedHash} but got {hash}", url, expectedHash, hash); + throw ex; + } + logger.LogInformation("Downloaded file {path} has hash '{hash}' as expected", url, hash); + } + await File.WriteAllBytesAsync(path, data, cancellationToken); } + else if (!string.IsNullOrEmpty(expectedHash)) + { + using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None, bufferSize: 4096, useAsync: true); + using var sha256 = SHA256.Create(); + var hashBytes = await sha256.ComputeHashAsync(fileStream, cancellationToken); + var hash = BitConverter.ToString(hashBytes).Replace("-", ""); + if (!hash.Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) + { + logger.LogWarning("Previously downloaded file {path} has hash {hash} but expected {expectedHash}. Deleting file and re-downloading", path, hash, expectedHash); + File.Delete(path); + await DownloadFileAsync(url, path, expectedHash, throwOnFailure, overrideFile, cancellationToken); + } + } else { + logger.LogInformation("Skipping download of {url} to {path} because the file already exists", url, path); } } diff --git a/src/WingetIntune/Interfaces/IFileManager.cs b/src/WingetIntune/Interfaces/IFileManager.cs index be2a8a2..7a8bedd 100644 --- a/src/WingetIntune/Interfaces/IFileManager.cs +++ b/src/WingetIntune/Interfaces/IFileManager.cs @@ -10,7 +10,7 @@ public interface IFileManager void DeleteFileOrFolder(string path); - Task DownloadFileAsync(string url, string path, bool throwOnFailure = true, bool overrideFile = false, CancellationToken cancellationToken = default); + Task DownloadFileAsync(string url, string path, string? expectedHash = null, bool throwOnFailure = true, bool overrideFile = false, CancellationToken cancellationToken = default); Task DownloadStringAsync(string url, bool throwOnFailure = true, CancellationToken cancellationToken = default); diff --git a/src/WingetIntune/Intune/IntuneManager.cs b/src/WingetIntune/Intune/IntuneManager.cs index b857526..6cb34ca 100644 --- a/src/WingetIntune/Intune/IntuneManager.cs +++ b/src/WingetIntune/Intune/IntuneManager.cs @@ -457,14 +457,14 @@ internal Task DownloadLogoAsync(string packageFolder, string packageId, Cancella var logoPath = Path.GetFullPath(Path.Combine(packageFolder, "..", "logo.png")); var logoUri = $"https://api.winstall.app/icons/{packageId}.png";//new Uri($"https://winget.azureedge.net/cache/icons/48x48/{packageId}.png"); LogDownloadLogo(logoUri); - return fileManager.DownloadFileAsync(logoUri, logoPath, throwOnFailure: false, overrideFile: false, cancellationToken); + return fileManager.DownloadFileAsync(logoUri, logoPath, throwOnFailure: false, overrideFile: false, cancellationToken: cancellationToken); } internal async Task DownloadInstallerAsync(string tempPackageFolder, PackageInfo packageInfo, CancellationToken cancellationToken) { var installerPath = Path.Combine(tempPackageFolder, packageInfo.InstallerFilename!); LogDownloadInstaller(packageInfo.InstallerUrl!, installerPath); - await fileManager.DownloadFileAsync(packageInfo.InstallerUrl!.ToString(), installerPath, throwOnFailure: true, overrideFile: false, cancellationToken); + await fileManager.DownloadFileAsync(packageInfo.InstallerUrl!.ToString(), installerPath, expectedHash: packageInfo.Installer!.InstallerSha256!, throwOnFailure: true, overrideFile: false, cancellationToken: cancellationToken); return installerPath; } diff --git a/tests/WingetIntune.Tests/AzCopyAzureUploaderTests.cs b/tests/WingetIntune.Tests/AzCopyAzureUploaderTests.cs index 1a08a8f..1472549 100644 --- a/tests/WingetIntune.Tests/AzCopyAzureUploaderTests.cs +++ b/tests/WingetIntune.Tests/AzCopyAzureUploaderTests.cs @@ -69,8 +69,8 @@ public async Task UploadAsync_DownloadsAzCopy() .Returns(false) .Verifiable(); - fileManagerMock.Setup(fileManagerMock => fileManagerMock.DownloadFileAsync("https://aka.ms/downloadazcopy-v10-windows", It.IsAny(), true, true, It.IsAny())) - .Returns(Task.CompletedTask) + fileManagerMock.Setup(fileManagerMock => fileManagerMock.DownloadFileAsync("https://aka.ms/downloadazcopy-v10-windows", It.IsAny(), null, true, true, It.IsAny())) + .Returns(Task.FromResult(null)) .Verifiable(); fileManagerMock.Setup(fileManagerMock => fileManagerMock.ExtractFileToFolder(It.IsAny(), It.IsAny())).Verifiable(); diff --git a/tests/WingetIntune.Tests/Intune/IntuneManagerTests.cs b/tests/WingetIntune.Tests/Intune/IntuneManagerTests.cs index f9703c8..8555cd8 100644 --- a/tests/WingetIntune.Tests/Intune/IntuneManagerTests.cs +++ b/tests/WingetIntune.Tests/Intune/IntuneManagerTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging.Abstractions; using System.Runtime.InteropServices; +using Winget.CommunityRepository.Models; using WingetIntune.Interfaces; using WingetIntune.Models; @@ -35,10 +36,10 @@ public async Task GenerateInstallerPackage_MsiPackage_Returns() var fileManagerMock = new Mock(MockBehavior.Strict); fileManagerMock.Setup(x => x.CreateFolderForPackage(tempFolder, packageId, version)).Returns(Path.Combine(tempFolder, packageId, version)).Verifiable(); fileManagerMock.Setup(x => x.CreateFolderForPackage(outputFolder, packageId, version)).Returns(Path.Combine(outputFolder, packageId, version)).Verifiable(); - fileManagerMock.Setup(x => x.DownloadFileAsync(installer.InstallerUrl!.ToString(), installerPath, true, false, It.IsAny())) + fileManagerMock.Setup(x => x.DownloadFileAsync(installer.InstallerUrl!.ToString(), installerPath, null, true, false, It.IsAny())) .Returns(Task.CompletedTask) .Verifiable(); - fileManagerMock.Setup(x => x.DownloadFileAsync($"https://api.winstall.app/icons/{packageId}.png", logoPath, false, false, It.IsAny())) + fileManagerMock.Setup(x => x.DownloadFileAsync($"https://api.winstall.app/icons/{packageId}.png", logoPath, null, false, false, It.IsAny())) .Returns(Task.CompletedTask) .Verifiable(); @@ -99,7 +100,7 @@ public async Task DownloadLogoAsync_CallsFilemanager() var logoPath = Path.GetFullPath(Path.Combine(folder, "..", "logo.png")); var fileManagerMock = new Mock(); - fileManagerMock.Setup(x => x.DownloadFileAsync($"https://api.winstall.app/icons/{packageId}.png", logoPath, false, false, It.IsAny())) + fileManagerMock.Setup(x => x.DownloadFileAsync($"https://api.winstall.app/icons/{packageId}.png", logoPath, null, false, false, It.IsAny())) .Returns(Task.CompletedTask) .Verifiable(); @@ -114,18 +115,24 @@ public async Task DownloadInstallerAsync_CallsFilemanager() { var packageId = "Microsoft.AzureCLI"; var version = "2.26.1"; + var hash = "1234567890"; var folder = Path.Combine(Path.GetTempPath(), "intunewin", packageId, version); var packageInfo = new PackageInfo { InstallerFilename = "testpackage.exe", - InstallerUrl = new Uri("https://localhost/testpackage.exe") + InstallerUrl = new Uri("https://localhost/testpackage.exe"), + Installer = new WingetInstaller + { + InstallerType = "exe", + InstallerSha256 = hash + } }; var installerPath = Path.GetFullPath(Path.Combine(folder, packageInfo.InstallerFilename)); var fileManagerMock = new Mock(); - fileManagerMock.Setup(x => x.DownloadFileAsync(packageInfo.InstallerUrl.ToString(), installerPath, true, false, It.IsAny())) + fileManagerMock.Setup(x => x.DownloadFileAsync(packageInfo.InstallerUrl.ToString(), installerPath, hash, true, false, It.IsAny())) .Returns(Task.CompletedTask) .Verifiable(); diff --git a/tests/WingetIntune.Tests/Intune/ProcessIntunePackagerTests.cs b/tests/WingetIntune.Tests/Intune/ProcessIntunePackagerTests.cs index 3cd6320..ac79264 100644 --- a/tests/WingetIntune.Tests/Intune/ProcessIntunePackagerTests.cs +++ b/tests/WingetIntune.Tests/Intune/ProcessIntunePackagerTests.cs @@ -22,7 +22,7 @@ public async Task CreatePackage_DownloadsTool() var cancellationToken = new CancellationToken(); fileManagerMock.Setup(x => x.FileExists(toolPath)).Returns(false); - fileManagerMock.Setup(x => x.DownloadFileAsync(ProcessIntunePackager.IntuneWinAppUtilUrl, toolPath, true, false, cancellationToken)) + fileManagerMock.Setup(x => x.DownloadFileAsync(ProcessIntunePackager.IntuneWinAppUtilUrl, toolPath, null, true, false, cancellationToken)) .Returns(Task.CompletedTask).Verifiable(); processManagerMock.Setup(x => x.RunProcessAsync(toolPath, It.IsAny(), cancellationToken, false))