wow-secret-lint 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/action/index.mjs +141 -0
- package/action.yml +32 -0
- package/bin/wow-secret-lint.mjs +197 -0
- package/data/api-snapshot.json +1 -0
- package/package.json +55 -0
- package/src/analyze.mjs +1013 -0
- package/src/apidata.mjs +362 -0
- package/src/index.mjs +164 -0
- package/src/luaparse.mjs +13 -0
- package/src/report.mjs +108 -0
- package/src/rules.mjs +153 -0
- package/src/toc.mjs +171 -0
- package/vendor/LICENSE.luaparse +20 -0
- package/vendor/luaparse.cjs +2742 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Booyaka101
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# wow-secret-lint
|
|
2
|
+
|
|
3
|
+
Static analysis for World of Warcraft **retail** addons. It finds Secret Value violations in your Lua before a player finds them in a red error frame.
|
|
4
|
+
|
|
5
|
+
Patch 12.0 introduced [secret values](https://warcraft.wiki.gg/wiki/Secret_Values). A lot of the API now hands your addon a value you are allowed to store, pass around and print, but not to do arithmetic on, compare, index, call, or measure with `#`. When tainted code does one of those, the wiki is blunt about the result:
|
|
6
|
+
|
|
7
|
+
> When an operation that is not allowed is performed, the result will be an **immediate** Lua error.
|
|
8
|
+
|
|
9
|
+
The failure is invisible until it happens at runtime, in someone else's game, in a stack trace that points at a Blizzard file:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
...UIWidgets/Blizzard_UIWidgetTemplateTextWithState.lua:35: attempt to perform arithmetic
|
|
13
|
+
on local 'textHeight' (a secret number value, while execution tainted by 'KkthnxUI')
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That one is [KkthnxUI#121](https://github.com/Kkthnx-Wow/KkthnxUI/issues/121), filed 2026-08-23. The identical trace was filed the same week against a completely unrelated addon, [aura-questor#68](https://github.com/lucascodev/aura-questor/issues/68). Both are in this repo's regression corpus.
|
|
17
|
+
|
|
18
|
+
`wow-secret-lint` reads Blizzard's own generated API documentation, tracks which of your locals hold a secret, and tells you where you touch one.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install --save-dev wow-secret-lint
|
|
24
|
+
# or run it once
|
|
25
|
+
npx wow-secret-lint ./MyAddon
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Node 20 or newer. No network access during a lint run: the API snapshot is vendored in the package.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Point it at an addon folder and it reads the `.toc` files to decide which Lua to analyse, in load order, following `<Script>` and `<Include>` entries in any listed `.xml`.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx wow-secret-lint ./MyAddon
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`Core/UnitFrame.lua`:
|
|
39
|
+
|
|
40
|
+
```lua
|
|
41
|
+
local hp = UnitHealth("target")
|
|
42
|
+
local max = UnitHealthMax("target")
|
|
43
|
+
local pct = hp / max * 100
|
|
44
|
+
if hp < max then frame:Show() end
|
|
45
|
+
frame.text:SetText(string.format("%s hp", hp))
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
$ npx wow-secret-lint Core/UnitFrame.lua
|
|
50
|
+
Core/UnitFrame.lua:3:13 error WSL001 arithmetic on a secret value: 'hp' derives from UnitHealth() (SecretReturns=true)
|
|
51
|
+
Core/UnitFrame.lua:4:4 error WSL002 comparison of a secret value: 'hp' derives from UnitHealth() (SecretReturns=true)
|
|
52
|
+
2 errors, 0 warnings
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Exit code 1. Line 5 is deliberately silent: the wiki says calling `string.format` with a secret is fine, so flagging it would be a false positive.
|
|
56
|
+
|
|
57
|
+
Add the guard and it goes quiet:
|
|
58
|
+
|
|
59
|
+
```lua
|
|
60
|
+
if not issecretvalue(hp) and not issecretvalue(max) then
|
|
61
|
+
local pct = hp / max * 100
|
|
62
|
+
if hp < max then frame:Show() end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
$ npx wow-secret-lint Core/Guarded.lua
|
|
68
|
+
0 errors, 0 warnings
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Exit code 0.
|
|
72
|
+
|
|
73
|
+
### Exit codes
|
|
74
|
+
|
|
75
|
+
| Code | Meaning |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| 0 | no error-severity findings |
|
|
78
|
+
| 1 | at least one error, or warnings above `--max-warnings` |
|
|
79
|
+
| 2 | a file failed to parse, or a usage/runtime failure |
|
|
80
|
+
|
|
81
|
+
## GitHub Action
|
|
82
|
+
|
|
83
|
+
If you already run [BigWigsMods/luacheck](https://github.com/BigWigsMods/luacheck), this sits next to it. Two lines:
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
jobs:
|
|
87
|
+
luacheck:
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
steps:
|
|
90
|
+
- uses: actions/checkout@v7
|
|
91
|
+
- name: Luacheck linter
|
|
92
|
+
uses: BigWigsMods/luacheck@main
|
|
93
|
+
with:
|
|
94
|
+
args: -q
|
|
95
|
+
+ - name: Secret value linter
|
|
96
|
+
+ uses: Booyaka101/wow-secret-lint@v1
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Findings render as pull request annotations on the exact line, plus a table in the job summary.
|
|
100
|
+
|
|
101
|
+
Inputs:
|
|
102
|
+
|
|
103
|
+
| Input | Default | Description |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `path` | `.` | addon folder, `.toc`, or `.lua`. Several allowed, space separated |
|
|
106
|
+
| `args` | `""` | extra CLI arguments, e.g. `--conditional=warn --disable=WSL011` |
|
|
107
|
+
| `format` | `github` | `github`, `stylish`, or `json` |
|
|
108
|
+
|
|
109
|
+
Outputs: `errors`, `warnings`.
|
|
110
|
+
|
|
111
|
+
## Rules
|
|
112
|
+
|
|
113
|
+
Every error-level rule maps to one sentence Blizzard publishes. `wow-secret-lint --rules` prints the table with the source for each.
|
|
114
|
+
|
|
115
|
+
| Rule | Severity | What it catches |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| WSL001 | error | arithmetic on a secret value |
|
|
118
|
+
| WSL002 | error | relational or equality comparison of a secret value |
|
|
119
|
+
| WSL003 | error | calling a secret value as if it were a function |
|
|
120
|
+
| WSL004 | error | length operator `#` on a secret value |
|
|
121
|
+
| WSL005 | error | indexed access, indexed assignment, or a secret used as a table key |
|
|
122
|
+
| WSL006 | error | secret passed to an API whose `SecretArguments` does not allow it |
|
|
123
|
+
| WSL007 | error | boolean test on a secret whose documented return type is `bool` |
|
|
124
|
+
| WSL008 | error | registering `COMBAT_LOG_EVENT` or `COMBAT_LOG_EVENT_UNFILTERED` |
|
|
125
|
+
| WSL009 | warning | secret crosses into a function in this file that never guards it |
|
|
126
|
+
| WSL010 | warning | conditionally secret value used with no guard anywhere in scope |
|
|
127
|
+
| WSL011 | warning | `tostring()` on a secret value |
|
|
128
|
+
|
|
129
|
+
WSL008 comes from the [12.0.0 API changes](https://warcraft.wiki.gg/wiki/Patch_12.0.0/API_changes): *"COMBAT_LOG_EVENT and COMBAT_LOG_EVENT_UNFILTERED will error when trying to register them."* Use `COMBAT_LOG_EVENT_INTERNAL_UNFILTERED` or the `C_CombatLog` namespace.
|
|
130
|
+
|
|
131
|
+
WSL011 is a warning on purpose. `tostring` is absent from the wiki's allowed list but is not listed as forbidden either, so it stays a warning until someone confirms it in game.
|
|
132
|
+
|
|
133
|
+
### What it will never flag
|
|
134
|
+
|
|
135
|
+
The wiki has an explicit allowed list, and flagging any of it is a false positive that gets a linter uninstalled. Each of these has a passing negative test in `test/`:
|
|
136
|
+
|
|
137
|
+
- storing a secret in a variable, an upvalue, or as a value in a table
|
|
138
|
+
- passing a secret to a Lua function
|
|
139
|
+
- concatenating string or number secrets with `..`
|
|
140
|
+
- calling `string.concat`, `string.format` or `string.join` with a secret
|
|
141
|
+
- boolean tests on non-boolean secrets, e.g. `if UnitHealth(unit) then`
|
|
142
|
+
|
|
143
|
+
That last one is why WSL007 checks the documented return type before it fires. `UnitInRange` returns a `bool` and is `SecretReturns = true`, so `if UnitInRange(u) then` is an error. `UnitHealth` returns a `number`, so `if hp then` is silent.
|
|
144
|
+
|
|
145
|
+
### Guards
|
|
146
|
+
|
|
147
|
+
Taint is cleared inside a branch guarded by `issecretvalue`, `canaccessvalue`, `issecrettable`, `canaccesstable` or `hasanysecretvalues`, by a frame's `HasSecretValues` / `HasSecretAspect`, and at any `scrubsecretvalues` or `secretwrap` boundary. `and`-chains, `else` branches and early returns all work:
|
|
148
|
+
|
|
149
|
+
```lua
|
|
150
|
+
if issecretvalue(hp) then return end
|
|
151
|
+
local pct = hp / max * 100 -- silent, the early return guarded it
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Almost every real addon wraps the raw guards. KkthnxUI ships `IsSecret`, BigWigs ships `self:IsSecret`. Any callee whose name starts with `is`/`has`/`hasany` + `secret`, or `canaccess`, is recognised as a guard automatically. For a wrapper with an unrelated name, pass it explicitly:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npx wow-secret-lint ./MyAddon --secret-guard=IsLocked --access-guard=CanRead
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Conditionally secret APIs
|
|
161
|
+
|
|
162
|
+
Blizzard marks a second tier in the generated docs: functions that return a secret only while a specific restriction is active. `C_Spell.GetSpellCooldown` carries `SecretWhenCooldownsRestricted`; `C_LFGList.GetSearchResultInfo` carries `SecretInChatMessagingLockdown`; `UnitGUID`, `UnitName` and `UnitClass` carry `SecretWhenUnitIdentityRestricted`. In practice that means "secret in PvP, in restricted instances, and for non-player or pet units in combat". Your code works fine right up until it does not.
|
|
163
|
+
|
|
164
|
+
That tier is **off by default**, because measured against 12 real addons it produces about one warning per Lua file, which is a tax rather than a signal. Turn it on for a deeper audit:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
npx wow-secret-lint ./MyAddon --conditional=warn # report as warnings
|
|
168
|
+
npx wow-secret-lint ./MyAddon --conditional=error # fail the build on them
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
It is precise about structure fields. Blizzard marks safe fields `NeverSecret = true`, so this reports line 13 and stays quiet on line 10:
|
|
172
|
+
|
|
173
|
+
```lua
|
|
174
|
+
local cooldown = C_Spell.GetSpellCooldown(spellID)
|
|
175
|
+
if not cooldown.isEnabled then -- isEnabled is NeverSecret, silent
|
|
176
|
+
return false
|
|
177
|
+
end
|
|
178
|
+
if cooldown.startTime ~= 0 then -- startTime is not, reported
|
|
179
|
+
return false
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
$ npx wow-secret-lint --conditional=warn Talents.lua
|
|
185
|
+
Talents.lua:13:5 warning WSL002 comparison of a secret value: 'cooldown.startTime' derives from C_Spell.GetSpellCooldown() (conditionally secret: SecretWhenCooldownsRestricted)
|
|
186
|
+
0 errors, 1 warning
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
That is [BtWLoadouts#67](https://github.com/Breeni/BtWLoadouts/issues/67), reduced to its shape.
|
|
190
|
+
|
|
191
|
+
## Measured false positives
|
|
192
|
+
|
|
193
|
+
Run against 12 real retail addons at default settings (BigWigs, LittleWigs, DBM, WeakAuras, Details, SpartanUI, KkthnxUI, oUF, Bartender4, Premade Groups Filter, BtWLoadouts, AdvancedInterfaceOptions): **2,209 Lua files, 118 errors, 0 warnings**, which is 0.053 findings per file. Six of the twelve addons are affected.
|
|
194
|
+
|
|
195
|
+
Every one of the 118 was read against its source line by hand. **The false-positive count is 0.** They are overwhelmingly the same two shapes:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
UnitHealth(uId) / UnitHealthMax(uId) * 100 WSL001
|
|
199
|
+
frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED") WSL008
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Three of the twelve report nothing at all, and KkthnxUI is one of them: it already wraps its reads in `IsSecret`, and the guard heuristic sees that.
|
|
203
|
+
|
|
204
|
+
With `--conditional=warn` the same corpus produces 118 errors and 2,166 warnings (1.03 per file). That number is why the tier is opt-in, and it is the honest cost of auditing the conditional surface.
|
|
205
|
+
|
|
206
|
+
Blizzard's own `BlizzardInterfaceCode` (2,274 files) is also in the corpus as a parser stress test: **0 parse errors**. Its findings are not violations, because Blizzard's code runs untainted.
|
|
207
|
+
|
|
208
|
+
## Configuration
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
--format=<stylish|json|github> output format (default: stylish)
|
|
212
|
+
--game=<retail|classic> classic has no secret values and exits 0 immediately
|
|
213
|
+
--conditional=<off|warn|error> conditionally secret APIs (default: off)
|
|
214
|
+
--secret-guard=<names> extra is-secret wrapper functions, comma separated
|
|
215
|
+
--access-guard=<names> extra can-access wrapper functions, comma separated
|
|
216
|
+
--disable=<ids> rule ids to silence, e.g. WSL010,WSL011
|
|
217
|
+
--max-warnings=<n> exit 1 when warnings exceed n
|
|
218
|
+
--snapshot=<path> use a different API snapshot
|
|
219
|
+
--refresh rebuild the vendored API snapshot (the only networked command)
|
|
220
|
+
--rules print the rule table with its sources
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Where the data comes from
|
|
224
|
+
|
|
225
|
+
`data/api-snapshot.json` is built from Blizzard's generated API documentation, mirrored at [Gethe/wow-ui-source](https://github.com/Gethe/wow-ui-source) on the `live` branch. The current snapshot carries **10,098 documented functions and 752 structures**: 20 with `SecretReturns = true`, 310 conditionally secret, and per-field `NeverSecret` markers on 20 structures.
|
|
226
|
+
|
|
227
|
+
It is parsed with `luaparse`, not regexed, so nested tables and multi-line entries cannot skew it. Rebuild it any time:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
npx wow-secret-lint --refresh
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A scheduled workflow in this repo does that weekly and opens a pull request when the docs move.
|
|
234
|
+
|
|
235
|
+
## Limitations and non-goals
|
|
236
|
+
|
|
237
|
+
- **No auto-fixing.** It tells you where; the fix is yours.
|
|
238
|
+
- **No runtime component and no in-game addon.** This is a build-time linter.
|
|
239
|
+
- **No Classic support.** Classic has no secret values, so `--game=classic` exits 0 immediately.
|
|
240
|
+
- **No cross-file interprocedural analysis in v1.** Taint follows plain assignment, table field stores, the return value of a file-local function, and one level of intra-file call-argument passing. A secret that leaves through a global and comes back in another file is not tracked.
|
|
241
|
+
- **No LuaJIT or Lua 5.4 syntax.** Files are parsed as Lua 5.1. WoW accepts a semicolon after `break`, which stock 5.1 does not, so a file that fails on 5.1 gets one retry under the 5.2 grammar before it is reported as a parse error.
|
|
242
|
+
- **Method calls are not resolved to a widget type**, so WSL006 only applies to plain and namespaced calls (`UnitHealth(...)`, `C_CVar.SetCVar(...)`), never to `frame:SetText(...)`.
|
|
243
|
+
- **`string.format` output is not tracked as secret.** The wiki names it as the sanctioned way to render a secret, and following it would flood every `SetText` call site. The trade is a known blind spot on `#string.format(...)`.
|
|
244
|
+
- A `.toc` listing a file that is not on disk warns and keeps going. A file that will not parse is reported and the run exits 2.
|
|
245
|
+
|
|
246
|
+
## Tests
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
npm test
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
123 tests. The suite covers every rule, the guard forms, the permitted-operations negative cases, the three reporters, the CLI surface, and eight regression fixtures reconstructed from real shipped traces:
|
|
253
|
+
|
|
254
|
+
| Fixture | Issue |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `kkthnxui-121-textheight-arithmetic` | [KkthnxUI#121](https://github.com/Kkthnx-Wow/KkthnxUI/issues/121) |
|
|
257
|
+
| `kkthnxui-119-map-icon-arithmetic` | [KkthnxUI#119](https://github.com/Kkthnx-Wow/KkthnxUI/issues/119) |
|
|
258
|
+
| `kkthnxui-118-secret-table-key` | [KkthnxUI#118](https://github.com/Kkthnx-Wow/KkthnxUI/issues/118) |
|
|
259
|
+
| `aura-questor-68-textheight-arithmetic` | [aura-questor#68](https://github.com/lucascodev/aura-questor/issues/68) |
|
|
260
|
+
| `betterfriendlist-133-missing-hassecretvalues-gate` | [BetterFriendlist#133](https://github.com/Hayato2846/BetterFriendlist/issues/133) |
|
|
261
|
+
| `premade-groups-filter-399-searchresult-index` | [premade-groups-filter#399](https://github.com/0xbs/premade-groups-filter/issues/399) |
|
|
262
|
+
| `btwloadouts-67-unguarded-cooldown-compare` | [BtWLoadouts#67](https://github.com/Breeni/BtWLoadouts/issues/67) |
|
|
263
|
+
| `combat-log-event-registration` | [Patch 12.0.0 API changes](https://warcraft.wiki.gg/wiki/Patch_12.0.0/API_changes) |
|
|
264
|
+
|
|
265
|
+
Each fixture reproduces the shape of the reported defect, not the exact runtime taint chain, and every one carries an `expected.json` pinning the rule id, line, severity and originating API. The header comment in each `input.lua` quotes the original trace.
|
|
266
|
+
|
|
267
|
+
## Contributing
|
|
268
|
+
|
|
269
|
+
A false positive is a bug and worth an issue. Include the Lua, the rule id, and what the API actually returns. A rule that fires on correct code is worse than a rule that misses.
|
|
270
|
+
|
|
271
|
+
## License
|
|
272
|
+
|
|
273
|
+
MIT
|
package/action/index.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// GitHub Action entrypoint. Runs the linter in-process against the checked-out workspace
|
|
2
|
+
// and writes annotations plus a job summary. No install step, so src/ resolves luaparse
|
|
3
|
+
// from vendor/ (see src/luaparse.mjs).
|
|
4
|
+
|
|
5
|
+
import { appendFile } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { lintPaths, VERSION } from '../src/index.mjs';
|
|
10
|
+
import { format, FORMATS, counts } from '../src/report.mjs';
|
|
11
|
+
import { RULE_IDS } from '../src/rules.mjs';
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
function input(name, fallback = '') {
|
|
16
|
+
const key = `INPUT_${name.toUpperCase().replace(/ /g, '_')}`;
|
|
17
|
+
const v = process.env[key];
|
|
18
|
+
return v === undefined || v === '' ? fallback : v;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function splitArgs(raw) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = re.exec(raw)) !== null) out.push(m[1] ?? m[2] ?? m[3]);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function setOutput(name, value) {
|
|
30
|
+
const file = process.env.GITHUB_OUTPUT;
|
|
31
|
+
if (!file) return;
|
|
32
|
+
await appendFile(file, `${name}=${value}\n`, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function summary(markdown) {
|
|
36
|
+
const file = process.env.GITHUB_STEP_SUMMARY;
|
|
37
|
+
if (!file) return;
|
|
38
|
+
await appendFile(file, markdown, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fail(message) {
|
|
42
|
+
process.stdout.write(`::error::${message.replace(/\r?\n/g, '%0A')}\n`);
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const paths = splitArgs(input('path', '.'));
|
|
47
|
+
const extra = splitArgs(input('args', ''));
|
|
48
|
+
const fmt = input('format', 'github');
|
|
49
|
+
|
|
50
|
+
if (!FORMATS.includes(fmt)) fail(`unknown format "${fmt}" (expected one of: ${FORMATS.join(', ')})`);
|
|
51
|
+
|
|
52
|
+
const options = { conditional: 'off', disable: [], secretGuards: [], accessGuards: [], game: 'retail' };
|
|
53
|
+
let maxWarnings = Infinity;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < extra.length; i++) {
|
|
56
|
+
const a = extra[i];
|
|
57
|
+
const eq = a.indexOf('=');
|
|
58
|
+
const key = eq === -1 ? a : a.slice(0, eq);
|
|
59
|
+
const val = () => (eq === -1 ? extra[++i] : a.slice(eq + 1));
|
|
60
|
+
switch (key) {
|
|
61
|
+
case '--conditional':
|
|
62
|
+
options.conditional = val();
|
|
63
|
+
break;
|
|
64
|
+
case '--game':
|
|
65
|
+
options.game = val();
|
|
66
|
+
break;
|
|
67
|
+
case '--disable':
|
|
68
|
+
options.disable.push(...val().split(',').map((s) => s.trim()).filter(Boolean));
|
|
69
|
+
break;
|
|
70
|
+
case '--secret-guard':
|
|
71
|
+
options.secretGuards.push(...val().split(',').map((s) => s.trim()).filter(Boolean));
|
|
72
|
+
break;
|
|
73
|
+
case '--access-guard':
|
|
74
|
+
options.accessGuards.push(...val().split(',').map((s) => s.trim()).filter(Boolean));
|
|
75
|
+
break;
|
|
76
|
+
case '--max-warnings':
|
|
77
|
+
maxWarnings = Number(val());
|
|
78
|
+
break;
|
|
79
|
+
case '--snapshot':
|
|
80
|
+
options.snapshotPath = val();
|
|
81
|
+
break;
|
|
82
|
+
default:
|
|
83
|
+
fail(`unknown value in "args": ${key}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!['off', 'warn', 'error'].includes(options.conditional)) {
|
|
88
|
+
fail(`unknown --conditional "${options.conditional}" (expected off, warn or error)`);
|
|
89
|
+
}
|
|
90
|
+
if (!['retail', 'classic'].includes(options.game)) {
|
|
91
|
+
fail(`unknown --game "${options.game}" (expected retail or classic)`);
|
|
92
|
+
}
|
|
93
|
+
for (const id of options.disable) {
|
|
94
|
+
if (!RULE_IDS.includes(id)) fail(`unknown rule id "${id}" in --disable`);
|
|
95
|
+
}
|
|
96
|
+
if (Number.isNaN(maxWarnings)) fail('--max-warnings needs a number');
|
|
97
|
+
|
|
98
|
+
if (options.game === 'classic') {
|
|
99
|
+
process.stdout.write('::notice::classic has no secret values; nothing to check\n');
|
|
100
|
+
await setOutput('errors', 0);
|
|
101
|
+
await setOutput('warnings', 0);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
options.snapshotPath ??= join(HERE, '..', 'data', 'api-snapshot.json');
|
|
106
|
+
|
|
107
|
+
let merged;
|
|
108
|
+
try {
|
|
109
|
+
merged = await lintPaths(paths, options);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
fail(err.message);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.stdout.write(format(merged, fmt) + '\n');
|
|
115
|
+
|
|
116
|
+
const { errors, warnings } = counts(merged);
|
|
117
|
+
await setOutput('errors', errors);
|
|
118
|
+
await setOutput('warnings', warnings);
|
|
119
|
+
|
|
120
|
+
const rows = merged.findings
|
|
121
|
+
.slice(0, 50)
|
|
122
|
+
.map((f) => `| \`${f.file}:${f.line}:${f.column}\` | ${f.severity} | ${f.ruleId} | ${f.message.replace(/\|/g, '\\|')} |`)
|
|
123
|
+
.join('\n');
|
|
124
|
+
|
|
125
|
+
await summary(
|
|
126
|
+
`## wow-secret-lint ${VERSION}\n\n` +
|
|
127
|
+
`${merged.filesScanned} Lua file(s) scanned against ${merged.snapshot.functionCount ?? 0} documented APIs ` +
|
|
128
|
+
`(${merged.snapshot.secretReturnCount ?? 0} with \`SecretReturns=true\`).\n\n` +
|
|
129
|
+
`**${errors} error(s), ${warnings} warning(s)**` +
|
|
130
|
+
(merged.parseErrors.length ? `, ${merged.parseErrors.length} parse error(s)` : '') +
|
|
131
|
+
'\n\n' +
|
|
132
|
+
(rows
|
|
133
|
+
? `| Location | Severity | Rule | Message |\n| --- | --- | --- | --- |\n${rows}\n` +
|
|
134
|
+
(merged.findings.length > 50 ? `\n_${merged.findings.length - 50} more not shown._\n` : '')
|
|
135
|
+
: '_No findings._\n')
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (merged.parseErrors.length) process.exit(2);
|
|
139
|
+
if (errors > 0) process.exit(1);
|
|
140
|
+
if (warnings > maxWarnings) process.exit(1);
|
|
141
|
+
process.exit(0);
|
package/action.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: 'wow-secret-lint'
|
|
2
|
+
description: 'Find WoW retail addon Secret Value violations in Lua before they ship.'
|
|
3
|
+
author: 'Booyaka101'
|
|
4
|
+
branding:
|
|
5
|
+
icon: 'shield'
|
|
6
|
+
color: 'purple'
|
|
7
|
+
|
|
8
|
+
inputs:
|
|
9
|
+
path:
|
|
10
|
+
description: 'Addon folder, .toc, or .lua file to lint. Accepts several, space separated.'
|
|
11
|
+
required: false
|
|
12
|
+
default: '.'
|
|
13
|
+
args:
|
|
14
|
+
description: 'Extra CLI arguments, e.g. "--conditional=warn --disable=WSL011".'
|
|
15
|
+
required: false
|
|
16
|
+
default: ''
|
|
17
|
+
format:
|
|
18
|
+
description: 'Output format: github (annotations), stylish, or json.'
|
|
19
|
+
required: false
|
|
20
|
+
default: 'github'
|
|
21
|
+
|
|
22
|
+
outputs:
|
|
23
|
+
errors:
|
|
24
|
+
description: 'Number of error-severity findings.'
|
|
25
|
+
value: ${{ steps.lint.outputs.errors }}
|
|
26
|
+
warnings:
|
|
27
|
+
description: 'Number of warning-severity findings.'
|
|
28
|
+
value: ${{ steps.lint.outputs.warnings }}
|
|
29
|
+
|
|
30
|
+
runs:
|
|
31
|
+
using: 'node20'
|
|
32
|
+
main: 'action/index.mjs'
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wow-secret-lint CLI.
|
|
3
|
+
//
|
|
4
|
+
// Exit codes: 0 clean, 1 findings at error severity, 2 parse error or usage/runtime failure.
|
|
5
|
+
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import { lintPaths, VERSION } from '../src/index.mjs';
|
|
8
|
+
import { format, FORMATS, counts } from '../src/report.mjs';
|
|
9
|
+
import { refreshSnapshot, writeSnapshot, SNAPSHOT_PATH } from '../src/apidata.mjs';
|
|
10
|
+
import { RULES, RULE_IDS } from '../src/rules.mjs';
|
|
11
|
+
|
|
12
|
+
const USAGE = `wow-secret-lint ${VERSION}
|
|
13
|
+
Static analysis for World of Warcraft retail addons: finds Secret Value violations
|
|
14
|
+
in Lua before they ship.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
wow-secret-lint [options] <path>...
|
|
18
|
+
|
|
19
|
+
<path> an addon folder (its .toc files decide the file list), a .toc, or a .lua file.
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--format=<stylish|json|github> output format (default: stylish)
|
|
23
|
+
--game=<retail|classic> classic has no secret values and exits 0 immediately
|
|
24
|
+
--conditional=<off|warn|error> how to treat APIs Blizzard marks secret only under a
|
|
25
|
+
runtime restriction, e.g. SecretWhenCooldownsRestricted
|
|
26
|
+
or SecretInChatMessagingLockdown (default: off)
|
|
27
|
+
--secret-guard=<names> extra is-secret wrapper functions, comma separated.
|
|
28
|
+
Names matching is*secret/has*secret are detected already.
|
|
29
|
+
--access-guard=<names> extra can-access wrapper functions, comma separated
|
|
30
|
+
--disable=<ids> comma-separated rule ids to silence, e.g. WSL010,WSL011
|
|
31
|
+
--max-warnings=<n> exit 1 when warnings exceed n (default: unlimited)
|
|
32
|
+
--snapshot=<path> use a different API snapshot
|
|
33
|
+
--refresh rebuild the vendored API snapshot from the public mirror
|
|
34
|
+
(the only command that uses the network)
|
|
35
|
+
--rules print the rule table and exit
|
|
36
|
+
--version print the version and exit
|
|
37
|
+
-h, --help print this help and exit
|
|
38
|
+
|
|
39
|
+
Rules: ${RULE_IDS.join(' ')}
|
|
40
|
+
Docs: https://github.com/Booyaka101/wow-secret-lint
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
function fail(message, code = 2) {
|
|
44
|
+
process.stderr.write(`wow-secret-lint: ${message}\n`);
|
|
45
|
+
process.exit(code);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const opts = {
|
|
50
|
+
format: 'stylish',
|
|
51
|
+
game: 'retail',
|
|
52
|
+
conditional: 'off',
|
|
53
|
+
disable: [],
|
|
54
|
+
secretGuards: [],
|
|
55
|
+
accessGuards: [],
|
|
56
|
+
maxWarnings: Infinity,
|
|
57
|
+
snapshot: undefined,
|
|
58
|
+
refresh: false,
|
|
59
|
+
rules: false,
|
|
60
|
+
help: false,
|
|
61
|
+
version: false,
|
|
62
|
+
paths: [],
|
|
63
|
+
};
|
|
64
|
+
for (let i = 0; i < argv.length; i++) {
|
|
65
|
+
const arg = argv[i];
|
|
66
|
+
if (arg === '--') {
|
|
67
|
+
opts.paths.push(...argv.slice(i + 1));
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
if (arg === '-h' || arg === '--help') opts.help = true;
|
|
71
|
+
else if (arg === '--version' || arg === '-v') opts.version = true;
|
|
72
|
+
else if (arg === '--refresh') opts.refresh = true;
|
|
73
|
+
else if (arg === '--rules') opts.rules = true;
|
|
74
|
+
else if (arg.startsWith('--format')) opts.format = value(arg, argv, () => i++);
|
|
75
|
+
else if (arg.startsWith('--game')) opts.game = value(arg, argv, () => i++);
|
|
76
|
+
else if (arg.startsWith('--conditional')) opts.conditional = value(arg, argv, () => i++);
|
|
77
|
+
else if (arg.startsWith('--secret-guard')) opts.secretGuards.push(...value(arg, argv, () => i++).split(',').map((s) => s.trim()).filter(Boolean));
|
|
78
|
+
else if (arg.startsWith('--access-guard')) opts.accessGuards.push(...value(arg, argv, () => i++).split(',').map((s) => s.trim()).filter(Boolean));
|
|
79
|
+
else if (arg.startsWith('--disable')) opts.disable = value(arg, argv, () => i++).split(',').map((s) => s.trim()).filter(Boolean);
|
|
80
|
+
else if (arg.startsWith('--max-warnings')) opts.maxWarnings = Number(value(arg, argv, () => i++));
|
|
81
|
+
else if (arg.startsWith('--snapshot')) opts.snapshot = value(arg, argv, () => i++);
|
|
82
|
+
else if (arg.startsWith('-')) throw new Error(`unknown option "${arg}"`);
|
|
83
|
+
else opts.paths.push(arg);
|
|
84
|
+
|
|
85
|
+
function value(a, list, bump) {
|
|
86
|
+
const eq = a.indexOf('=');
|
|
87
|
+
if (eq !== -1) return a.slice(eq + 1);
|
|
88
|
+
bump();
|
|
89
|
+
const next = list[i];
|
|
90
|
+
if (next === undefined) throw new Error(`option "${a}" needs a value`);
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return opts;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main() {
|
|
98
|
+
let opts;
|
|
99
|
+
try {
|
|
100
|
+
opts = parseArgs(process.argv.slice(2));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
process.stderr.write(`wow-secret-lint: ${err.message}\n\n${USAGE}`);
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (opts.help) {
|
|
107
|
+
process.stdout.write(USAGE);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (opts.version) {
|
|
111
|
+
process.stdout.write(`${VERSION}\n`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
if (opts.rules) {
|
|
115
|
+
for (const id of RULE_IDS) {
|
|
116
|
+
process.stdout.write(`${id} ${RULES[id].severity.padEnd(7)} ${RULES[id].summary}\n ${RULES[id].source}\n`);
|
|
117
|
+
}
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (opts.refresh) {
|
|
122
|
+
process.stderr.write('rebuilding API snapshot from Gethe/wow-ui-source@live ...\n');
|
|
123
|
+
let index;
|
|
124
|
+
try {
|
|
125
|
+
let last = 0;
|
|
126
|
+
index = await refreshSnapshot({
|
|
127
|
+
onProgress: ({ done, total }) => {
|
|
128
|
+
if (done - last >= 50 || done === total) {
|
|
129
|
+
last = done;
|
|
130
|
+
process.stderr.write(` ${done}/${total} documentation files\n`);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
fail(`--refresh failed: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
const path = await writeSnapshot(index, opts.snapshot ?? SNAPSHOT_PATH);
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
`wrote ${path}: ${index.functionCount} functions, ${index.secretReturnCount} with SecretReturns=true, ` +
|
|
140
|
+
`${index.conditionalCount} conditionally secret, ${index.structureCount} structures\n`
|
|
141
|
+
);
|
|
142
|
+
if (index.failures && index.failures.length) {
|
|
143
|
+
process.stderr.write(` ${index.failures.length} file(s) could not be read: ${index.failures.map((f) => f.file).join(', ')}\n`);
|
|
144
|
+
}
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!FORMATS.includes(opts.format)) fail(`unknown format "${opts.format}" (expected one of: ${FORMATS.join(', ')})`);
|
|
149
|
+
if (!['retail', 'classic'].includes(opts.game)) fail(`unknown game "${opts.game}" (expected retail or classic)`);
|
|
150
|
+
if (!['warn', 'error', 'off'].includes(opts.conditional)) {
|
|
151
|
+
fail(`unknown --conditional "${opts.conditional}" (expected warn, error or off)`);
|
|
152
|
+
}
|
|
153
|
+
for (const id of opts.disable) {
|
|
154
|
+
if (!RULE_IDS.includes(id)) fail(`unknown rule id "${id}" in --disable (known: ${RULE_IDS.join(', ')})`);
|
|
155
|
+
}
|
|
156
|
+
if (Number.isNaN(opts.maxWarnings)) fail('--max-warnings needs a number');
|
|
157
|
+
if (!opts.paths.length) {
|
|
158
|
+
process.stderr.write(`wow-secret-lint: no path given\n\n${USAGE}`);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (opts.game === 'classic') {
|
|
163
|
+
if (opts.format === 'stylish') process.stdout.write('classic has no secret values; nothing to check\n');
|
|
164
|
+
else if (opts.format === 'github') process.stdout.write('::notice::classic has no secret values; nothing to check\n');
|
|
165
|
+
else process.stdout.write(`${JSON.stringify({ version: VERSION, game: 'classic', findings: [], parseErrors: [], summary: { errors: 0, warnings: 0, parseErrors: 0 } }, null, 2)}\n`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let merged;
|
|
170
|
+
try {
|
|
171
|
+
merged = await lintPaths(opts.paths, {
|
|
172
|
+
game: opts.game,
|
|
173
|
+
conditional: opts.conditional,
|
|
174
|
+
disable: opts.disable,
|
|
175
|
+
secretGuards: opts.secretGuards,
|
|
176
|
+
accessGuards: opts.accessGuards,
|
|
177
|
+
snapshotPath: opts.snapshot,
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
fail(err.message);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
process.stdout.write(format(merged, opts.format) + '\n');
|
|
184
|
+
|
|
185
|
+
const { errors, warnings } = counts(merged);
|
|
186
|
+
if (merged.parseErrors.length) return 2;
|
|
187
|
+
if (errors > 0) return 1;
|
|
188
|
+
if (warnings > opts.maxWarnings) return 1;
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
main()
|
|
193
|
+
.then((code) => process.exit(code))
|
|
194
|
+
.catch((err) => {
|
|
195
|
+
process.stderr.write(`wow-secret-lint: unexpected failure: ${err && err.stack ? err.stack : err}\n`);
|
|
196
|
+
process.exit(2);
|
|
197
|
+
});
|