Skip to content

Commit

Permalink
feat: Checking the installer hash before packaging
Browse files Browse the repository at this point in the history
  • Loading branch information
svrooij committed Oct 4, 2023
1 parent b8dd5a4 commit d8f8233
Show file tree
Hide file tree
Showing 8 changed files with 57 additions and 15 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/WingetIntune/Implementations/AzCopyAzureUploader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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\"";
Expand Down
32 changes: 31 additions & 1 deletion src/WingetIntune/Implementations/DefaultFileManager.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;

namespace WingetIntune.Os;

Expand Down Expand Up @@ -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))
{
Expand All @@ -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);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/WingetIntune/Interfaces/IFileManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string?> DownloadStringAsync(string url, bool throwOnFailure = true, CancellationToken cancellationToken = default);

Expand Down
4 changes: 2 additions & 2 deletions src/WingetIntune/Intune/IntuneManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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;
}

Expand Down
4 changes: 2 additions & 2 deletions tests/WingetIntune.Tests/AzCopyAzureUploaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(), true, true, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask)
fileManagerMock.Setup(fileManagerMock => fileManagerMock.DownloadFileAsync("https://aka.ms/downloadazcopy-v10-windows", It.IsAny<string>(), null, true, true, It.IsAny<CancellationToken>()))
.Returns(Task.FromResult<string?>(null))
.Verifiable();

fileManagerMock.Setup(fileManagerMock => fileManagerMock.ExtractFileToFolder(It.IsAny<string>(), It.IsAny<string>())).Verifiable();
Expand Down
17 changes: 12 additions & 5 deletions tests/WingetIntune.Tests/Intune/IntuneManagerTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using System.Runtime.InteropServices;
using Winget.CommunityRepository.Models;
using WingetIntune.Interfaces;
using WingetIntune.Models;

Expand Down Expand Up @@ -35,10 +36,10 @@ public async Task GenerateInstallerPackage_MsiPackage_Returns()
var fileManagerMock = new Mock<IFileManager>(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<CancellationToken>()))
fileManagerMock.Setup(x => x.DownloadFileAsync(installer.InstallerUrl!.ToString(), installerPath, null, true, false, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask)
.Verifiable();
fileManagerMock.Setup(x => x.DownloadFileAsync($"https://api.winstall.app/icons/{packageId}.png", logoPath, false, false, It.IsAny<CancellationToken>()))
fileManagerMock.Setup(x => x.DownloadFileAsync($"https://api.winstall.app/icons/{packageId}.png", logoPath, null, false, false, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask)
.Verifiable();

Expand Down Expand Up @@ -99,7 +100,7 @@ public async Task DownloadLogoAsync_CallsFilemanager()
var logoPath = Path.GetFullPath(Path.Combine(folder, "..", "logo.png"));

var fileManagerMock = new Mock<IFileManager>();
fileManagerMock.Setup(x => x.DownloadFileAsync($"https://api.winstall.app/icons/{packageId}.png", logoPath, false, false, It.IsAny<CancellationToken>()))
fileManagerMock.Setup(x => x.DownloadFileAsync($"https://api.winstall.app/icons/{packageId}.png", logoPath, null, false, false, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask)
.Verifiable();

Expand All @@ -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<IFileManager>();
fileManagerMock.Setup(x => x.DownloadFileAsync(packageInfo.InstallerUrl.ToString(), installerPath, true, false, It.IsAny<CancellationToken>()))
fileManagerMock.Setup(x => x.DownloadFileAsync(packageInfo.InstallerUrl.ToString(), installerPath, hash, true, false, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask)
.Verifiable();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(), cancellationToken, false))
Expand Down

0 comments on commit d8f8233

Please sign in to comment.