From d6f868ecef0e801cd5686cf71ad7455ce8078da3 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 28 Sep 2026 00:44:03 -0400 Subject: [PATCH] migrate to the revamped malware blacklist This commit: - moves the malware blacklist into the malware scanner data repo; - optimizes the format to simplify editing (e.g. to allow multiple IDs/hashes per entry); - syncs it automatically to the web server; - and allows SMAPI to launch without the blacklist (in which case it'll resync automatically). --- .gitignore | 2 + build/deploy-local-smapi.targets | 1 - build/scripts/prepare-install-package.ps1 | 25 +- docs/release-notes.md | 2 +- docs/technical/web.md | 3 +- .../Toolkit/MalwareBlacklistConverterTests.cs | 231 ++++++++++++++++++ .../LooseFileBlacklistEntryModel.cs | 2 +- .../MalwareBlacklistConverter.cs | 119 +++++++++ .../ModBlacklistData/MalwareEntryModel.cs | 27 ++ .../ModBlacklistData/MalwareListModel.cs | 39 +++ .../MalwareLooseFileMatchModel.cs | 32 +++ .../ModBlacklistData/MalwareModMatchModel.cs | 27 ++ .../ModBlacklistEntryModel.cs | 2 +- src/SMAPI.Web/BackgroundService.cs | 79 ++++++ .../Framework/Clients/GitHub/GitFile.cs | 42 ++++ .../Framework/Clients/GitHub/GitHubClient.cs | 61 ++++- .../Framework/Clients/GitHub/IGitHubClient.cs | 8 + .../ConfigModels/MalwareBlacklistConfig.cs | 17 ++ src/SMAPI.Web/SMAPI.Web.csproj | 1 - src/SMAPI.Web/Startup.cs | 44 ++-- src/SMAPI.Web/appsettings.json | 6 + src/SMAPI.Web/wwwroot/SMAPI.blacklist.json | 64 ----- src/SMAPI/Framework/SCore.cs | 18 +- src/SMAPI/SMAPI.csproj | 1 - 24 files changed, 752 insertions(+), 101 deletions(-) create mode 100644 src/SMAPI.Tests/Toolkit/MalwareBlacklistConverterTests.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareBlacklistConverter.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareEntryModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareListModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareLooseFileMatchModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareModMatchModel.cs create mode 100644 src/SMAPI.Web/Framework/Clients/GitHub/GitFile.cs create mode 100644 src/SMAPI.Web/Framework/ConfigModels/MalwareBlacklistConfig.cs delete mode 100644 src/SMAPI.Web/wwwroot/SMAPI.blacklist.json diff --git a/.gitignore b/.gitignore index 7bf9b226..7e8315e5 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ appsettings.Development.json # auto-generated files src/SMAPI.Web/wwwroot/Content/data +src/SMAPI.Web/wwwroot/SMAPI.blacklist.json +src/SMAPI.Web/wwwroot/SMAPI.blacklist.json.tmp src/SMAPI.Web/Properties/PublishProfiles src/SMAPI.Web/Properties/ServiceDependencies diff --git a/build/deploy-local-smapi.targets b/build/deploy-local-smapi.targets index 14c4d816..6394d707 100644 --- a/build/deploy-local-smapi.targets +++ b/build/deploy-local-smapi.targets @@ -19,7 +19,6 @@ This assumes `find-game-folder.targets` has already been imported and validated. - diff --git a/build/scripts/prepare-install-package.ps1 b/build/scripts/prepare-install-package.ps1 index ee407397..4de7ba56 100644 --- a/build/scripts/prepare-install-package.ps1 +++ b/build/scripts/prepare-install-package.ps1 @@ -186,6 +186,22 @@ foreach ($name in @("install on Linux.sh", "install on macOS.command", "install Copy-Item "$installAssets/$name" "$packagePath" } +# fetch 'malicious mods' blacklist +# SMAPI fetches it from the same URL automatically, but this avoids known malicious mods being loaded on first launch. +Write-Host "Fetching mod blacklist..." +$blacklistUrl = "https://smapi.io/SMAPI.blacklist.json" # same as BlacklistUrl in SMAPI.config.json +$blacklistPath = [System.IO.Path]::GetTempFileName() +try { + Invoke-WebRequest -Uri "$blacklistUrl" -OutFile "$blacklistPath" -TimeoutSec 30 -MaximumRetryCount 2 -RetryIntervalSec 5 + $blacklistData = Get-Content "$blacklistPath" -Raw | ConvertFrom-Json + Write-Host " Fetched blacklist with $($blacklistData.Blacklist.Count) mod entries and $($blacklistData.LooseFileBlacklist.Count) loose file entries." +} +catch { + Write-Warning "Couldn't fetch the mod blacklist from $blacklistUrl, so it'll be omitted from the release. SMAPI will fetch the latest blacklist when launched, but it won't take effect until the next launch. Error: $($_.Exception.Message)" + Remove-Item "$blacklistPath" + $blacklistPath = $null +} + # copy per-platform files foreach ($folder in $folders) { $runtime = $runtimes[$folder] @@ -231,7 +247,9 @@ foreach ($folder in $folders) { Copy-Item "$smapiBin/VdfConverter.dll" "$bundlePath/smapi-internal" } - Copy-Item "$smapiBin/SMAPI.blacklist.json" "$bundlePath/smapi-internal/blacklist.json" + if ($blacklistPath) { + Copy-Item "$blacklistPath" "$bundlePath/smapi-internal/blacklist.json" + } Copy-Item "$smapiBin/SMAPI.config.json" "$bundlePath/smapi-internal/config.json" Copy-Item "$smapiBin/SMAPI.metadata.json" "$bundlePath/smapi-internal/metadata.json" if ($folder -eq "linux" -or $folder -eq "macOS") { @@ -263,6 +281,11 @@ foreach ($folder in $folders) { } } +# remove temporary files +if ($blacklistPath) { + Remove-Item "$blacklistPath" +} + # mark scripts executable Write-Host "Setting file permissions..." if ($IsWindows) { diff --git a/docs/release-notes.md b/docs/release-notes.md index 42117376..f5e8fd42 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -6,8 +6,8 @@ * Improved performance. * Improved error message when a mod is blocked by Windows Smart App Control. * Improved translations. Thanks to To2morrow (updated Korean)! + * Migrated to the revamped malware blacklist. * Fixed rare edge case where a mod blacklist update could fail. - * Updated internal mod blacklist. * For mod authors: * Added OS metrics to the [metrics API](technical/web.md#modsmetrics). diff --git a/docs/technical/web.md b/docs/technical/web.md index eb3d1ae4..d3b8a99e 100644 --- a/docs/technical/web.md +++ b/docs/technical/web.md @@ -374,7 +374,7 @@ Initial setup: property name | description ------------------------------- | ----------------- `ApiClients.AzureBlobConnectionString` | The connection string for the Azure Blob storage account created in step 2. - `ApiClients.GitHubUsername`
`ApiClients.GitHubPassword` | The login credentials for the GitHub account with which to fetch release info. If these are omitted, GitHub will impose much stricter rate limits. + `ApiClients.GitHubUsername`
`ApiClients.GitHubPassword` | The login credentials for the GitHub account with which to fetch release info and the malware blacklist. If these are omitted, GitHub will impose much stricter rate limits, and the malware blacklist won't be synced from its private repo. `ApiClients:NexusApiKey` | The [Nexus API authentication key](https://github.com/Pathoschild/FluentNexus#init-a-client). Optional settings: @@ -382,6 +382,7 @@ Initial setup: property name | description ------------------------------- | ----------------- `BackgroundServices:Enabled` | Set to `true` to enable background processes like fetching data from the wiki, or false to disable them. + `MalwareBlacklist:GitHubRepo`
`MalwareBlacklist:GitRef`
`MalwareBlacklist:FilePath` | The GitHub repository, branch, and file path from which to sync the malware blacklist. Set `GitHubRepo` to `null` to disable syncing. `Site:OtherBlurb` | A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format. `Site:SupporterList` | A list of Patreon supports to credit on the download page. diff --git a/src/SMAPI.Tests/Toolkit/MalwareBlacklistConverterTests.cs b/src/SMAPI.Tests/Toolkit/MalwareBlacklistConverterTests.cs new file mode 100644 index 00000000..7183f5d8 --- /dev/null +++ b/src/SMAPI.Tests/Toolkit/MalwareBlacklistConverterTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using FluentAssertions; +using NUnit.Framework; +using StardewModdingAPI.Toolkit.Framework.ModBlacklistData; + +namespace SMAPI.Tests.Toolkit; + +/// Unit tests for . +[TestFixture] +internal class MalwareBlacklistConverterTests +{ + /********* + ** Fields + *********/ + /// The default message key to test. + private const string MessageKey = "RemoteCode"; + + + /********* + ** Unit tests + *********/ + /**** + ** Conversion + ****/ + [Test(Description = "Assert that a valid blacklist is converted into the expected public JSON.")] + public void Conversion_ProducesExpectedPublicJson() + { + // arrange + const string json = """ + { + "$schema": "https://example.org/schema.json", + "Format": 1, + "Messages": { + "RemoteCode": "Remote code message." + }, + "Mods": [ + { + "Added": "2026-01-01", + "Notes": "ID only", + "Match": { + "Id": [ "Author.IdOnly" ] + }, + "Message": "RemoteCode" + }, + { + "Added": "2026-01-02", + "Notes": null, + "Match": { + "Id": [ "Author.A", "Author.B" ], + "EntryDllMd5Hash": [ "00000000000000000000000000000001", "00000000000000000000000000000002" ] + }, + "Message": "A literal message." + }, + { + "Added": "2026-01-03", + "Notes": "hash only", + "Match": { + "EntryDllMd5Hash": [ "00000000000000000000000000000003" ] + }, + "Message": "RemoteCode" + } + ], + "LooseFiles": [ + { + "Added": "2026-01-04", + "Notes": "name + hash", + "Match": { + "Name": [ "example.bat" ], + "Md5Hash": [ "00000000000000000000000000000004" ] + }, + "Message": "RemoteCode" + }, + { + "Added": "2026-01-05", + "Notes": "extension only", + "Match": { + "Extension": [ ".scr" ] + }, + "Message": "RemoteCode" + } + ] + } + """; + + // act + string result = this.Convert(json); + + // assert + result.Should().Be( + """{""" + + "\"Blacklist\":[" + + "{\"Id\":\"Author.IdOnly\",\"Message\":\"Remote code message.\"}," + + "{\"Id\":\"Author.A\",\"EntryDllHash\":\"00000000000000000000000000000001\",\"Message\":\"A literal message.\"}," + + "{\"Id\":\"Author.A\",\"EntryDllHash\":\"00000000000000000000000000000002\",\"Message\":\"A literal message.\"}," + + "{\"Id\":\"Author.B\",\"EntryDllHash\":\"00000000000000000000000000000001\",\"Message\":\"A literal message.\"}," + + "{\"Id\":\"Author.B\",\"EntryDllHash\":\"00000000000000000000000000000002\",\"Message\":\"A literal message.\"}," + + "{\"EntryDllHash\":\"00000000000000000000000000000003\",\"Message\":\"Remote code message.\"}" + + "]," + + "\"LooseFileBlacklist\":[" + + "{\"Name\":\"example.bat\",\"Hash\":\"00000000000000000000000000000004\",\"Message\":\"Remote code message.\"}," + + "{\"Extension\":\".scr\",\"Message\":\"Remote code message.\"}" + + "]" + + "}" + ); + } + + [Test(Description = "Assert that the converted public JSON can be read by SMAPI's blacklist logic.")] + public void Conversion_CanBeParsedByModBlacklist() + { + // arrange + const string message = "This mod is silly & \"\"."; // special characters + string json = this.GetValidBlacklistJson( + mod: new MalwareEntryModel( + match: new MalwareModMatchModel(id: ["Author.Mod"], entryDllMd5Hash: []), + message: message + ) + ); + + // act + string result = this.Convert(json); + ModBlacklistModel? model = Newtonsoft.Json.JsonConvert.DeserializeObject(result); + ModBlacklist blacklist = new(model!); + + // assert + blacklist.CheckMod("author.mod", entryDllPath: null).Should().NotBeNull(); // should be case-insensitive + blacklist.CheckMod("author.mod", entryDllPath: null)!.Message.Should().Be(message); + blacklist.CheckMod("Author.OtherMod", entryDllPath: null).Should().BeNull(); + } + + /**** + ** Validation + ****/ + [Test(Description = "Assert that a blacklist with an unsupported format version is rejected.")] + public void Conversion_RejectsUnsupportedFormat() + { + // arrange + string json = this.GetValidBlacklistJson(format: 2); + + // act + Action act = () => this.Convert(json); + + // assert + act.Should().Throw().WithMessage("*unsupported format version 2*"); + } + + [Test(Description = "Assert that a mod entry with no match conditions is rejected, since it would block every mod.")] + [TestCase("null")] + [TestCase("{}")] + [TestCase("""{ "Md5Hash": [ "00000000000000000000000000000000" ] }""")] // mod with loose file fields + [TestCase("""{ "Id": [] }""")] + [TestCase("""{ "Id": [], "EntryDllMd5Hash": [] }""")] + public void Convert_RejectsModEntryWithNoConditions(string match) + { + // arrange + string json = this.GetValidBlacklistJson( + mod: new MalwareEntryModel( + match: JsonSerializer.Deserialize(match), + message: MessageKey + ) + ); + + // act + Action act = () => this.Convert(json); + + // assert + act.Should().Throw().WithMessage("*avoid blocking every mod*"); + } + + [Test(Description = "Assert that a loose file entry with no match conditions is rejected, since it would block every file.")] + [TestCase("null")] + [TestCase("{}")] + [TestCase("""{ "Id": [ "Author.Mod" ] }""")] // loose file with mod fields + [TestCase("""{ "Name": [], "Extension": [], "Md5Hash": [] }""")] + public void Convert_RejectsLooseFileEntryWithNoConditions(string match) + { + // arrange + string json = this.GetValidBlacklistJson( + looseFile: new MalwareEntryModel( + match: JsonSerializer.Deserialize(match), + message: MessageKey + ) + ); + + // act + Action act = () => this.Convert(json); + + // assert + act.Should().Throw().WithMessage("*avoid blocking every file*"); + } + + + /********* + ** Private methods + *********/ + /// Parse a malware blacklist and convert it into the public JSON. + /// The malware blacklist JSON. + private string Convert(string json) + { + MalwareListModel blacklist = MalwareBlacklistConverter.FromInternalFormat(json); + ModBlacklistModel publicModel = MalwareBlacklistConverter.ToPublicModel(blacklist); + return MalwareBlacklistConverter.ToPublicJson(publicModel); + } + + /// Get the JSON for a malware blacklist with a single mod entry. + /// The format version. + /// The mod entry to blacklist, or null for a default entry which matches Author.Mod. + /// The loose file to blacklist, or null for an empty loose files list. + private string GetValidBlacklistJson(int format = 1, MalwareEntryModel? mod = null, MalwareEntryModel? looseFile = null) + { + return JsonSerializer.Serialize( + new MalwareListModel( + format: format, + messages: new Dictionary + { + [MessageKey] = "Remote code message" + }, + mods: [ + mod ?? new MalwareEntryModel( + match: new MalwareModMatchModel(id: ["Author.Mod"], entryDllMd5Hash: []), + message: MessageKey + ) + ], + looseFiles: looseFile != null + ? [looseFile] + : [] + ) + ); + } +} diff --git a/src/SMAPI.Toolkit/Framework/ModBlacklistData/LooseFileBlacklistEntryModel.cs b/src/SMAPI.Toolkit/Framework/ModBlacklistData/LooseFileBlacklistEntryModel.cs index bc437574..2de1f5de 100644 --- a/src/SMAPI.Toolkit/Framework/ModBlacklistData/LooseFileBlacklistEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/ModBlacklistData/LooseFileBlacklistEntryModel.cs @@ -27,7 +27,7 @@ public class LooseFileBlacklistEntryModel /// /// /// - public LooseFileBlacklistEntryModel(string? name, string? extension, string? hash, string message) + public LooseFileBlacklistEntryModel(string? name, string? extension, string? hash, string? message) { this.Name = name; this.Extension = extension; diff --git a/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareBlacklistConverter.cs b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareBlacklistConverter.cs new file mode 100644 index 00000000..4548e4e1 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareBlacklistConverter.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData; + +/// Converts the internal format into the public format used by SMAPI. +[SuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "This isn't an issue here, since all fields are referenced in code.")] +public static class MalwareBlacklistConverter +{ + /********* + ** Accessors + *********/ + /// The version supported by the converter. + public const int SupportedFormat = 1; + + + /********* + ** Public methods + *********/ + /// Parse the raw JSON for a malware blacklist. + /// The raw JSON text. + /// The JSON can't be parsed. + /// The file is empty, or uses an unsupported format version. + public static MalwareListModel FromInternalFormat(string json) + { + MalwareListModel blacklist = + JsonSerializer.Deserialize(json) + ?? throw new FormatException("Can't parse the malware blacklist: the file is empty."); + + if (blacklist.Format != MalwareBlacklistConverter.SupportedFormat) + throw new FormatException($"Can't parse the malware blacklist: unsupported format version {blacklist.Format} (expected {MalwareBlacklistConverter.SupportedFormat})."); + + return blacklist; + } + + /// Convert a malware blacklist into the public format used by SMAPI. + /// The malware blacklist to convert. + /// An entry has invalid match conditions. + public static ModBlacklistModel ToPublicModel(MalwareListModel blacklist) + { + List mods = []; + foreach (MalwareEntryModel entry in blacklist.Mods ?? []) + { + MalwareModMatchModel? match = entry.Match; + if (match?.Id is not { Length: > 0 } && match?.EntryDllMd5Hash is not { Length: > 0 }) + throw new FormatException("Can't convert the malware blacklist: each mod entry must have match conditions to avoid blocking every mod."); + + string? message = MalwareBlacklistConverter.GetMessageText(blacklist, entry.Message); + + foreach (string? id in MalwareBlacklistConverter.GetPermutationValues(match.Id)) + { + foreach (string? hash in MalwareBlacklistConverter.GetPermutationValues(match.EntryDllMd5Hash)) + mods.Add(new ModBlacklistEntryModel(id, hash, message)); + } + } + + List looseFiles = []; + foreach (MalwareEntryModel entry in blacklist.LooseFiles ?? []) + { + MalwareLooseFileMatchModel? match = entry.Match; + if (match?.Name is not { Length: > 0 } && match?.Extension is not { Length: > 0 } && match?.Md5Hash is not { Length: > 0 }) + throw new FormatException("Can't convert the malware blacklist: each loose file entry must have match conditions to avoid blocking every file."); + + string? message = MalwareBlacklistConverter.GetMessageText(blacklist, entry.Message); + + foreach (string? name in MalwareBlacklistConverter.GetPermutationValues(match.Name)) + { + foreach (string? extension in MalwareBlacklistConverter.GetPermutationValues(match.Extension)) + { + foreach (string? hash in MalwareBlacklistConverter.GetPermutationValues(match.Md5Hash)) + looseFiles.Add(new LooseFileBlacklistEntryModel(name, extension, hash, message)); + } + } + } + + return new ModBlacklistModel(mods.ToArray(), looseFiles.ToArray()); + } + + /// Serialize a public blacklist into its JSON representation. + /// The public blacklist to serialize. + public static string ToPublicJson(ModBlacklistModel blacklist) + { + var options = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + return JsonSerializer.Serialize(blacklist, options); + } + + + /********* + ** Private methods + *********/ + /// Get the message text for an entry's Message field. + /// The malware blacklist containing the entry. + /// The entry's message key or literal message text. + private static string? GetMessageText(MalwareListModel blacklist, string? message) + { + return message != null && blacklist.Messages?.TryGetValue(message, out string? text) == true + ? text + : message; + } + + /// Get the values in a match field, or a single null if the field was omitted. + /// The match values. + private static string?[] GetPermutationValues(string[]? values) + { + if (values?.Length > 0) + return values; + + return [null]; + } +} diff --git a/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareEntryModel.cs b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareEntryModel.cs new file mode 100644 index 00000000..eaa96e37 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareEntryModel.cs @@ -0,0 +1,27 @@ +namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData; + +/// An entry in the . +public class MalwareEntryModel +{ + /********* + ** Accessors + *********/ + /// The conditions which identify the mod or loose file. + public TMatch? Match { get; } + + /// A key in the field, or the literal message text to show when the entry matches. + public string? Message { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// + /// + public MalwareEntryModel(TMatch? match, string? message) + { + this.Match = match; + this.Message = message; + } +} diff --git a/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareListModel.cs b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareListModel.cs new file mode 100644 index 00000000..216102b2 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareListModel.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData; + +/// The internal blacklist which tracks known malware. +public class MalwareListModel +{ + /********* + ** Accessors + *********/ + /// The format version. + public int Format { get; } + + /// The reusable messages which can be shown when a mod is blocked, indexed by the key which entries reference in their Message field. + public Dictionary? Messages { get; } + + /// The mods which are known to be malicious. + public MalwareEntryModel[]? Mods { get; } + + /// The individual files which are known to be malicious. + public MalwareEntryModel[]? LooseFiles { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// + /// + /// + /// + public MalwareListModel(int format, Dictionary? messages, MalwareEntryModel[]? mods, MalwareEntryModel[]? looseFiles) + { + this.Format = format; + this.Messages = messages; + this.Mods = mods; + this.LooseFiles = looseFiles; + } +} diff --git a/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareLooseFileMatchModel.cs b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareLooseFileMatchModel.cs new file mode 100644 index 00000000..9f8ae321 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareLooseFileMatchModel.cs @@ -0,0 +1,32 @@ +namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData; + +/// The fields which identify a loose file in the . +public class MalwareLooseFileMatchModel +{ + /********* + ** Accessors + *********/ + /// The file names to match (including the extension), or null to match any file name. + public string[]? Name { get; } + + /// The file extensions to match (including the dot), or null to match any file extension. + public string[]? Extension { get; } + + /// The MD5 hashes of the file to match, or null to match any hash. + public string[]? Md5Hash { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// + /// + /// + public MalwareLooseFileMatchModel(string[]? name, string[]? extension, string[]? md5Hash) + { + this.Name = name; + this.Extension = extension; + this.Md5Hash = md5Hash; + } +} diff --git a/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareModMatchModel.cs b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareModMatchModel.cs new file mode 100644 index 00000000..0f6ada4f --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ModBlacklistData/MalwareModMatchModel.cs @@ -0,0 +1,27 @@ +namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData; + +/// The fields which identify a mod in the . +public class MalwareModMatchModel +{ + /********* + ** Accessors + *********/ + /// The manifest UniqueID values to match, or null to match any ID. + public string[]? Id { get; } + + /// The MD5 hashes of the mod's entry DLL to match, or null to match any hash. + public string[]? EntryDllMd5Hash { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// + /// + public MalwareModMatchModel(string[]? id, string[]? entryDllMd5Hash) + { + this.Id = id; + this.EntryDllMd5Hash = entryDllMd5Hash; + } +} diff --git a/src/SMAPI.Toolkit/Framework/ModBlacklistData/ModBlacklistEntryModel.cs b/src/SMAPI.Toolkit/Framework/ModBlacklistData/ModBlacklistEntryModel.cs index bf976954..1decc9f4 100644 --- a/src/SMAPI.Toolkit/Framework/ModBlacklistData/ModBlacklistEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/ModBlacklistData/ModBlacklistEntryModel.cs @@ -25,7 +25,7 @@ public class ModBlacklistEntryModel /// /// /// - public ModBlacklistEntryModel(string? id, string? entryDllHash, string message) + public ModBlacklistEntryModel(string? id, string? entryDllHash, string? message) { this.Id = id; this.EntryDllHash = entryDllHash; diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index be1d3ef9..ebe69ba6 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using Hangfire; @@ -16,6 +17,7 @@ using StardewModdingAPI.Toolkit.Framework.Clients.CompatibilityRepo; using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport; using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; +using StardewModdingAPI.Toolkit.Framework.ModBlacklistData; using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.CompatibilityRepo; using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport; @@ -24,6 +26,7 @@ using StardewModdingAPI.Web.Framework.Caching.ModDropExport; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.NexusExport; using StardewModdingAPI.Web.Framework.Clients.CurseForge; +using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.ConfigModels; @@ -52,6 +55,9 @@ internal class BackgroundService : IHostedService, IDisposable /// The cache in which to store the mod data from the CurseForge export API. private static ICurseForgeExportCacheRepository? CurseForgeExportCache; + /// The HTTP client for fetching data from GitHub. + private static IGitHubClient? GitHubClient; + /// The HTTP client for fetching the mod export from the ModDrop export API. private static IModDropExportApiClient? ModDropExportApiClient; @@ -70,6 +76,12 @@ internal class BackgroundService : IHostedService, IDisposable /// The mod dataset repository. private static IModDatasetRepository? ModDatasetRepo; + /// The config settings for syncing the malware blacklist. + private static IOptions? MalwareBlacklistConfig; + + /// The HTTP ETag header for the last fetched malware blacklist file, if any. + private static string? LastMalwareBlacklistETag; + /// The web root path for writing static data files. private static string? WebRootPath; @@ -80,11 +92,13 @@ internal class BackgroundService : IHostedService, IDisposable nameof(BackgroundService.CompatibilityCache), nameof(BackgroundService.CurseForgeExportApiClient), nameof(BackgroundService.CurseForgeExportCache), + nameof(BackgroundService.GitHubClient), nameof(BackgroundService.ModDropExportApiClient), nameof(BackgroundService.ModDropExportCache), nameof(BackgroundService.NexusExportApiClient), nameof(BackgroundService.NexusExportCache), nameof(BackgroundService.ModDatasetRepo), + nameof(BackgroundService.MalwareBlacklistConfig), nameof(BackgroundService.UpdateCheckConfig), nameof(BackgroundService.WebRootPath) )] @@ -105,12 +119,14 @@ internal class BackgroundService : IHostedService, IDisposable /// /// /// + /// /// /// /// /// /// /// The Hangfire storage implementation. + /// /// /// The web host environment metadata. [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")] @@ -119,12 +135,14 @@ internal class BackgroundService : IHostedService, IDisposable IModCacheRepository modCache, ICurseForgeExportCacheRepository curseForgeExportCache, ICurseForgeExportApiClient curseForgeExportApiClient, + IGitHubClient gitHubClient, IModDropExportCacheRepository modDropExportCache, IModDropExportApiClient modDropExportApiClient, INexusExportCacheRepository nexusExportCache, INexusExportApiClient nexusExportApiClient, IModDatasetRepository modDatasetRepo, JobStorage hangfireStorage, + IOptions malwareBlacklistConfig, IOptions updateCheckConfig, IWebHostEnvironment hostEnvironment ) @@ -133,11 +151,13 @@ internal class BackgroundService : IHostedService, IDisposable BackgroundService.ModCache = modCache; BackgroundService.CurseForgeExportApiClient = curseForgeExportApiClient; BackgroundService.CurseForgeExportCache = curseForgeExportCache; + BackgroundService.GitHubClient = gitHubClient; BackgroundService.ModDropExportApiClient = modDropExportApiClient; BackgroundService.ModDropExportCache = modDropExportCache; BackgroundService.NexusExportCache = nexusExportCache; BackgroundService.NexusExportApiClient = nexusExportApiClient; BackgroundService.ModDatasetRepo = modDatasetRepo; + BackgroundService.MalwareBlacklistConfig = malwareBlacklistConfig; BackgroundService.UpdateCheckConfig = updateCheckConfig; BackgroundService.WebRootPath = hostEnvironment.WebRootPath; @@ -153,6 +173,7 @@ internal class BackgroundService : IHostedService, IDisposable bool enableCurseForgeExport = BackgroundService.CurseForgeExportApiClient is not DisabledCurseForgeExportApiClient; bool enableModDropExport = BackgroundService.ModDropExportApiClient is not DisabledModDropExportApiClient; bool enableNexusExport = BackgroundService.NexusExportApiClient is not DisabledNexusExportApiClient; + bool enableMalwareBlacklist = !string.IsNullOrWhiteSpace(BackgroundService.MalwareBlacklistConfig?.Value.GitHubRepo); // set startup tasks BackgroundJob.Enqueue(() => BackgroundService.UpdateCompatibilityListAsync(null)); @@ -164,6 +185,8 @@ internal class BackgroundService : IHostedService, IDisposable BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync(null)); BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync()); BackgroundJob.Enqueue(() => BackgroundService.UpdateModDatasetAsync(null)); + if (enableMalwareBlacklist) + BackgroundJob.Enqueue(() => BackgroundService.UpdateMalwareBlacklistAsync(null)); // set recurring tasks RecurringJob.AddOrUpdate("update compatibility list", () => BackgroundService.UpdateCompatibilityListAsync(null), "*/10 * * * *"); // every 10 minutes @@ -175,6 +198,8 @@ internal class BackgroundService : IHostedService, IDisposable RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(null), "*/10 * * * *"); RecurringJob.AddOrUpdate("remove stale mods", () => BackgroundService.RemoveStaleModsAsync(), "2/10 * * * *"); // offset by 2 minutes so it runs after updates (e.g. 00:02, 00:12, etc) RecurringJob.AddOrUpdate("update mod dataset", () => BackgroundService.UpdateModDatasetAsync(null), "0 * * * *"); // hourly + if (enableMalwareBlacklist) + RecurringJob.AddOrUpdate("update malware blacklist", () => BackgroundService.UpdateMalwareBlacklistAsync(null), "*/10 * * * *"); BackgroundService.IsStarted = true; @@ -286,6 +311,60 @@ internal class BackgroundService : IHostedService, IDisposable context.WriteLine("Done!"); } + /// Fetch the malware blacklist and convert it into the static file fetched by SMAPI. + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])] + public static async Task UpdateMalwareBlacklistAsync(PerformContext? context) + { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + + // get config + MalwareBlacklistConfig config = BackgroundService.MalwareBlacklistConfig.Value; + string repo = config.GitHubRepo ?? throw new InvalidOperationException("Can't update the malware blacklist: no GitHub repository is configured."); + string outputPath = Path.Combine(BackgroundService.WebRootPath, Startup.BlacklistFileName); + + // fetch file + GitFile file; + { + context.WriteLine($"Fetching '{config.FilePath}' from {repo}{(config.GitRef != null ? $"@{config.GitRef}" : "")}..."); + + string? previousETag = File.Exists(outputPath) + ? BackgroundService.LastMalwareBlacklistETag + : null; + + file = + await BackgroundService.GitHubClient.GetFileAsync(repo, config.FilePath, config.GitRef, previousETag) + ?? throw new InvalidOperationException($"Can't update the malware blacklist: file '{config.FilePath}' not found in GitHub repository '{repo}'. If the repository is private, the configured GitHub credentials may not have read access to it."); + } + + // skip if unchanged + if (!file.IsModified) + { + context.WriteLine($"Skipped: file hasn't changed since the last sync (ETag {file.ETag})."); + return; + } + + // convert to public format + context.WriteLine($"Parsing blacklist (ETag {file.ETag})..."); + MalwareListModel rawBlacklist = MalwareBlacklistConverter.FromInternalFormat(file.Content); + ModBlacklistModel blacklist = MalwareBlacklistConverter.ToPublicModel(rawBlacklist); + string publicJson = MalwareBlacklistConverter.ToPublicJson(blacklist); + context.WriteLine($"Updated malware blacklist ({blacklist.Blacklist.Length} mod entries and {blacklist.LooseFileBlacklist.Length} loose file entries)."); + + // save file + { + string tempPath = $"{outputPath}.tmp"; + await File.WriteAllTextAsync(tempPath, publicJson, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + File.Move(tempPath, outputPath, overwrite: true); + + context.WriteLine($"Saved to {Startup.BlacklistFileName}."); + } + + BackgroundService.LastMalwareBlacklistETag = file.ETag; + context.WriteLine("Done!"); + } + /// Remove mods which haven't been requested in over 48 hours. public static Task RemoveStaleModsAsync() { diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitFile.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitFile.cs new file mode 100644 index 00000000..37d7d666 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitFile.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub; + +/// A file fetched from a GitHub repository. +internal class GitFile +{ + /********* + ** Accessors + *********/ + /// Whether the file changed since it was last fetched (i.e. does not match the one provided). If this is false, the is null. + [MemberNotNullWhen(true, nameof(GitFile.Content))] + public bool IsModified { get; } + + /// The raw file content, or null if is false. + public string? Content { get; } + + /// The HTTP ETag header value which identifies this version of the file, if provided by GitHub. This can be passed to a later request to only fetch the file if it changed. + public string? ETag { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// + /// + public GitFile(string content, string? eTag) + { + this.IsModified = true; + this.Content = content; + this.ETag = eTag; + } + + /// Construct an instance for a file which is unchanged since the given entity tag. + /// + public GitFile(string? eTag) + { + this.IsModified = false; + this.ETag = eTag; + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 8b8234f9..5c6b24f5 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Net; +using System.Net.Http.Headers; using System.Threading.Tasks; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -42,9 +43,7 @@ internal class GitHubClient : IGitHubClient this.Client = this.Client.SetBasicAuthentication(username, password!); } - /// Get basic metadata for a GitHub repository, if available. - /// The repository key (like Pathoschild/SMAPI). - /// Returns the repository info if it exists, else null. + /// public async Task GetRepositoryAsync(string repo) { this.AssertKeyFormat(repo); @@ -60,10 +59,7 @@ internal class GitHubClient : IGitHubClient } } - /// Get the latest release for a GitHub repository. - /// The repository key (like Pathoschild/SMAPI). - /// Whether to return a prerelease version if it's latest. - /// Returns the release if found, else null. + /// public async Task GetLatestReleaseAsync(string repo, bool includePrerelease = false) { this.AssertKeyFormat(repo); @@ -87,8 +83,53 @@ internal class GitHubClient : IGitHubClient } } - /// Get update check info about a mod. - /// The mod ID. + /// + public async Task GetFileAsync(string repo, string path, string? gitRef = null, string? eTag = null) + { + this.AssertKeyFormat(repo); + + // build URL + string[] pathSegments = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + string escapedPath = string.Join('/', pathSegments.Select(Uri.EscapeDataString)); + string url = $"repos/{repo}/contents/{escapedPath}"; + if (gitRef != null) + url += $"?ref={Uri.EscapeDataString(gitRef)}"; + + // fetch file + IResponse response; + try + { + response = await this.Client + .GetAsync(url) + .WithCustom(request => + { + // get raw file content + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github.raw+json")); + + // only fetch if changed + if (eTag != null) + request.Headers.TryAddWithoutValidation("If-None-Match", eTag); + }) + .AsResponse(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotModified) + { + return new GitFile(eTag); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; + } + + // read content + return new GitFile( + content: await response.AsString(), + eTag: response.Message.Headers.ETag?.ToString() + ); + } + + /// public async Task GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -135,7 +176,7 @@ internal class GitHubClient : IGitHubClient return page.SetInfo(name: name, url: url, version: null, downloads: downloads); } - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// public void Dispose() { this.Client.Dispose(); diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index ebc63cae..f2c8d5ba 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -19,4 +19,12 @@ internal interface IGitHubClient : IModSiteClient, IDisposable /// Whether to return a prerelease version if it's latest. /// Returns the release if found, else null. Task GetLatestReleaseAsync(string repo, bool includePrerelease = false); + + /// Get a file from a GitHub repository, if available. + /// The repository key (like Pathoschild/SMAPI). + /// The file path relative to the repository root. + /// The branch, tag, or commit from which to get the file, or null for the repository's default branch. + /// The from a previous request, if the file should only be fetched if it changed since then. + /// Returns the file if it exists and is accessible, else null. + Task GetFileAsync(string repo, string path, string? gitRef = null, string? eTag = null); } diff --git a/src/SMAPI.Web/Framework/ConfigModels/MalwareBlacklistConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MalwareBlacklistConfig.cs new file mode 100644 index 00000000..5a73edf5 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/MalwareBlacklistConfig.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels; + +/// The config settings for syncing the malware blacklist from the Stardew malware scanner data repo. +internal class MalwareBlacklistConfig +{ + /********* + ** Accessors + *********/ + /// The GitHub repository which contains the malware blacklist, or null to disable syncing. The repository must be readable to the GitHub login in . + public string? GitHubRepo { get; set; } + + /// The branch, tag, or commit from which to fetch the file, or null for the repository's default branch. + public string? GitRef { get; set; } + + /// The malware blacklist's file path relative to the root. + public string FilePath { get; set; } = null!; +} diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index 1f8421fb..86720999 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -38,7 +38,6 @@ - diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 68edd551..8bc445d2 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport; @@ -46,16 +47,16 @@ internal class Startup /********* ** Fields *********/ - /// The name of the static 'malicious mods' blacklist file. - private const string BlacklistFileName = "SMAPI.blacklist.json"; - - /// The MD5 hash for the 'malicious mods' blacklist file. - private readonly Lazy BlacklistMd5Hash; + /// The cached metadata for the last version of the malware blacklist file that was fetched. + private CachedFileMetadata? LastBlacklist; /********* ** Accessors *********/ + /// The name of the malware blacklist file generated by the background service. + public const string BlacklistFileName = "SMAPI.blacklist.json"; + /// The web app configuration. public IConfigurationRoot Configuration { get; } @@ -74,9 +75,6 @@ internal class Startup .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) .AddEnvironmentVariables() .Build(); - - // get cached 'malicious mods' blacklist MD5 hash - this.BlacklistMd5Hash = new Lazy(() => this.GetBlacklistMd5Hash(env.WebRootPath)); } /// The method called by the runtime to add services to the container. @@ -96,6 +94,7 @@ internal class Startup services .Configure(this.Configuration.GetRequiredSection("ApiClients")) .Configure(this.Configuration.GetRequiredSection("BackgroundServices")) + .Configure(this.Configuration.GetRequiredSection("MalwareBlacklist")) .Configure(this.Configuration.GetRequiredSection("ModCompatibilityList")) .Configure(this.Configuration.GetRequiredSection("ModUpdateCheck")) .Configure(this.Configuration.GetRequiredSection("Site")) @@ -298,7 +297,7 @@ internal class Startup private void OnPrepareStaticFileResponse(StaticFileResponseContext context) { if (context.File.Name == Startup.BlacklistFileName) - context.Context.Response.Headers.ContentMD5 = this.BlacklistMd5Hash.Value; + context.Context.Response.Headers.ContentMD5 = this.GetBlacklistMd5Hash(context.File); } /// Get the redirect rules to apply. @@ -381,13 +380,28 @@ internal class Startup } /// Get the Content-MD5 header value for the 'malicious mods' blacklist file. - /// The web root path. - private string GetBlacklistMd5Hash(string webRootPath) + /// The blacklist file being served. + private string GetBlacklistMd5Hash(IFileInfo file) { - string path = Path.Combine(webRootPath, Startup.BlacklistFileName); - using FileStream stream = File.OpenRead(path); - byte[] hash = MD5.HashData(stream); + string? hash = this.LastBlacklist?.Hash; - return Convert.ToBase64String(hash); + if (hash is null || this.LastBlacklist?.LastModified != file.LastModified || this.LastBlacklist.Length != file.Length) + { + using (Stream stream = file.CreateReadStream()) + hash = Convert.ToBase64String(MD5.HashData(stream)); + this.LastBlacklist = new CachedFileMetadata(file.LastModified, file.Length, hash); + } + + return hash; } + + + /********* + ** Private models + *********/ + /// Cached metadata about a file. + /// The file's last-modified date. + /// The file's size in bytes. + /// The file hash. + private record CachedFileMetadata(DateTimeOffset LastModified, long Length, string Hash); } diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 3f1ffe55..8ee64e64 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -70,6 +70,12 @@ "Enabled": true }, + "MalwareBlacklist": { + "GitHubRepo": "Pathoschild/StardewMalwareScanner.Data", + "FilePath": "data/malware-blacklist.json", + "GitRef": "nightly" + }, + "ModDataset": { "DownloadZipUrl": "https://github.com/Pathoschild/StardewModDataset/archive/refs/heads/main.zip", "LocalRootPath": "%TEMP%/StardewModDataset" diff --git a/src/SMAPI.Web/wwwroot/SMAPI.blacklist.json b/src/SMAPI.Web/wwwroot/SMAPI.blacklist.json deleted file mode 100644 index c600d88b..00000000 --- a/src/SMAPI.Web/wwwroot/SMAPI.blacklist.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - /** - * Metadata about malicious or harmful SMAPI mods which are blocked by default. This doesn't contain mods blocked - * due to compatibility issues; see metadata.json for that. - * - * See `src\SMAPI.Toolkit\Framework\ModBlacklistData\ModBlacklistModel.cs` for the format info. - */ - "Blacklist": [ - // 2025-07 - { - "Id": "OpUlarenous.PortableCommunityCenter", // 'Even Better Portable Community Center' by OpUlarenous (Nexus:35601, dfd9e53ee3e6fb997c04a2b0c7f49a50; malicious reupload of legit mod) - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete this mod and run a complete anti-malware scan of your computer now to be safe." - }, - { - "Id": "BrEvoiultion.GiveSpeedInMorning", // 'Give Speed In Morning' by BrEvoiultion (Nexus:35650, 97860c755192a27cdb6c16ec32327774; malicious reupload of legit mod) - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete this mod and run a complete anti-malware scan of your computer now to be safe." - }, - - // 2026-04 - { - "Id": "BritishW.ChaosWhispers", // 'Chaos Whispers' by BritishW (Nexus:44497, 381a5a9892e6699d9db1a8e2517a9a91) - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete this mod and run a complete anti-malware scan of your computer now to be safe." - }, - { - "Id": "DweDweD1.GhostCompanion", // 'GhostPet' or 'Ghost Companion' by DweDweD1 (CurseForge:1500648, 85dc32c81407d801fc58cad39a4e1542; Nexus:44342, c95364ba4634fef7826df261712e885d) - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete this mod and run a complete anti-malware scan of your computer now to be safe." - }, - { - "Id": "DweDweD1.MusicPlayer", // 'Music Player' by DweDweD1 (Nexus:44488, 159093613f4f62001db88c3078ee5c99) - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete this mod and run a complete anti-malware scan of your computer now to be safe." - }, - { - "Id": "BritishW.TheStardewDoppelganger", // The Stardew Doppelganger by BritishW (Nexus:44345, f0d1636288cb0877441d3f08134fc2cf) - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete this mod and run a complete anti-malware scan of your computer now to be safe." - }, - - // 2026-09 - { - "Id": "StardewLabs.StardewEssentials", // 'Stardew Essentials' by Hallowqueen/StardewLabs (CurseForge:1670031, 709c4ad894f1fdd8c41fe91a63ed6da8 (2.1.0) + d04b1ff874f12193eef0360cdd7e5141 (2.1.1)) - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete this mod and run a complete anti-malware scan of your computer now to be safe." - }, - { - "Id": "SwitchPosty.StardewSpaceAge", // 'Stardew: Space Age' by SwitchPosty (Nexus:50051, ac3266c5b45f6c514128280f4618604c) - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete this mod and run a complete anti-malware scan of your computer now to be safe." - }, - { - "Id": "User.StardewSprint", // 'StardewSprint' by Hallowqueen (CurseForge:1674030, d71a32b947aa35df2eb701c0299101b0 (1.0.0) + d467df7a77eb2d1c8075943a48adb22c (1.1.0) + ead306d003d2fb64348add02e162de9b (1.2.0) + 2721a2536655f6e425ed0f760a3487dc (1.3.0)) - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete this mod and run a complete anti-malware scan of your computer now to be safe." - } - ], - - /** - * Metadata about individual files which are known to be malicious, and should be blocked. If any file in a folder - * matches an entry, the entire folder is considered malicious. - */ - "LooseFileBlacklist": [ - // 2026-01: 'Alchemistry' (ModDrop:1731397; malicious reupload of legit mod) - { - "Name": "Auto_Alchemistry.bat", - "Hash": "8b749727775689a05cc92ab4d624ab8b", - "Message": "It downloads malicious code from a remote server and runs it on your computer. SMAPI refused to load the mod, but you should delete the mod containing this file and run a complete anti-malware scan of your computer now to be safe." - } - ] -} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 624fdb67..f5dc49d8 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -435,8 +435,16 @@ internal class SCore : IDisposable } if (modBlacklist is null) { - modBlacklist = toolkit.GetModBlacklist(Constants.ApiBlacklistPath); - Constants.ApiBlacklistActualPath = Constants.ApiBlacklistPath; + if (File.Exists(Constants.ApiBlacklistPath)) + { + modBlacklist = toolkit.GetModBlacklist(Constants.ApiBlacklistPath); + Constants.ApiBlacklistActualPath = Constants.ApiBlacklistPath; + } + else + { + this.Monitor.Log("SMAPI's mod blacklist is missing, so it can't block known malicious mods yet. SMAPI will try to download it from the web server, which will take effect on the next launch.", LogLevel.Warn); + modBlacklist = new ModBlacklist(); + } } // check for malicious loose files @@ -1825,7 +1833,9 @@ internal class SCore : IDisposable } // skip if up-to-date - string localHash = FileUtilities.GetFileHash(Constants.ApiBlacklistActualPath!); + string? localHash = Constants.ApiBlacklistActualPath != null + ? FileUtilities.GetFileHash(Constants.ApiBlacklistActualPath) + : null; // no local blacklist if (localHash == serverHash) { this.Monitor.Log(" Mod blacklist OK."); @@ -1843,7 +1853,7 @@ internal class SCore : IDisposable await using (FileStream stream = File.Create(Constants.ApiBlacklistFetchedPath)) await downloadStream.CopyToAsync(stream); - this.Monitor.Log($" Mod blacklist updated to match the server (updated from {localHash} to {serverHash}). The changes will take effect on the next SMAPI launch."); + this.Monitor.Log($" Mod blacklist updated to match the server (updated from {localHash ?? "none"} to {serverHash}). The changes will take effect on the next SMAPI launch."); } catch (Exception ex) { diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index 2d6d11e6..eb55f5dc 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -49,7 +49,6 @@ -