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).
This commit is contained in:
Jesse Plamondon-Willard
2026-09-28 00:44:03 -04:00
parent f090df0db3
commit d6f868ecef
24 changed files with 752 additions and 101 deletions
+2
View File
@@ -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
-1
View File
@@ -19,7 +19,6 @@ This assumes `find-game-folder.targets` has already been imported and validated.
<Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFolder="$(GamePath)" Condition="$(OS) == 'Windows_NT'" />
<Copy SourceFiles="$(TargetDir)\$(TargetName)" DestinationFolder="$(GamePath)" Condition="$(OS) != 'Windows_NT'" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\SMAPI.blacklist.json" DestinationFiles="$(GamePath)\smapi-internal\blacklist.json" />
<Copy SourceFiles="$(TargetDir)\SMAPI.config.json" DestinationFiles="$(GamePath)\smapi-internal\config.json" />
<Copy SourceFiles="$(TargetDir)\SMAPI.metadata.json" DestinationFiles="$(GamePath)\smapi-internal\metadata.json" />
<Copy SourceFiles="$(TargetDir)\Markdig.dll" DestinationFolder="$(GamePath)\smapi-internal" />
+24 -1
View File
@@ -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) {
+1 -1
View File
@@ -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).
+2 -1
View File
@@ -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`<br />`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`<br />`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`<br />`MalwareBlacklist:GitRef`<br />`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.
@@ -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;
/// <summary>Unit tests for <see cref="MalwareBlacklistConverter"/>.</summary>
[TestFixture]
internal class MalwareBlacklistConverterTests
{
/*********
** Fields
*********/
/// <summary>The default message key to test.</summary>
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 & \"<malicious>\"."; // special characters
string json = this.GetValidBlacklistJson(
mod: new MalwareEntryModel<MalwareModMatchModel>(
match: new MalwareModMatchModel(id: ["Author.Mod"], entryDllMd5Hash: []),
message: message
)
);
// act
string result = this.Convert(json);
ModBlacklistModel? model = Newtonsoft.Json.JsonConvert.DeserializeObject<ModBlacklistModel>(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<FormatException>().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<MalwareModMatchModel>(
match: JsonSerializer.Deserialize<MalwareModMatchModel>(match),
message: MessageKey
)
);
// act
Action act = () => this.Convert(json);
// assert
act.Should().Throw<FormatException>().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<MalwareLooseFileMatchModel>(
match: JsonSerializer.Deserialize<MalwareLooseFileMatchModel>(match),
message: MessageKey
)
);
// act
Action act = () => this.Convert(json);
// assert
act.Should().Throw<FormatException>().WithMessage("*avoid blocking every file*");
}
/*********
** Private methods
*********/
/// <summary>Parse a malware blacklist and convert it into the public JSON.</summary>
/// <param name="json">The malware blacklist JSON.</param>
private string Convert(string json)
{
MalwareListModel blacklist = MalwareBlacklistConverter.FromInternalFormat(json);
ModBlacklistModel publicModel = MalwareBlacklistConverter.ToPublicModel(blacklist);
return MalwareBlacklistConverter.ToPublicJson(publicModel);
}
/// <summary>Get the JSON for a malware blacklist with a single mod entry.</summary>
/// <param name="format">The format version.</param>
/// <param name="mod">The mod entry to blacklist, or <c>null</c> for a default entry which matches <c>Author.Mod</c>.</param>
/// <param name="looseFile">The loose file to blacklist, or <c>null</c> for an empty loose files list.</param>
private string GetValidBlacklistJson(int format = 1, MalwareEntryModel<MalwareModMatchModel>? mod = null, MalwareEntryModel<MalwareLooseFileMatchModel>? looseFile = null)
{
return JsonSerializer.Serialize(
new MalwareListModel(
format: format,
messages: new Dictionary<string, string>
{
[MessageKey] = "Remote code message"
},
mods: [
mod ?? new MalwareEntryModel<MalwareModMatchModel>(
match: new MalwareModMatchModel(id: ["Author.Mod"], entryDllMd5Hash: []),
message: MessageKey
)
],
looseFiles: looseFile != null
? [looseFile]
: []
)
);
}
}
@@ -27,7 +27,7 @@ public class LooseFileBlacklistEntryModel
/// <param name="extension"><inheritdoc cref="Extension" path="/summary"/></param>
/// <param name="hash"><inheritdoc cref="Hash" path="/summary"/></param>
/// <param name="message"><inheritdoc cref="Message" path="/summary"/></param>
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;
@@ -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;
/// <summary>Converts the internal <see cref="MalwareListModel"/> format into the public <see cref="ModBlacklistModel"/> format used by SMAPI.</summary>
[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
*********/
/// <summary>The <see cref="MalwareListModel.Format"/> version supported by the converter.</summary>
public const int SupportedFormat = 1;
/*********
** Public methods
*********/
/// <summary>Parse the raw JSON for a malware blacklist.</summary>
/// <param name="json">The raw JSON text.</param>
/// <exception cref="JsonException">The JSON can't be parsed.</exception>
/// <exception cref="FormatException">The file is empty, or uses an unsupported format version.</exception>
public static MalwareListModel FromInternalFormat(string json)
{
MalwareListModel blacklist =
JsonSerializer.Deserialize<MalwareListModel>(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;
}
/// <summary>Convert a malware blacklist into the public format used by SMAPI.</summary>
/// <param name="blacklist">The malware blacklist to convert.</param>
/// <exception cref="FormatException">An entry has invalid match conditions.</exception>
public static ModBlacklistModel ToPublicModel(MalwareListModel blacklist)
{
List<ModBlacklistEntryModel> mods = [];
foreach (MalwareEntryModel<MalwareModMatchModel> 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<LooseFileBlacklistEntryModel> looseFiles = [];
foreach (MalwareEntryModel<MalwareLooseFileMatchModel> 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());
}
/// <summary>Serialize a public blacklist into its JSON representation.</summary>
/// <param name="blacklist">The public blacklist to serialize.</param>
public static string ToPublicJson(ModBlacklistModel blacklist)
{
var options = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
return JsonSerializer.Serialize(blacklist, options);
}
/*********
** Private methods
*********/
/// <summary>Get the message text for an entry's <c>Message</c> field.</summary>
/// <param name="blacklist">The malware blacklist containing the entry.</param>
/// <param name="message">The entry's message key or literal message text.</param>
private static string? GetMessageText(MalwareListModel blacklist, string? message)
{
return message != null && blacklist.Messages?.TryGetValue(message, out string? text) == true
? text
: message;
}
/// <summary>Get the values in a match field, or a single <c>null</c> if the field was omitted.</summary>
/// <param name="values">The match values.</param>
private static string?[] GetPermutationValues(string[]? values)
{
if (values?.Length > 0)
return values;
return [null];
}
}
@@ -0,0 +1,27 @@
namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
/// <summary>An entry in the <see cref="MalwareListModel"/>.</summary>
public class MalwareEntryModel<TMatch>
{
/*********
** Accessors
*********/
/// <summary>The conditions which identify the mod or loose file.</summary>
public TMatch? Match { get; }
/// <summary>A key in the <see cref="MalwareListModel.Messages"/> field, or the literal message text to show when the entry matches.</summary>
public string? Message { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="match"><inheritdoc cref="Match" path="/summary"/></param>
/// <param name="message"><inheritdoc cref="Message" path="/summary"/></param>
public MalwareEntryModel(TMatch? match, string? message)
{
this.Match = match;
this.Message = message;
}
}
@@ -0,0 +1,39 @@
using System.Collections.Generic;
namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
/// <summary>The internal blacklist which tracks known malware.</summary>
public class MalwareListModel
{
/*********
** Accessors
*********/
/// <summary>The format version.</summary>
public int Format { get; }
/// <summary>The reusable messages which can be shown when a mod is blocked, indexed by the key which entries reference in their <c>Message</c> field.</summary>
public Dictionary<string, string>? Messages { get; }
/// <summary>The mods which are known to be malicious.</summary>
public MalwareEntryModel<MalwareModMatchModel>[]? Mods { get; }
/// <summary>The individual files which are known to be malicious.</summary>
public MalwareEntryModel<MalwareLooseFileMatchModel>[]? LooseFiles { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="format"><inheritdoc cref="Format" path="/summary"/></param>
/// <param name="messages"><inheritdoc cref="Messages" path="/summary"/></param>
/// <param name="mods"><inheritdoc cref="Mods" path="/summary"/></param>
/// <param name="looseFiles"><inheritdoc cref="LooseFiles" path="/summary"/></param>
public MalwareListModel(int format, Dictionary<string, string>? messages, MalwareEntryModel<MalwareModMatchModel>[]? mods, MalwareEntryModel<MalwareLooseFileMatchModel>[]? looseFiles)
{
this.Format = format;
this.Messages = messages;
this.Mods = mods;
this.LooseFiles = looseFiles;
}
}
@@ -0,0 +1,32 @@
namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
/// <summary>The fields which identify a loose file in the <see cref="MalwareListModel"/>.</summary>
public class MalwareLooseFileMatchModel
{
/*********
** Accessors
*********/
/// <summary>The file names to match (including the extension), or <c>null</c> to match any file name.</summary>
public string[]? Name { get; }
/// <summary>The file extensions to match (including the dot), or <c>null</c> to match any file extension.</summary>
public string[]? Extension { get; }
/// <summary>The MD5 hashes of the file to match, or <c>null</c> to match any hash.</summary>
public string[]? Md5Hash { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="name"><inheritdoc cref="Name" path="/summary"/></param>
/// <param name="extension"><inheritdoc cref="Extension" path="/summary"/></param>
/// <param name="md5Hash"><inheritdoc cref="Md5Hash" path="/summary"/></param>
public MalwareLooseFileMatchModel(string[]? name, string[]? extension, string[]? md5Hash)
{
this.Name = name;
this.Extension = extension;
this.Md5Hash = md5Hash;
}
}
@@ -0,0 +1,27 @@
namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
/// <summary>The fields which identify a mod in the <see cref="MalwareListModel"/>.</summary>
public class MalwareModMatchModel
{
/*********
** Accessors
*********/
/// <summary>The manifest <c>UniqueID</c> values to match, or <c>null</c> to match any ID.</summary>
public string[]? Id { get; }
/// <summary>The MD5 hashes of the mod's entry DLL to match, or <c>null</c> to match any hash.</summary>
public string[]? EntryDllMd5Hash { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="id"><inheritdoc cref="Id" path="/summary"/></param>
/// <param name="entryDllMd5Hash"><inheritdoc cref="EntryDllMd5Hash" path="/summary"/></param>
public MalwareModMatchModel(string[]? id, string[]? entryDllMd5Hash)
{
this.Id = id;
this.EntryDllMd5Hash = entryDllMd5Hash;
}
}
@@ -25,7 +25,7 @@ public class ModBlacklistEntryModel
/// <param name="id"><inheritdoc cref="Id" path="/summary"/></param>
/// <param name="entryDllHash"><inheritdoc cref="EntryDllHash" path="/summary"/></param>
/// <param name="message"><inheritdoc cref="Message" path="/summary"/></param>
public ModBlacklistEntryModel(string? id, string? entryDllHash, string message)
public ModBlacklistEntryModel(string? id, string? entryDllHash, string? message)
{
this.Id = id;
this.EntryDllHash = entryDllHash;
+79
View File
@@ -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
/// <summary>The cache in which to store the mod data from the CurseForge export API.</summary>
private static ICurseForgeExportCacheRepository? CurseForgeExportCache;
/// <summary>The HTTP client for fetching data from GitHub.</summary>
private static IGitHubClient? GitHubClient;
/// <summary>The HTTP client for fetching the mod export from the ModDrop export API.</summary>
private static IModDropExportApiClient? ModDropExportApiClient;
@@ -70,6 +76,12 @@ internal class BackgroundService : IHostedService, IDisposable
/// <summary>The mod dataset repository.</summary>
private static IModDatasetRepository? ModDatasetRepo;
/// <summary>The config settings for syncing the malware blacklist.</summary>
private static IOptions<MalwareBlacklistConfig>? MalwareBlacklistConfig;
/// <summary>The HTTP ETag header for the last fetched malware blacklist file, if any.</summary>
private static string? LastMalwareBlacklistETag;
/// <summary>The web root path for writing static data files.</summary>
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
/// <param name="modCache"><inheritdoc cref="ModCache" path="/summary"/></param>
/// <param name="curseForgeExportCache"><inheritdoc cref="CurseForgeExportCache" path="/summary"/></param>
/// <param name="curseForgeExportApiClient"><inheritdoc cref="CurseForgeExportApiClient" path="/summary"/></param>
/// <param name="gitHubClient"><inheritdoc cref="GitHubClient" path="/summary"/></param>
/// <param name="modDropExportCache"><inheritdoc cref="ModDropExportCache" path="/summary"/></param>
/// <param name="modDropExportApiClient"><inheritdoc cref="ModDropExportApiClient" path="/summary"/></param>
/// <param name="nexusExportCache"><inheritdoc cref="NexusExportCache" path="/summary"/></param>
/// <param name="nexusExportApiClient"><inheritdoc cref="NexusExportApiClient" path="/summary"/></param>
/// <param name="modDatasetRepo"><inheritdoc cref="ModDatasetRepo" path="/summary"/></param>
/// <param name="hangfireStorage">The Hangfire storage implementation.</param>
/// <param name="malwareBlacklistConfig"><inheritdoc cref="MalwareBlacklistConfig" path="/summary"/></param>
/// <param name="updateCheckConfig"><inheritdoc cref="UpdateCheckConfig" path="/summary"/></param>
/// <param name="hostEnvironment">The web host environment metadata.</param>
[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> malwareBlacklistConfig,
IOptions<ModUpdateCheckConfig> 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!");
}
/// <summary>Fetch the malware blacklist and convert it into the static file fetched by SMAPI.</summary>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
[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!");
}
/// <summary>Remove mods which haven't been requested in over 48 hours.</summary>
public static Task RemoveStaleModsAsync()
{
@@ -0,0 +1,42 @@
using System.Diagnostics.CodeAnalysis;
namespace StardewModdingAPI.Web.Framework.Clients.GitHub;
/// <summary>A file fetched from a GitHub repository.</summary>
internal class GitFile
{
/*********
** Accessors
*********/
/// <summary>Whether the file changed since it was last fetched (i.e. <see cref="ETag"/> does not match the one provided). If this is false, the <see cref="Content"/> is <c>null</c>.</summary>
[MemberNotNullWhen(true, nameof(GitFile.Content))]
public bool IsModified { get; }
/// <summary>The raw file content, or <c>null</c> if <see cref="IsModified"/> is false.</summary>
public string? Content { get; }
/// <summary>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.</summary>
public string? ETag { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="content"><inheritdoc cref="Content" path="/summary"/></param>
/// <param name="eTag"><inheritdoc cref="ETag" path="/summary"/></param>
public GitFile(string content, string? eTag)
{
this.IsModified = true;
this.Content = content;
this.ETag = eTag;
}
/// <summary>Construct an instance for a file which is unchanged since the given entity tag.</summary>
/// <param name="eTag"><inheritdoc cref="ETag" path="/summary"/></param>
public GitFile(string? eTag)
{
this.IsModified = false;
this.ETag = eTag;
}
}
@@ -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!);
}
/// <summary>Get basic metadata for a GitHub repository, if available.</summary>
/// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param>
/// <returns>Returns the repository info if it exists, else <c>null</c>.</returns>
/// <inheritdoc />
public async Task<GitRepo?> GetRepositoryAsync(string repo)
{
this.AssertKeyFormat(repo);
@@ -60,10 +59,7 @@ internal class GitHubClient : IGitHubClient
}
}
/// <summary>Get the latest release for a GitHub repository.</summary>
/// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param>
/// <param name="includePrerelease">Whether to return a prerelease version if it's latest.</param>
/// <returns>Returns the release if found, else <c>null</c>.</returns>
/// <inheritdoc />
public async Task<GitRelease?> GetLatestReleaseAsync(string repo, bool includePrerelease = false)
{
this.AssertKeyFormat(repo);
@@ -87,8 +83,53 @@ internal class GitHubClient : IGitHubClient
}
}
/// <summary>Get update check info about a mod.</summary>
/// <param name="id">The mod ID.</param>
/// <inheritdoc />
public async Task<GitFile?> 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()
);
}
/// <inheritdoc />
public async Task<IModPage?> 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);
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
/// <inheritdoc />
public void Dispose()
{
this.Client.Dispose();
@@ -19,4 +19,12 @@ internal interface IGitHubClient : IModSiteClient, IDisposable
/// <param name="includePrerelease">Whether to return a prerelease version if it's latest.</param>
/// <returns>Returns the release if found, else <c>null</c>.</returns>
Task<GitRelease?> GetLatestReleaseAsync(string repo, bool includePrerelease = false);
/// <summary>Get a file from a GitHub repository, if available.</summary>
/// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param>
/// <param name="path">The file path relative to the repository root.</param>
/// <param name="gitRef">The branch, tag, or commit from which to get the file, or <c>null</c> for the repository's default branch.</param>
/// <param name="eTag">The <see cref="GitFile.ETag"/> from a previous request, if the file should only be fetched if it changed since then.</param>
/// <returns>Returns the file if it exists and is accessible, else <c>null</c>.</returns>
Task<GitFile?> GetFileAsync(string repo, string path, string? gitRef = null, string? eTag = null);
}
@@ -0,0 +1,17 @@
namespace StardewModdingAPI.Web.Framework.ConfigModels;
/// <summary>The config settings for syncing the malware blacklist from the Stardew malware scanner data repo.</summary>
internal class MalwareBlacklistConfig
{
/*********
** Accessors
*********/
/// <summary>The GitHub repository which contains the malware blacklist, or <c>null</c> to disable syncing. The repository must be readable to the GitHub login in <see cref="ApiClientsConfig"/>.</summary>
public string? GitHubRepo { get; set; }
/// <summary>The branch, tag, or commit from which to fetch the file, or <c>null</c> for the repository's default branch.</summary>
public string? GitRef { get; set; }
/// <summary>The malware blacklist's file path relative to the <see cref="GitHubRepo"/> root.</summary>
public string FilePath { get; set; } = null!;
}
-1
View File
@@ -38,7 +38,6 @@
<Content Update="Views\Index\Privacy.cshtml" Pack="$(IncludeRazorContentInPack)" />
<Content Update="Views\Mods\Index.cshtml" Pack="$(IncludeRazorContentInPack)" />
<Content Update="wwwroot\SMAPI.blacklist.json" CopyToOutputDirectory="PreserveNewest" />
<Content Update="wwwroot\SMAPI.metadata.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
+29 -15
View File
@@ -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
*********/
/// <summary>The name of the static 'malicious mods' blacklist file.</summary>
private const string BlacklistFileName = "SMAPI.blacklist.json";
/// <summary>The MD5 hash for the 'malicious mods' blacklist file.</summary>
private readonly Lazy<string> BlacklistMd5Hash;
/// <summary>The cached metadata for the last version of the malware blacklist file that was fetched.</summary>
private CachedFileMetadata? LastBlacklist;
/*********
** Accessors
*********/
/// <summary>The name of the malware blacklist file generated by the background service.</summary>
public const string BlacklistFileName = "SMAPI.blacklist.json";
/// <summary>The web app configuration.</summary>
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<string>(() => this.GetBlacklistMd5Hash(env.WebRootPath));
}
/// <summary>The method called by the runtime to add services to the container.</summary>
@@ -96,6 +94,7 @@ internal class Startup
services
.Configure<ApiClientsConfig>(this.Configuration.GetRequiredSection("ApiClients"))
.Configure<BackgroundServicesConfig>(this.Configuration.GetRequiredSection("BackgroundServices"))
.Configure<MalwareBlacklistConfig>(this.Configuration.GetRequiredSection("MalwareBlacklist"))
.Configure<ModCompatibilityListConfig>(this.Configuration.GetRequiredSection("ModCompatibilityList"))
.Configure<ModUpdateCheckConfig>(this.Configuration.GetRequiredSection("ModUpdateCheck"))
.Configure<SiteConfig>(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);
}
/// <summary>Get the redirect rules to apply.</summary>
@@ -381,13 +380,28 @@ internal class Startup
}
/// <summary>Get the <c>Content-MD5</c> header value for the 'malicious mods' blacklist file.</summary>
/// <param name="webRootPath">The web root path.</param>
private string GetBlacklistMd5Hash(string webRootPath)
/// <param name="file">The blacklist file being served.</param>
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
*********/
/// <summary>Cached metadata about a file.</summary>
/// <param name="LastModified">The file's last-modified date.</param>
/// <param name="Length">The file's size in bytes.</param>
/// <param name="Hash">The file hash.</param>
private record CachedFileMetadata(DateTimeOffset LastModified, long Length, string Hash);
}
+6
View File
@@ -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"
@@ -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."
}
]
}
+14 -4
View File
@@ -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)
{
-1
View File
@@ -49,7 +49,6 @@
<ItemGroup>
<Content Include="SMAPI.config.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\SMAPI.Web\wwwroot\SMAPI.blacklist.json" Link="SMAPI.blacklist.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\SMAPI.Web\wwwroot\SMAPI.metadata.json" Link="SMAPI.metadata.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="i18n\*" CopyToOutputDirectory="PreserveNewest" />