Skip to content

Commit d6f868e

Browse files
committed
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).
1 parent f090df0 commit d6f868e

24 files changed

Lines changed: 752 additions & 101 deletions

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ appsettings.Development.json
3333

3434
# auto-generated files
3535
src/SMAPI.Web/wwwroot/Content/data
36+
src/SMAPI.Web/wwwroot/SMAPI.blacklist.json
37+
src/SMAPI.Web/wwwroot/SMAPI.blacklist.json.tmp
3638
src/SMAPI.Web/Properties/PublishProfiles
3739
src/SMAPI.Web/Properties/ServiceDependencies
3840

‎build/deploy-local-smapi.targets‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ This assumes `find-game-folder.targets` has already been imported and validated.
1919
<Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFolder="$(GamePath)" Condition="$(OS) == 'Windows_NT'" />
2020
<Copy SourceFiles="$(TargetDir)\$(TargetName)" DestinationFolder="$(GamePath)" Condition="$(OS) != 'Windows_NT'" />
2121
<Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)" />
22-
<Copy SourceFiles="$(TargetDir)\SMAPI.blacklist.json" DestinationFiles="$(GamePath)\smapi-internal\blacklist.json" />
2322
<Copy SourceFiles="$(TargetDir)\SMAPI.config.json" DestinationFiles="$(GamePath)\smapi-internal\config.json" />
2423
<Copy SourceFiles="$(TargetDir)\SMAPI.metadata.json" DestinationFiles="$(GamePath)\smapi-internal\metadata.json" />
2524
<Copy SourceFiles="$(TargetDir)\Markdig.dll" DestinationFolder="$(GamePath)\smapi-internal" />

‎build/scripts/prepare-install-package.ps1‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,22 @@ foreach ($name in @("install on Linux.sh", "install on macOS.command", "install
186186
Copy-Item "$installAssets/$name" "$packagePath"
187187
}
188188

189+
# fetch 'malicious mods' blacklist
190+
# SMAPI fetches it from the same URL automatically, but this avoids known malicious mods being loaded on first launch.
191+
Write-Host "Fetching mod blacklist..."
192+
$blacklistUrl = "https://smapi.io/SMAPI.blacklist.json" # same as BlacklistUrl in SMAPI.config.json
193+
$blacklistPath = [System.IO.Path]::GetTempFileName()
194+
try {
195+
Invoke-WebRequest -Uri "$blacklistUrl" -OutFile "$blacklistPath" -TimeoutSec 30 -MaximumRetryCount 2 -RetryIntervalSec 5
196+
$blacklistData = Get-Content "$blacklistPath" -Raw | ConvertFrom-Json
197+
Write-Host " Fetched blacklist with $($blacklistData.Blacklist.Count) mod entries and $($blacklistData.LooseFileBlacklist.Count) loose file entries."
198+
}
199+
catch {
200+
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)"
201+
Remove-Item "$blacklistPath"
202+
$blacklistPath = $null
203+
}
204+
189205
# copy per-platform files
190206
foreach ($folder in $folders) {
191207
$runtime = $runtimes[$folder]
@@ -231,7 +247,9 @@ foreach ($folder in $folders) {
231247
Copy-Item "$smapiBin/VdfConverter.dll" "$bundlePath/smapi-internal"
232248
}
233249

234-
Copy-Item "$smapiBin/SMAPI.blacklist.json" "$bundlePath/smapi-internal/blacklist.json"
250+
if ($blacklistPath) {
251+
Copy-Item "$blacklistPath" "$bundlePath/smapi-internal/blacklist.json"
252+
}
235253
Copy-Item "$smapiBin/SMAPI.config.json" "$bundlePath/smapi-internal/config.json"
236254
Copy-Item "$smapiBin/SMAPI.metadata.json" "$bundlePath/smapi-internal/metadata.json"
237255
if ($folder -eq "linux" -or $folder -eq "macOS") {
@@ -263,6 +281,11 @@ foreach ($folder in $folders) {
263281
}
264282
}
265283

284+
# remove temporary files
285+
if ($blacklistPath) {
286+
Remove-Item "$blacklistPath"
287+
}
288+
266289
# mark scripts executable
267290
Write-Host "Setting file permissions..."
268291
if ($IsWindows) {

‎docs/release-notes.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* Improved performance.
77
* Improved error message when a mod is blocked by Windows Smart App Control.
88
* Improved translations. Thanks to To2morrow (updated Korean)!
9+
* Migrated to the revamped malware blacklist.
910
* Fixed rare edge case where a mod blacklist update could fail.
10-
* Updated internal mod blacklist.
1111

1212
* For mod authors:
1313
* Added OS metrics to the [metrics API](technical/web.md#modsmetrics).

‎docs/technical/web.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,14 +374,15 @@ Initial setup:
374374
property name | description
375375
------------------------------- | -----------------
376376
`ApiClients.AzureBlobConnectionString` | The connection string for the Azure Blob storage account created in step 2.
377-
`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.
377+
`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.
378378
`ApiClients:NexusApiKey` | The [Nexus API authentication key](https://github.com/Pathoschild/FluentNexus#init-a-client).
379379

380380
Optional settings:
381381

382382
property name | description
383383
------------------------------- | -----------------
384384
`BackgroundServices:Enabled` | Set to `true` to enable background processes like fetching data from the wiki, or false to disable them.
385+
`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.
385386
`Site:OtherBlurb` | A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format.
386387
`Site:SupporterList` | A list of Patreon supports to credit on the download page.
387388

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text.Json;
4+
using FluentAssertions;
5+
using NUnit.Framework;
6+
using StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
7+
8+
namespace SMAPI.Tests.Toolkit;
9+
10+
/// <summary>Unit tests for <see cref="MalwareBlacklistConverter"/>.</summary>
11+
[TestFixture]
12+
internal class MalwareBlacklistConverterTests
13+
{
14+
/*********
15+
** Fields
16+
*********/
17+
/// <summary>The default message key to test.</summary>
18+
private const string MessageKey = "RemoteCode";
19+
20+
21+
/*********
22+
** Unit tests
23+
*********/
24+
/****
25+
** Conversion
26+
****/
27+
[Test(Description = "Assert that a valid blacklist is converted into the expected public JSON.")]
28+
public void Conversion_ProducesExpectedPublicJson()
29+
{
30+
// arrange
31+
const string json = """
32+
{
33+
"$schema": "https://example.org/schema.json",
34+
"Format": 1,
35+
"Messages": {
36+
"RemoteCode": "Remote code message."
37+
},
38+
"Mods": [
39+
{
40+
"Added": "2026-01-01",
41+
"Notes": "ID only",
42+
"Match": {
43+
"Id": [ "Author.IdOnly" ]
44+
},
45+
"Message": "RemoteCode"
46+
},
47+
{
48+
"Added": "2026-01-02",
49+
"Notes": null,
50+
"Match": {
51+
"Id": [ "Author.A", "Author.B" ],
52+
"EntryDllMd5Hash": [ "00000000000000000000000000000001", "00000000000000000000000000000002" ]
53+
},
54+
"Message": "A literal message."
55+
},
56+
{
57+
"Added": "2026-01-03",
58+
"Notes": "hash only",
59+
"Match": {
60+
"EntryDllMd5Hash": [ "00000000000000000000000000000003" ]
61+
},
62+
"Message": "RemoteCode"
63+
}
64+
],
65+
"LooseFiles": [
66+
{
67+
"Added": "2026-01-04",
68+
"Notes": "name + hash",
69+
"Match": {
70+
"Name": [ "example.bat" ],
71+
"Md5Hash": [ "00000000000000000000000000000004" ]
72+
},
73+
"Message": "RemoteCode"
74+
},
75+
{
76+
"Added": "2026-01-05",
77+
"Notes": "extension only",
78+
"Match": {
79+
"Extension": [ ".scr" ]
80+
},
81+
"Message": "RemoteCode"
82+
}
83+
]
84+
}
85+
""";
86+
87+
// act
88+
string result = this.Convert(json);
89+
90+
// assert
91+
result.Should().Be(
92+
"""{"""
93+
+ "\"Blacklist\":["
94+
+ "{\"Id\":\"Author.IdOnly\",\"Message\":\"Remote code message.\"},"
95+
+ "{\"Id\":\"Author.A\",\"EntryDllHash\":\"00000000000000000000000000000001\",\"Message\":\"A literal message.\"},"
96+
+ "{\"Id\":\"Author.A\",\"EntryDllHash\":\"00000000000000000000000000000002\",\"Message\":\"A literal message.\"},"
97+
+ "{\"Id\":\"Author.B\",\"EntryDllHash\":\"00000000000000000000000000000001\",\"Message\":\"A literal message.\"},"
98+
+ "{\"Id\":\"Author.B\",\"EntryDllHash\":\"00000000000000000000000000000002\",\"Message\":\"A literal message.\"},"
99+
+ "{\"EntryDllHash\":\"00000000000000000000000000000003\",\"Message\":\"Remote code message.\"}"
100+
+ "],"
101+
+ "\"LooseFileBlacklist\":["
102+
+ "{\"Name\":\"example.bat\",\"Hash\":\"00000000000000000000000000000004\",\"Message\":\"Remote code message.\"},"
103+
+ "{\"Extension\":\".scr\",\"Message\":\"Remote code message.\"}"
104+
+ "]"
105+
+ "}"
106+
);
107+
}
108+
109+
[Test(Description = "Assert that the converted public JSON can be read by SMAPI's blacklist logic.")]
110+
public void Conversion_CanBeParsedByModBlacklist()
111+
{
112+
// arrange
113+
const string message = "This mod is silly & \"<malicious>\"."; // special characters
114+
string json = this.GetValidBlacklistJson(
115+
mod: new MalwareEntryModel<MalwareModMatchModel>(
116+
match: new MalwareModMatchModel(id: ["Author.Mod"], entryDllMd5Hash: []),
117+
message: message
118+
)
119+
);
120+
121+
// act
122+
string result = this.Convert(json);
123+
ModBlacklistModel? model = Newtonsoft.Json.JsonConvert.DeserializeObject<ModBlacklistModel>(result);
124+
ModBlacklist blacklist = new(model!);
125+
126+
// assert
127+
blacklist.CheckMod("author.mod", entryDllPath: null).Should().NotBeNull(); // should be case-insensitive
128+
blacklist.CheckMod("author.mod", entryDllPath: null)!.Message.Should().Be(message);
129+
blacklist.CheckMod("Author.OtherMod", entryDllPath: null).Should().BeNull();
130+
}
131+
132+
/****
133+
** Validation
134+
****/
135+
[Test(Description = "Assert that a blacklist with an unsupported format version is rejected.")]
136+
public void Conversion_RejectsUnsupportedFormat()
137+
{
138+
// arrange
139+
string json = this.GetValidBlacklistJson(format: 2);
140+
141+
// act
142+
Action act = () => this.Convert(json);
143+
144+
// assert
145+
act.Should().Throw<FormatException>().WithMessage("*unsupported format version 2*");
146+
}
147+
148+
[Test(Description = "Assert that a mod entry with no match conditions is rejected, since it would block every mod.")]
149+
[TestCase("null")]
150+
[TestCase("{}")]
151+
[TestCase("""{ "Md5Hash": [ "00000000000000000000000000000000" ] }""")] // mod with loose file fields
152+
[TestCase("""{ "Id": [] }""")]
153+
[TestCase("""{ "Id": [], "EntryDllMd5Hash": [] }""")]
154+
public void Convert_RejectsModEntryWithNoConditions(string match)
155+
{
156+
// arrange
157+
string json = this.GetValidBlacklistJson(
158+
mod: new MalwareEntryModel<MalwareModMatchModel>(
159+
match: JsonSerializer.Deserialize<MalwareModMatchModel>(match),
160+
message: MessageKey
161+
)
162+
);
163+
164+
// act
165+
Action act = () => this.Convert(json);
166+
167+
// assert
168+
act.Should().Throw<FormatException>().WithMessage("*avoid blocking every mod*");
169+
}
170+
171+
[Test(Description = "Assert that a loose file entry with no match conditions is rejected, since it would block every file.")]
172+
[TestCase("null")]
173+
[TestCase("{}")]
174+
[TestCase("""{ "Id": [ "Author.Mod" ] }""")] // loose file with mod fields
175+
[TestCase("""{ "Name": [], "Extension": [], "Md5Hash": [] }""")]
176+
public void Convert_RejectsLooseFileEntryWithNoConditions(string match)
177+
{
178+
// arrange
179+
string json = this.GetValidBlacklistJson(
180+
looseFile: new MalwareEntryModel<MalwareLooseFileMatchModel>(
181+
match: JsonSerializer.Deserialize<MalwareLooseFileMatchModel>(match),
182+
message: MessageKey
183+
)
184+
);
185+
186+
// act
187+
Action act = () => this.Convert(json);
188+
189+
// assert
190+
act.Should().Throw<FormatException>().WithMessage("*avoid blocking every file*");
191+
}
192+
193+
194+
/*********
195+
** Private methods
196+
*********/
197+
/// <summary>Parse a malware blacklist and convert it into the public JSON.</summary>
198+
/// <param name="json">The malware blacklist JSON.</param>
199+
private string Convert(string json)
200+
{
201+
MalwareListModel blacklist = MalwareBlacklistConverter.FromInternalFormat(json);
202+
ModBlacklistModel publicModel = MalwareBlacklistConverter.ToPublicModel(blacklist);
203+
return MalwareBlacklistConverter.ToPublicJson(publicModel);
204+
}
205+
206+
/// <summary>Get the JSON for a malware blacklist with a single mod entry.</summary>
207+
/// <param name="format">The format version.</param>
208+
/// <param name="mod">The mod entry to blacklist, or <c>null</c> for a default entry which matches <c>Author.Mod</c>.</param>
209+
/// <param name="looseFile">The loose file to blacklist, or <c>null</c> for an empty loose files list.</param>
210+
private string GetValidBlacklistJson(int format = 1, MalwareEntryModel<MalwareModMatchModel>? mod = null, MalwareEntryModel<MalwareLooseFileMatchModel>? looseFile = null)
211+
{
212+
return JsonSerializer.Serialize(
213+
new MalwareListModel(
214+
format: format,
215+
messages: new Dictionary<string, string>
216+
{
217+
[MessageKey] = "Remote code message"
218+
},
219+
mods: [
220+
mod ?? new MalwareEntryModel<MalwareModMatchModel>(
221+
match: new MalwareModMatchModel(id: ["Author.Mod"], entryDllMd5Hash: []),
222+
message: MessageKey
223+
)
224+
],
225+
looseFiles: looseFile != null
226+
? [looseFile]
227+
: []
228+
)
229+
);
230+
}
231+
}

‎src/SMAPI.Toolkit/Framework/ModBlacklistData/LooseFileBlacklistEntryModel.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public class LooseFileBlacklistEntryModel
2727
/// <param name="extension"><inheritdoc cref="Extension" path="/summary"/></param>
2828
/// <param name="hash"><inheritdoc cref="Hash" path="/summary"/></param>
2929
/// <param name="message"><inheritdoc cref="Message" path="/summary"/></param>
30-
public LooseFileBlacklistEntryModel(string? name, string? extension, string? hash, string message)
30+
public LooseFileBlacklistEntryModel(string? name, string? extension, string? hash, string? message)
3131
{
3232
this.Name = name;
3333
this.Extension = extension;

0 commit comments

Comments
 (0)