Conversation
The banned call list only matched functions by name, so a submission could
reach the same capability without ever writing the name:
$f = 'ass' . 'ert';
$f($_POST['x']);
Nothing in that is a banned call, and it passed. Three rules close it.
SRC_DYNAMIC_CALL now covers calling a variable, not only call_user_func. The
severity comes from how the variable was built: assembled from concatenated
literals or from a decoder is an error, anything else is a warning so a
closure held in a variable still passes.
SRC_DYNAMIC_INCLUDE reports include and require. Request data reaching either
is a remote code execution and is an error, a path built from constants such
as PATH_PLUGINS is a warning. Bludit includes plugin.php before any controller
runs, so this one decides whether the site boots.
SRC_UNSERIALIZE reports unserialize. On a superglobal it is object injection
and an error, on the plugin's own data it is a warning.
Also fixes backticks being reported twice, once for each delimiter, since the
opening and the closing token are identical.
Measured against every bundled plugin and all of bl-kernel: no new errors, and
one correct warning on search/plugin.php requiring its vendored Fuzz library.
tests/corpus holds twelve plugins, nine attacks and three legitimate patterns,
with tests/expected.json pinning what each has to report. A rule that stops
matching would otherwise fail nothing at all, every submission would just
start passing. Verified by disabling a rule and watching the corpus fail.
The submission is the record a maintainer reviews and merges, the zip is what a site installs. Only version and compatible were compared, so the other seven shared fields were whatever the author typed. author, website, license, compatible, version, releaseDate and type are now compared against metadata.json, and name and description against plugin-data in languages/en.json. A difference is an error, the directory must not advertise anything the plugin does not ship. Every plugin bundled with Bludit carries all six metadata fields, so requiring them costs a real author nothing. The description cap goes from 200 to 300 characters so an exact match is reachable, the longest bundled description is 204. tests/payload pins the new rules the way tests/corpus pins the source rules, including the cases that must stay silent: an absent type, and a submission that agrees with its zip.
index.json is now built from the submission alone. A listing can never change because an author replaced a release asset, and a maintainer approves exactly what gets published. The zip is still compared against the submission, as a warning for the reviewer instead of an error. metadata.json without version or compatible stays an error, PluginInstaller refuses to install a plugin without them. The filename is the id, so the field is gone and build_index.py adds it the way it already adds sha256 and size. releaseDate is gone too, nothing read it and the tag in download carries it. description takes one line per language keyed by a Bludit language code, en required as the fallback, instead of one flat field. Suffixed keys like description_es were how the previous repository did it and every consumer ended up parsing key names. price_in_usd lists a plugin sold elsewhere. Bludit cannot install an asset it has to pay for, so a priced submission carries no download, gets no checksum, is never analyzed, and is hidden from the admin panel. The two impossible combinations are refused: a price with a download, and neither. The dependency free fallback validator learned objects and numbers, and agrees with jsonschema on every case tested.
…blishing Nothing tested check_submission, so no required field was actually covered. tests/submission-expected.json is one case per field: each required field missing, each format rule broken, the derived fields supplied by hand, the two impossible price and download combinations, and five submissions that have to be accepted. Every case runs twice, once with jsonschema and once with the fallback, and a disagreement between the two fails. That caught the fallback not checking arrays at all, so tags like ["Not A Slug"] passed locally and only failed in the pull request. Writing the cases also showed ID_DUPLICATE could no longer fire. The id is the filename now, so two submissions cannot share one, the filesystem keeps them unique. Removed rather than left looking like a check. build_index.py re-validates every submission before writing index.json. The pull request is gated, but the index is generated from these files, so a file that reached main any other way must not publish itself.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
BANNED_CALLSmatched functions by name, so a submission could reach the same capability without writing the name:No banned call appears anywhere in that, and it passed the analyzer.
Measured against a corpus of nine attacks, the analyzer caught six. These three were the misses.
The rules
SRC_DYNAMIC_CALLnow covers calling a variable, not onlycall_user_func. Severity depends on how the variable was built — assembled from concatenated literals or fed by a decoder is an error, anything else is a warning, so a closure held in a variable still passes.SRC_DYNAMIC_INCLUDEreportsincludeandrequire. Request data reaching either is an error; a path built from constants likePATH_PLUGINSis a warning. This one matters more in Bludit than elsewhere —buildPlugins()includes everyplugin.phpbefore any controller runs, so it decides whether the site boots at all.SRC_UNSERIALIZEreportsunserialize. On a superglobal it is object injection and an error; on the plugin's own data it is a warning.Also fixes backticks reporting
SRC_SHELLtwice — the opening and closing tokens are identical, so both fired.False positives
Swept every bundled plugin and all of
bl-kernel:bl-plugins/(29 plugins): one finding, a warning onsearch/plugin.php:679requiring its vendored Fuzz library from$this->phpPath(). Correct — computed path, routed to a human, does not block.bl-kernel/: 14 findings, all warnings, zero errors.joditstill passes with the same two suggestions as before.Why the corpus
A rule that quietly stops matching fails nothing — every submission simply starts passing. That is the same shape as the
analyze.ymlshallow-fetch bug, where a stale merge base made the analyzer report "no submission changed" and label the PR ready to merge without looking at it.tests/corpusholds twelve plugins (nine attacks, three legitimate patterns) andtests/expected.jsonpins exactly what each must report, including the files that must report nothing.selftest.ymlruns it on every change toscripts/,tests/orrules/.Verified the suite is not vacuous by disabling the include rule and confirming
03-lfi.phpfails.Considered and rejected: Semgrep
Ran Semgrep 1.175.0 with
p/php+p/security-auditover the same corpus. It caught 3 of 9, a strict subset of whatanalyze.pyalready found, and missed theeval(base64_decode(...))dropper. Not worth the CI dependency.CodeRabbit and Copilot code review don't fit here either — they review the PR diff, which in this repo is a single JSON file. The plugin source is downloaded at CI time and isn't in the diff at all. They belong on
bludit/bluditand thebludit-extensionsplugin repos, where real source changes.Second commit: the submission has to match the zip
The submission is the record a maintainer reviews and merges. The zip is what a site installs. Only
versionandcompatiblewere compared, so the other seven shared fields were whatever the author typed.version,compatiblemetadata.jsonauthor,website,license,releaseDate,typemetadata.jsonname,descriptionplugin-datainlanguages/en.jsonAll of them are errors now. The directory must not advertise anything the plugin does not ship, and there is no reading of a mismatch that isn't either a mistake or a misrepresentation.
This is not hypothetical — it already happened in PR #1, the first submission to this repo:
The directory would have shown one description and the admin panel another. Nothing caught it.
False positives
Every one of the 29 plugins bundled with Bludit carries all six
metadata.jsonfields, so requiring them costs a real author nothing. The description cap goes from 200 to 300 characters because an exact match has to be reachable and the longest bundled description is 204.typeis the one field that is legitimately absent — 22 of the 29 omit it — so an absenttypeis compared against the submission's"".Tests
tests/payloadpins these rules the waytests/corpuspins the source rules: a submission next to the extracted zip it claims to describe, one case per field, plus the three cases that must stay silent (an agreeing submission, an absenttype, a declaredtype).Verified not vacuous by disabling both comparisons: 9 of the 14 cases fail.