Skip to content

Commit 5c3237b

Browse files
committed
add support for detecting known malicious loose files
1 parent 0a39176 commit 5c3237b

8 files changed

Lines changed: 139 additions & 22 deletions

File tree

docs/release-notes.md

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,10 @@
22

33
# Release notes
44
## Upcoming release
5-
**SMAPI release builds are automated on GitHub** (thanks to DecidedlyHuman!).
6-
7-
The main benefits include...
8-
- Significantly improved security:
9-
- This eliminates the risk of hidden malware infecting SMAPI releases.
10-
- When downloading a release, you can compare the download's file hash against the GitHub build to
11-
guarantee that (a) it was compiled from the code on GitHub and (b) it has not been tampered with.
12-
- You can now download a preview of the next SMAPI release at any time, since every commit produces an alpha build.
13-
14-
This is also a big step towards future improvements, like...
15-
- signing builds to reduce antivirus alerts on Windows and macOS;
16-
- and automating mod builds too.
5+
* For players:
6+
* SMAPI now uses [automated and attested builds](https://www.patreon.com/posts/automated-builds-148417912) (thanks to DecidedlyHuman)!
7+
_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._
8+
* SMAPI can now detect known malicious loose files in the `Mods` folder.
179

1810
## 4.4.0
1911
Released 10 January 2026 for Stardew Valley 1.6.14 or later. See [release highlights](https://www.patreon.com/posts/147916705).
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
2+
3+
/// <summary>A loose file entry in the <see cref="ModBlacklistModel"/>.</summary>
4+
public class LooseFileBlacklistEntryModel
5+
{
6+
/*********
7+
** Accessors
8+
*********/
9+
/// <summary>The file name to block (if any).</summary>
10+
public string? Name { get; }
11+
12+
/// <summary>The file extension to block (if any), including the dot.</summary>
13+
public string? Extension { get; }
14+
15+
/// <summary>The MD5 hash to block (if any).</summary>
16+
public string? Hash { get; }
17+
18+
/// <summary>A player-friendly explanation of why the mod is blocked and what they should do next.</summary>
19+
public string? Message { get; }
20+
21+
22+
/*********
23+
** Public methods
24+
*********/
25+
/// <summary>Construct an instance.</summary>
26+
/// <param name="name"><inheritdoc cref="Name" path="/summary"/></param>
27+
/// <param name="extension"><inheritdoc cref="Extension" path="/summary"/></param>
28+
/// <param name="hash"><inheritdoc cref="Hash" path="/summary"/></param>
29+
/// <param name="message"><inheritdoc cref="Message" path="/summary"/></param>
30+
public LooseFileBlacklistEntryModel(string? name, string? extension, string? hash, string message)
31+
{
32+
this.Name = name;
33+
this.Extension = extension;
34+
this.Hash = hash;
35+
this.Message = message;
36+
}
37+
}

src/SMAPI.Toolkit/Framework/ModBlacklistData/ModBlacklist.cs

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
24
using StardewModdingAPI.Toolkit.Utilities;
35

46
namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
@@ -18,7 +20,7 @@ public class ModBlacklist
1820
*********/
1921
/// <summary>Construct an empty instance.</summary>
2022
public ModBlacklist()
21-
: this(new ModBlacklistModel([])) { }
23+
: this(new ModBlacklistModel([], [])) { }
2224

2325
/// <summary>Construct an instance.</summary>
2426
/// <param name="data">The underlying mod blacklist data.</param>
@@ -30,7 +32,7 @@ public ModBlacklist(ModBlacklistModel data)
3032
/// <summary>Get the blacklist entry for a mod, if any.</summary>
3133
/// <param name="modId">The unique mod ID.</param>
3234
/// <param name="entryDllPath">The absolute path to the entry DLL, if this is a C# mod.</param>
33-
public ModBlacklistEntryModel? Get(string modId, string? entryDllPath)
35+
public ModBlacklistEntryModel? CheckMod(string modId, string? entryDllPath)
3436
{
3537
string? entryDllHash = null;
3638

@@ -56,4 +58,57 @@ public ModBlacklist(ModBlacklistModel data)
5658

5759
return null;
5860
}
61+
62+
/// <summary>Scan a folder to detect any files which match the list of malicious files.</summary>
63+
/// <param name="rootPath">The root path to scan.</param>
64+
public IEnumerable<(string FilePath, LooseFileBlacklistEntryModel Match)> CheckLooseFiles(string rootPath)
65+
{
66+
foreach (string path in Directory.EnumerateFiles(rootPath, "*.*", SearchOption.AllDirectories))
67+
{
68+
LooseFileBlacklistEntryModel? blacklistEntry = this.CheckLooseFile(path);
69+
70+
if (blacklistEntry != null)
71+
yield return (path, blacklistEntry);
72+
}
73+
}
74+
75+
/// <summary>Get the blacklist entry for a loose file, if any.</summary>
76+
/// <param name="fullPath">The absolute path to the file to check.</param>
77+
public LooseFileBlacklistEntryModel? CheckLooseFile(string fullPath)
78+
{
79+
string? name = null;
80+
string? extension = null;
81+
string? hash = null;
82+
83+
foreach (LooseFileBlacklistEntryModel entry in this.Blacklist.LooseFileBlacklist)
84+
{
85+
// check file name
86+
if (entry.Name != null)
87+
{
88+
name ??= Path.GetFileName(fullPath);
89+
if (!string.Equals(name, entry.Name, StringComparison.OrdinalIgnoreCase))
90+
continue;
91+
}
92+
93+
// check file extension
94+
if (entry.Extension != null)
95+
{
96+
extension ??= Path.GetExtension(fullPath);
97+
if (!string.Equals(extension, entry.Extension, StringComparison.OrdinalIgnoreCase))
98+
continue;
99+
}
100+
101+
// check hash
102+
if (entry.Hash != null)
103+
{
104+
hash ??= FileUtilities.GetFileHash(fullPath);
105+
if (!string.Equals(hash, entry.Hash, StringComparison.OrdinalIgnoreCase))
106+
continue;
107+
}
108+
109+
return entry;
110+
}
111+
112+
return null;
113+
}
59114
}

src/SMAPI.Toolkit/Framework/ModBlacklistData/ModBlacklistEntryModel.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ namespace StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
33
/// <summary>A mod entry in the <see cref="ModBlacklistModel"/>.</summary>
44
public class ModBlacklistEntryModel
55
{
6+
/*********
7+
** Accessors
8+
*********/
69
/// <summary>The manifest IDs to block (if any).</summary>
710
public string? Id { get; }
811

9-
/// <summary>The MD5 hashes of the entry DLL to block (if any).</summary>
10-
/// <remarks>Due to the chance of MD5 collisions, this should only be used in addition to the <see cref="Id"/>.</remarks>
12+
/// <summary>The MD5 hash of the entry DLL to block (if any).</summary>
1113
public string? EntryDllHash { get; }
1214

1315
/// <summary>A player-friendly explanation of why the mod is blocked and what they should do next.</summary>
14-
public string Message { get; }
16+
public string? Message { get; }
1517

1618

1719
/*********

src/SMAPI.Toolkit/Framework/ModBlacklistData/ModBlacklistModel.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@ public class ModBlacklistModel
99
/// <summary>Metadata about malicious or harmful SMAPI mods which are disabled by default.</summary>
1010
public ModBlacklistEntryModel[] Blacklist { get; }
1111

12+
/// <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>
13+
public LooseFileBlacklistEntryModel[] LooseFileBlacklist { get; }
14+
1215

1316
/*********
1417
** Public methods
1518
*********/
1619
/// <summary>Construct an instance.</summary>
1720
/// <param name="blacklist"><inheritdoc cref="Blacklist" path="/summary"/></param>
18-
public ModBlacklistModel(ModBlacklistEntryModel[]? blacklist)
21+
/// <param name="looseFileBlacklist"><inheritdoc cref="LooseFileBlacklist" path="/summary"/></param>
22+
public ModBlacklistModel(ModBlacklistEntryModel[]? blacklist, LooseFileBlacklistEntryModel[]? looseFileBlacklist)
1923
{
2024
this.Blacklist = blacklist ?? [];
25+
this.LooseFileBlacklist = looseFileBlacklist ?? [];
2126
}
2227
}

src/SMAPI.Web/wwwroot/SMAPI.blacklist.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@
77
"Blacklist": [
88
// 2025-07
99
{
10-
"Id": "OpUlarenous.PortableCommunityCenter", // 'Even Better Portable Community Center' (Nexus:35601, dfd9e53ee3e6fb997c04a2b0c7f49a50)
10+
"Id": "OpUlarenous.PortableCommunityCenter", // 'Even Better Portable Community Center' (Nexus:35601, dfd9e53ee3e6fb997c04a2b0c7f49a50, malicious reupload of legit mod)
1111
"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."
1212
},
1313
{
14-
"Id": "BrEvoiultion.GiveSpeedInMorning", // 'Give Speed In Morning' (Nexus:35650, 97860c755192a27cdb6c16ec32327774)
14+
"Id": "BrEvoiultion.GiveSpeedInMorning", // 'Give Speed In Morning' (Nexus:35650, 97860c755192a27cdb6c16ec32327774, malicious reupload of legit mod)
1515
"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."
1616
}
17+
],
18+
19+
/**
20+
* Metadata about individual files which are known to be malicious, and should be blocked.
21+
* If any file in a folder matches an entry, the entire folder is considered malicious.
22+
*/
23+
"LooseFileBlacklist": [
1724
]
1825
}

src/SMAPI/Framework/ModLoading/ModResolver.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public IEnumerable<IModMetadata> ReadManifests(ModToolkit toolkit, string rootPa
4343
if (!File.Exists(entryDllPath))
4444
entryDllPath = null;
4545

46-
blacklistEntry = modBlacklist.Get(manifest.UniqueID, entryDllPath);
46+
blacklistEntry = modBlacklist.CheckMod(manifest.UniqueID, entryDllPath);
4747
}
4848

4949
// parse internal data record (if any)

src/SMAPI/Framework/SCore.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,25 @@ private void InitializeBeforeFirstAssetLoaded()
444444
Constants.ApiBlacklistActualPath = Constants.ApiBlacklistPath;
445445
}
446446

447+
// check for malicious loose files
448+
this.Monitor.Log("Scanning for malicious files...");
449+
{
450+
bool foundMaliciousFiles = false;
451+
452+
foreach ((string filePath, LooseFileBlacklistEntryModel match) in modBlacklist.CheckLooseFiles(this.ModsPath))
453+
{
454+
foundMaliciousFiles = true;
455+
456+
this.Monitor.LogFatal("Malicious mod file detected.");
457+
this.Monitor.Log($"File path: '{filePath}'", LogLevel.Error);
458+
this.Monitor.Newline();
459+
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);
460+
}
461+
462+
if (foundMaliciousFiles)
463+
this.LogManager.PressAnyKeyToExit();
464+
}
465+
447466
// load mods
448467
{
449468
this.Monitor.Log("Loading mod metadata...", LogLevel.Debug);
@@ -743,7 +762,7 @@ private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action
743762
// conflict (e.g. collection changed during enumeration errors) and data may change
744763
// unexpectedly from one mod instruction to the next.
745764
//
746-
// Therefore we can just run Game1.Update here without raising any SMAPI events. There's
765+
// Therefore, we can just run Game1.Update here without raising any SMAPI events. There's
747766
// a small chance that the task will finish after we defer but before the game checks,
748767
// which means technically events should be raised, but the effects of missing one
749768
// update tick are negligible and not worth the complications of bypassing Game1.Update.

0 commit comments

Comments
 (0)