add support for detecting known malicious loose files

This commit is contained in:
Jesse Plamondon-Willard
2026-01-25 00:00:00 -05:00
parent 0a39176313
commit 5c3237bd90
8 changed files with 139 additions and 22 deletions
+4 -12
View File
@@ -2,18 +2,10 @@
# Release notes
## Upcoming release
**SMAPI release builds are automated on GitHub** (thanks to DecidedlyHuman!).
The main benefits include...
- Significantly improved security:
- This eliminates the risk of hidden malware infecting SMAPI releases.
- When downloading a release, you can compare the download's file hash against the GitHub build to
guarantee that (a) it was compiled from the code on GitHub and (b) it has not been tampered with.
- You can now download a preview of the next SMAPI release at any time, since every commit produces an alpha build.
This is also a big step towards future improvements, like...
- signing builds to reduce antivirus alerts on Windows and macOS;
- and automating mod builds too.
* For players:
* SMAPI now uses [automated and attested builds](https://www.patreon.com/posts/automated-builds-148417912) (thanks to DecidedlyHuman)!
_This improves the security and transparency of SMAPI builds. Every step to build SMAPI from the public source code is now public and verifiable, with file signatures to let players and tools confirm the build hasn't been tampered with._
* SMAPI can now detect known malicious loose files in the `Mods` folder.
## 4.4.0
Released 10 January 2026 for Stardew Valley 1.6.14 or later. See [release highlights](https://www.patreon.com/posts/147916705).
@@ -0,0 +1,37 @@
namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
/// <summary>A loose file entry in the <see cref="ModBlacklistModel"/>.</summary>
public class LooseFileBlacklistEntryModel
{
/*********
** Accessors
*********/
/// <summary>The file name to block (if any).</summary>
public string? Name { get; }
/// <summary>The file extension to block (if any), including the dot.</summary>
public string? Extension { get; }
/// <summary>The MD5 hash to block (if any).</summary>
public string? Hash { get; }
/// <summary>A player-friendly explanation of why the mod is blocked and what they should do next.</summary>
public string? Message { 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="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)
{
this.Name = name;
this.Extension = extension;
this.Hash = hash;
this.Message = message;
}
}
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
@@ -18,7 +20,7 @@ public class ModBlacklist
*********/
/// <summary>Construct an empty instance.</summary>
public ModBlacklist()
: this(new ModBlacklistModel([])) { }
: this(new ModBlacklistModel([], [])) { }
/// <summary>Construct an instance.</summary>
/// <param name="data">The underlying mod blacklist data.</param>
@@ -30,7 +32,7 @@ public class ModBlacklist
/// <summary>Get the blacklist entry for a mod, if any.</summary>
/// <param name="modId">The unique mod ID.</param>
/// <param name="entryDllPath">The absolute path to the entry DLL, if this is a C# mod.</param>
public ModBlacklistEntryModel? Get(string modId, string? entryDllPath)
public ModBlacklistEntryModel? CheckMod(string modId, string? entryDllPath)
{
string? entryDllHash = null;
@@ -56,4 +58,57 @@ public class ModBlacklist
return null;
}
/// <summary>Scan a folder to detect any files which match the list of malicious files.</summary>
/// <param name="rootPath">The root path to scan.</param>
public IEnumerable<(string FilePath, LooseFileBlacklistEntryModel Match)> CheckLooseFiles(string rootPath)
{
foreach (string path in Directory.EnumerateFiles(rootPath, "*.*", SearchOption.AllDirectories))
{
LooseFileBlacklistEntryModel? blacklistEntry = this.CheckLooseFile(path);
if (blacklistEntry != null)
yield return (path, blacklistEntry);
}
}
/// <summary>Get the blacklist entry for a loose file, if any.</summary>
/// <param name="fullPath">The absolute path to the file to check.</param>
public LooseFileBlacklistEntryModel? CheckLooseFile(string fullPath)
{
string? name = null;
string? extension = null;
string? hash = null;
foreach (LooseFileBlacklistEntryModel entry in this.Blacklist.LooseFileBlacklist)
{
// check file name
if (entry.Name != null)
{
name ??= Path.GetFileName(fullPath);
if (!string.Equals(name, entry.Name, StringComparison.OrdinalIgnoreCase))
continue;
}
// check file extension
if (entry.Extension != null)
{
extension ??= Path.GetExtension(fullPath);
if (!string.Equals(extension, entry.Extension, StringComparison.OrdinalIgnoreCase))
continue;
}
// check hash
if (entry.Hash != null)
{
hash ??= FileUtilities.GetFileHash(fullPath);
if (!string.Equals(hash, entry.Hash, StringComparison.OrdinalIgnoreCase))
continue;
}
return entry;
}
return null;
}
}
@@ -3,15 +3,17 @@ namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
/// <summary>A mod entry in the <see cref="ModBlacklistModel"/>.</summary>
public class ModBlacklistEntryModel
{
/*********
** Accessors
*********/
/// <summary>The manifest IDs to block (if any).</summary>
public string? Id { get; }
/// <summary>The MD5 hashes of the entry DLL to block (if any).</summary>
/// <remarks>Due to the chance of MD5 collisions, this should only be used in addition to the <see cref="Id"/>.</remarks>
/// <summary>The MD5 hash of the entry DLL to block (if any).</summary>
public string? EntryDllHash { get; }
/// <summary>A player-friendly explanation of why the mod is blocked and what they should do next.</summary>
public string Message { get; }
public string? Message { get; }
/*********
@@ -9,14 +9,19 @@ public class ModBlacklistModel
/// <summary>Metadata about malicious or harmful SMAPI mods which are disabled by default.</summary>
public ModBlacklistEntryModel[] Blacklist { get; }
/// <summary>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.</summary>
public LooseFileBlacklistEntryModel[] LooseFileBlacklist { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="blacklist"><inheritdoc cref="Blacklist" path="/summary"/></param>
public ModBlacklistModel(ModBlacklistEntryModel[]? blacklist)
/// <param name="looseFileBlacklist"><inheritdoc cref="LooseFileBlacklist" path="/summary"/></param>
public ModBlacklistModel(ModBlacklistEntryModel[]? blacklist, LooseFileBlacklistEntryModel[]? looseFileBlacklist)
{
this.Blacklist = blacklist ?? [];
this.LooseFileBlacklist = looseFileBlacklist ?? [];
}
}
+9 -2
View File
@@ -7,12 +7,19 @@
"Blacklist": [
// 2025-07
{
"Id": "OpUlarenous.PortableCommunityCenter", // 'Even Better Portable Community Center' (Nexus:35601, dfd9e53ee3e6fb997c04a2b0c7f49a50)
"Id": "OpUlarenous.PortableCommunityCenter", // 'Even Better Portable Community Center' (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' (Nexus:35650, 97860c755192a27cdb6c16ec32327774)
"Id": "BrEvoiultion.GiveSpeedInMorning", // 'Give Speed In Morning' (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."
}
],
/**
* 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": [
]
}
@@ -43,7 +43,7 @@ internal class ModResolver
if (!File.Exists(entryDllPath))
entryDllPath = null;
blacklistEntry = modBlacklist.Get(manifest.UniqueID, entryDllPath);
blacklistEntry = modBlacklist.CheckMod(manifest.UniqueID, entryDllPath);
}
// parse internal data record (if any)
+20 -1
View File
@@ -444,6 +444,25 @@ internal class SCore : IDisposable
Constants.ApiBlacklistActualPath = Constants.ApiBlacklistPath;
}
// check for malicious loose files
this.Monitor.Log("Scanning for malicious files...");
{
bool foundMaliciousFiles = false;
foreach ((string filePath, LooseFileBlacklistEntryModel match) in modBlacklist.CheckLooseFiles(this.ModsPath))
{
foundMaliciousFiles = true;
this.Monitor.LogFatal("Malicious mod file detected.");
this.Monitor.Log($"File path: '{filePath}'", LogLevel.Error);
this.Monitor.Newline();
this.Monitor.Log(match.Message ?? "This file has been flagged as malicious. You should immediately delete the mod containing the file, and perform a full anti-malware scan of your computer to be safe.", LogLevel.Error);
}
if (foundMaliciousFiles)
this.LogManager.PressAnyKeyToExit();
}
// load mods
{
this.Monitor.Log("Loading mod metadata...", LogLevel.Debug);
@@ -743,7 +762,7 @@ internal class SCore : IDisposable
// conflict (e.g. collection changed during enumeration errors) and data may change
// unexpectedly from one mod instruction to the next.
//
// Therefore we can just run Game1.Update here without raising any SMAPI events. There's
// Therefore, we can just run Game1.Update here without raising any SMAPI events. There's
// a small chance that the task will finish after we defer but before the game checks,
// which means technically events should be raised, but the effects of missing one
// update tick are negligible and not worth the complications of bypassing Game1.Update.