Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Close three source analysis gaps and pin the rules with a corpus - #2

Open
dignajar wants to merge 5 commits into
mainfrom
feat/source-rules-and-selftest
Open

dignajar wants to merge 5 commits into
mainfrom
feat/source-rules-and-selftest

Conversation

@dignajar

@dignajar dignajar commented Sep 2, 2026

Copy link
Copy Markdown
Member

The gap

BANNED_CALLS matched functions by name, so a submission could reach the same capability without writing the name:

$f = 'ass' . 'ert';
$f($_POST['x']);

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_CALL now covers calling a variable, not only call_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_INCLUDE reports include and require. Request data reaching either is an error; a path built from constants like PATH_PLUGINS is a warning. This one matters more in Bludit than elsewhere — buildPlugins() includes every plugin.php before any controller runs, so it decides whether the site boots at all.

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 reporting SRC_SHELL twice — 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 on search/plugin.php:679 requiring 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.

jodit still 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.yml shallow-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/corpus holds twelve plugins (nine attacks, three legitimate patterns) and tests/expected.json pins exactly what each must report, including the files that must report nothing. selftest.yml runs it on every change to scripts/, tests/ or rules/.

Verified the suite is not vacuous by disabling the include rule and confirming 03-lfi.php fails.

Considered and rejected: Semgrep

Ran Semgrep 1.175.0 with p/php + p/security-audit over the same corpus. It caught 3 of 9, a strict subset of what analyze.py already found, and missed the eval(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/bludit and the bludit-extensions plugin 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 version and compatible were compared, so the other seven shared fields were whatever the author typed.

Submission field Compared against Before
version, compatible metadata.json ✅ error
author, website, license, releaseDate, type metadata.json ❌ not compared
name, description plugin-data in languages/en.json ❌ had to exist, never compared

All 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:

submission:  HTML editor for formatting content, a lightweight WYSIWYG alternative to Markdown.
en.json:     HTML Editor for formatting content. Lightweight WYSIWYG alternative for the
             users who don't want to work with Markdown code.

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.json fields, 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.

type is the one field that is legitimately absent — 22 of the 29 omit it — so an absent type is compared against the submission's "".

Tests

tests/payload pins these rules the way tests/corpus pins 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 absent type, a declared type).

Verified not vacuous by disabling both comparisons: 9 of the 14 cases fail.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant