Skip to content

Vulnerability Score rule type rebuilt on the library's scoring model; lat/long equals against a database (#52, #49) - #59

Merged
sean-e-dietrich merged 5 commits into
mainfrom
feat/52-vulnerability-score
Sep 30, 2026
Merged

sean-e-dietrich merged 5 commits into
mainfrom
feat/52-vulnerability-score

Conversation

@sean-e-dietrich

@sean-e-dietrich sean-e-dietrich commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #52
Fixes #49

Builds on #58. #58 was squash-merged to main before this branch was cut, so the branch is based on main and shows only its own commits. main (with #40, rc.5) is merged in.

Summary

#52: Vulnerability Score, rebuilt on what the library reads

Kanopi\Firewall\Plugins\VulnerabilityScore reads only these:

  • config.risk_levels: name → {threshold, block, status_code?, expiration_time?}. A request's level is the one with the highest threshold its total reaches. The plugin matches only if that level has block: true. The level's status code and ban length override the rule's through getStatusCode() / getExpirationTime().
  • config.scoring.methods (method → score), patterns (list of {pattern, score, type, locations}, each counted once), user_agents (list of {pattern, score, type}, first match only).
  • config.scoring.countries, only with metadata.country_reader (it calls country(), so it needs a Country database; a City one is refused). config.scoring.asn and asn_patterns (first matching substring), only with metadata.asn_reader (GeoLite2-ASN).
  • Regexes go through the library's safeRegexMatch(), which needs the same delimiter at both ends.

The form has these fields, each stored in the library's own shape and typed as lines:

  • Risk levels: name threshold match [status] [ban] or name threshold pass.
  • Scores, a fields group: method, attack patterns (score type where pattern), user agent (score type pattern), country, network, network organisation.
  • Databases, a fields group: the Country and ASN paths.

Compiling only re-keys: empty signals are left out, and a reader is written only when a signal needs it. The defaults are a working subset of the library's malicious-requests preset.

Validation refuses:

  • a regex without matching delimiters, or one that doesn't compile
  • an unknown location or match type
  • a non-numeric score, a bad method, country code or ASN
  • a matching level at 0 or below, or no matching level at all
  • two levels with the same name or the same threshold
  • a pass level above a matching one
  • a status code outside 400–599
  • country or network scores with no database

Missing, unreadable or wrong-type databases are reported by check_requirements().

Stored {threshold, weights} rules: upgrade routine 12 (schema 12):

  • Threshold with no weights: translated into one level that matches at that threshold, with the default scores. The rule keeps its response and its enabled state.
  • Any weights: not translated. A weight was one number for a whole signal, and the library scores each method, country or pattern separately, so any mapping would be a guess. The rule is kept exactly as it was and switched off.

The WITHDRAWN registry machinery is gone. The old shape is the safety net everywhere a rule is handled:

  • the compiler skips it and names it (enabled or not), so it shows on the Status screen and in Site Health
  • the validator returns it unchanged
  • the rule list says "Saved before the rebuild"
  • the rule screen explains it through check_requirements()

Rule screen: the textarea sanitiser stripped < and %xx from patterns on every save. Fields a type marks verbatim are now read as typed, and the type's validator still checks them.

#49: latitude/longitude equals

Checked against 2.35.1: EvaluateTrait::evaluateComparison() still compares equals, not_equals and in strictly, and #423 coerces only the ASN. So against a MaxMind database (floats), equals never matched and not_equals matched everyone.

The fix casts the value to a float at compile time, for those operators on location.latitude / location.longitude, when the rule reads a database. It also sets case_sensitive: true, because the library otherwise lowercases an in list back to strings. Header sources are left alone: CDN headers are strings, and string against string already works.

I chose this over withdrawing equals: existing rules start working as written, and there's nothing to migrate. Coordinates in a referenced list are filled in after compile and stay text; the README says so.

Testing

  • Unit: 326 OK. composer analyse: no errors. composer lint: clean.

  • Integration (DDEV): 628 tests OK. The 2 warnings and 19 skips are pre-existing, from the environment.

    • RuleTypesHonouredTest: vulnerability_score is in the coverage enumeration, which now reads the settings each form offers (identical for every other type). The withdrawn exception is removed.
      • test_vulnerability_score: a request above a matching level matches and one below doesn't, evaluated through the real library, with the per-level status and ban, first-match user agent, and a pass level. The effective risk_levels / scoring are read back off the plugin object.
      • test_vulnerability_score_databases: the compiled readers, and country + ASN + organisation scoring through Fake_Geo_Reader (which gains country()).
      • A data provider of 16 refusals.
      • An untranslatable legacy rule is kept, skipped and named, enabled or not.
    • UpgradeRoutinesTest: routine 12 translates or switches off, and is idempotent.
    • ScreenRoundTripTest: a full vulnerability score rule (fractional threshold, integer ASN keys, negative and fractional scores, encoded patterns) survives render → post → store twice.
    • GeoVariableTest: equals, not_equals, in and gt on both coordinates against the database record and CloudFront headers, through a YAML round trip. The database cases fail without the cast.
  • E2E: 13 OK. CLI (tests/cli/commands.sh): 36 passed. check-versions: OK (rc.5). build-zip: OK, verify-scope OK.

  • DDEV basic_firewall_* options: unchanged apart from the schema version, 11 → 12, and compile/refresh timestamps.

  • Was AI used in this pull request?

…del (#52)

Withdrawn from 1.0 because it compiled {threshold, weights}, which the
library's VulnerabilityScore plugin never reads. The rebuilt type stores
exactly what the plugin reads, in its own shape: risk_levels (threshold,
block, and a status code and ban length per matching level) and
scoring.methods, patterns, user_agents, countries, asn and asn_patterns,
with country_reader and asn_reader opened only for a signal that uses
them. Defaults are a working subset of the library's malicious-requests
preset. Validation refuses what the library would reject or ignore:
undelimited or uncompilable regexes, unknown locations and match types,
a matching level at or below zero, duplicate names or thresholds, a pass
level above a matching one, and country or network scores with no
database.

Upgrade routine 12 (schema 12) translates a stored rule with a threshold
and no weights into one matching level at that threshold with the
default scores. A rule with weights has no sound mapping, so it is kept
as it was, switched off, and named by the compiler on the Status screen
and in Site Health, enabled or not. The withdrawn-type registry
machinery is gone; the old shape is the safety net everywhere a rule is
handled.

The rule screen reads fields a type marks verbatim as typed: the
textarea sanitiser stripped < and %xx out of patterns on every save.

Tests: honoured-settings coverage for every field against the real
library (above and below a matching level, per-level status and ban,
first-match user agent and organisation, config read back off the
plugin), refusals, the upgrade routine, and a screen round trip.
…base (fixes #49)

A MaxMind record holds the coordinates as floats, a compiled value is
text, and the library still compares equals, not_equals and in strictly
in 2.35.1 (only the ASN is coerced), so latitude equals never matched a
database and not_equals matched every visitor. A database-backed rule
now compiles those values as floats, case-sensitive so the library does
not lowercase an in list back to text. A CDN-backed rule is left alone:
its headers are text, which already compares correctly.

Cast rather than withdrawing equals from the screen, so rules that use
it start working as written instead of needing rewriting.

Tests run equals, not_equals and in on both coordinates against the
fake City record and CloudFront's headers, through a YAML round trip.
…#49)

README: the type back in the rule types table (ten), a section on risk
levels, scores, what the form refuses and why, the defaults, and how
rules saved before 1.1 are translated; coordinates compared as numbers
against a database and as text from a CDN. readme.txt feature list and
CHANGELOG Unreleased (upgrade notes, Added, Fixed). The weight hint on
the rule screen names vulnerability scoring again.
Merging main brought in the rc.5 release, which renamed the previous
[Unreleased] to [1.0.0-rc.5]; this branch's entries landed under it.
They move to a fresh [Unreleased], and the rc.5 ASN entry's note that
latitude/longitude equals is not part of that release is restored.
@sean-e-dietrich
sean-e-dietrich marked this pull request as ready for review September 30, 2026 23:09
@sean-e-dietrich
sean-e-dietrich merged commit 0b1f4f6 into main Sep 30, 2026
14 checks passed
@sean-e-dietrich
sean-e-dietrich deleted the feat/52-vulnerability-score branch September 30, 2026 23:44
sean-e-dietrich added a commit that referenced this pull request Oct 1, 2026
Every stated version says 1.0.0-rc.6. [Unreleased] becomes [1.0.0-rc.6]
-- #59 (#52, #49), #61 (#60, #54) and #62 -- with a release-candidate lead
and a fresh [Unreleased] above it, and readme.txt's changelog gets a
matching entry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant