Skip to content

fix(signals): cap globToRegExp wildcard count to prevent ReDoS - #2445

Merged
JSONbored merged 3 commits into
claude/pr-b-content-lane-genericityfrom
claude/pr-c-guardrail-redos-hardening
Jul 2, 2026
Merged

fix(signals): cap globToRegExp wildcard count to prevent ReDoS#2445
JSONbored merged 3 commits into
claude/pr-b-content-lane-genericityfrom
claude/pr-c-guardrail-redos-hardening

Conversation

@JSONbored

@JSONbored JSONbored commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Summary

  • globToRegExp (src/signals/change-guardrail.ts) compiles a maintainer-facing path glob to a RegExp using chained [^/]* groups for each single-segment * wildcard. The compiled pattern's .test() call is exponential-time on an adversarial near-miss input once multiple wildcards chain in one glob — empirically verified: 5 chained wildcards against a 300-char adversarial input took ~19 seconds; 3 stayed under 5ms at the same length.
  • hardGuardrailGlobs (src/review/guardrail-config.ts) is 100% hardcoded engine constants today (no maintainer/contributor input reaches it), so there is no live exploit path — but globToRegExp is exported and reused elsewhere in the review engine (content-lane/spec-resolver.ts, from refactor(content-lane): remove metagraphed-specific hardcoding from the registry-review engine #2443), so this hardens the function itself rather than relying on every future caller to separately remember the risk.
  • The cap lives inside globToRegExp itself, not just in matchesAny's wrapper. An earlier revision only guarded the wildcard count inside matchesAny, leaving the exported globToRegExp free to compile and .test() a pathological glob if called directly — exactly what content-lane/spec-resolver.ts does. globToRegExp now short-circuits an over-complex glob (MAX_GLOB_WILDCARDS = 6) to a never-matching sentinel RegExp (/^(?!)$/) instead of compiling it, so every caller — present or future, direct or indirect — is protected automatically. "Never matches" is the correct general-purpose default (a false "matches everything" would misclassify unrelated files for a non-guardrail caller like content-lane's file-scope matching); matchesAny keeps its own hasUnsafeWildcardCount pre-check to override to the opposite fail direction ("matches everything", forcing a guardrail hold) for its specific safety-critical semantics.
  • Verified none of the ~10 real hardcoded guardrail globs in guardrail-config.ts exceed 2 wildcards, so this is a zero-behavior-change hardening for current production config.

Scope

Validation

  • git diff --check
  • npm run actionlint
  • npm run typecheck
  • npm run test:coverage locally; codecov/patch requires ≥99% coverage of the lines AND branches you changed (aim for 100% on your diff so CI variance does not fail near the threshold). Global coverage is a non-blocking trend with a loose 90% backstop, not the gate.
  • npm run test:workers
  • npm run build:mcp
  • npm run test:mcp-pack
  • npm run ui:openapi:check
  • npm run ui:lint
  • npm run ui:typecheck
  • npm run ui:build
  • npm audit --audit-level=moderate
  • New or changed behavior has unit/integration tests for new branches, fallback paths, and sanitizer boundaries

Safety

  • No secrets, wallet details, hotkeys, coldkeys, user PATs, private keys, raw trust scores, private rankings, or private maintainer evidence are exposed.
  • Public GitHub text stays sanitized, low-noise, and does not imply compensation guarantees or optimization tactics.
  • Auth, cookie, CORS, GitHub App, Cloudflare, or session changes include negative-path tests. (N/A — no auth/session/CORS surface touched.)
  • API/OpenAPI/MCP behavior is updated and tested where needed. (N/A — internal glob-matching helper only.)
  • UI changes use live API data or real empty/error/loading states, not production mock/demo fallbacks. (N/A — no UI changes.)
  • Visible UI changes include a UI Evidence section below with JPG/JPEG or PNG screenshots. (N/A — no UI changes.)
  • Public docs/changelogs are updated where needed; changelogs are only edited for release-prep PRs. (N/A — no public-facing behavior change.)

Notes

@dosubot dosubot Bot added the size:S label Jul 2, 2026

@superagent-security superagent-security Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superagent found 1 security concern(s).

Comment thread src/signals/change-guardrail.ts Outdated
@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jul 2, 2026
@loopover-orb

loopover-orb Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Tip

🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩

✅ Gittensory review result - approve/merge recommended

Review updated: 2026-07-02 01:32:30 UTC

2 files · 1 AI reviewer · no blockers · readiness 86/100 · CI green · clean

✅ Suggested Action - Approve/Merge

  • safe to merge

Review summary
The change moves the ReDoS guard into `globToRegExp`, so direct callers get a never-matching sentinel for over-complex globs while guardrail matching deliberately fails toward holding by treating those globs as matching every path. The implementation matches the compiler tokenization closely enough for `*`, `**`, and `**/`, and the added tests cover both direct compiler behavior and guardrail-specific semantics. The most notable tradeoff is that a configured guardrail glob with three wildcard groups now becomes a repo-wide hold, which is intentional here but worth keeping explicitly documented.

Nits — 5 non-blocking
  • nit: `src/signals/change-guardrail.ts:15` has a very large benchmark/rationale comment embedded in production code; keep the invariant and threshold rationale here, but move detailed timings and path-shape examples into the test or a linked issue to reduce maintenance drift.
  • nit: `test/unit/change-guardrail.test.ts:11` uses wall-clock timing with `Date.now() - start < 1000`, which is useful as a smoke check but can be noisy on overloaded runners; prefer asserting the sentinel behavior directly and keep perf timing in a dedicated benchmark if this flakes.
  • nit: `test/unit/change-guardrail.test.ts:139` says `scripts/**` is exactly two stars in the comment while the production counter treats that as one wildcard group, so the wording can confuse the boundary being tested.
  • In `src/signals/change-guardrail.ts:15`, trim the production comment to the rule: `MAX_GLOB_WILDCARD_GROUPS = 2` because three groups has measured unsafe backtracking, and point benchmark detail to the test/issue.
  • In `test/unit/change-guardrail.test.ts:20`, make the direct-call security test assert that unsafe globs return the same sentinel behavior for matching and non-matching inputs, without relying on a one-second wall-clock threshold.
Signal Result Evidence
Code review ✅ No blockers 1 reviewer
Linked issue ⚠️ Missing No linked issue or no-issue rationale found.
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (no linked issue context).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 65 registered-repo PR(s), 55 merged, 563 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 65 PR(s), 563 issue(s).
Gate result ✅ Passing No configured blocker found.
Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository registration is not available in the local Gittensory cache.
  • Public profile languages: not available
  • Official Gittensor activity: 65 PR(s), 563 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Explain no-issue PR.
  • Triage stale or unlinked PRs.
  • No action.
  • Link the issue being solved, or explicitly explain why this is a no-issue PR.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by Gittensory, a quiet PR intelligence layer for OSS maintainers.

  • Re-run Gittensory review

@loopover-orb loopover-orb Bot added gittensor gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. labels Jul 2, 2026
@JSONbored JSONbored self-assigned this Jul 2, 2026
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.92%. Comparing base (2ca08ee) to head (9768697).
⚠️ Report is 1 commits behind head on claude/pr-b-content-lane-genericity.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@                         Coverage Diff                          @@
##           claude/pr-b-content-lane-genericity    #2445   +/-   ##
====================================================================
  Coverage                                95.91%   95.92%           
====================================================================
  Files                                      225      225           
  Lines                                    25290    25302   +12     
  Branches                                  9201     9205    +4     
====================================================================
+ Hits                                     24258    24270   +12     
  Misses                                     419      419           
  Partials                                   613      613           
Files with missing lines Coverage Δ
src/signals/change-guardrail.ts 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

hardGuardrailGlobs (src/review/guardrail-config.ts) and any future
maintainer-supplied glob compiled via globToRegExp are vulnerable to
catastrophic backtracking on chained `*` wildcards: 5 chained
wildcards against a 300-char adversarial path took ~19 seconds.

Cap wildcard count at compile time (MAX_GLOB_WILDCARDS = 6, well
above any of the ~10 real guardrail globs today). An over-complex
glob is treated as matching every path rather than failing open,
since a guardrail's job is to force manual review on uncertainty —
mirroring isGuardrailHit's existing "unknown ⇒ treat as a hit"
fail-safe direction.
@JSONbored
JSONbored force-pushed the claude/pr-b-content-lane-genericity branch from 23e3fcd to 2ca08ee Compare July 2, 2026 01:00
@JSONbored
JSONbored force-pushed the claude/pr-c-guardrail-redos-hardening branch from 7eb67a1 to 55e5bff Compare July 2, 2026 01:00
The wildcard-count guard previously lived only in matchesAny's
wrapper, so any other direct caller of the exported globToRegExp
(e.g. content-lane/spec-resolver.ts) could still compile and .test()
a pathological glob and hit catastrophic backtracking — the exact
gap this PR set out to close.

globToRegExp now short-circuits an over-complex glob to a
never-matching sentinel regex instead of compiling it, so every
caller (present or future, direct or indirect) is protected
automatically. "Never matches" (not "matches everything") is the
correct default for the general-purpose compiler, since a false
"matches everything" would misclassify unrelated files for a
non-guardrail caller; matchesAny keeps its own override to the
opposite fail direction for guardrail semantics specifically.
@dosubot dosubot Bot added size:M and removed size:S labels Jul 2, 2026
@superagent-security superagent-security Bot removed size:M pr:flagged PR flagged for review by security analysis. labels Jul 2, 2026
…DoS cap

The flagged blocker was correct: MAX_GLOB_WILDCARDS=6 let the PR's own
motivating example (5 chained wildcards, empirically catastrophic) through
uncapped. Lowering the raw-character cap to 2 fixed that but broke a real
consumer -- content-lane/spec-resolver.ts's artifactGlob "public/**/*.json"
has 3 raw '*' characters (** counts as two) despite being empirically
instant even against a 4,000-char adversarial path, because a `**`
globstar compiles to a single .* group, not two independent wildcards.

Re-benchmarked with the right unit (wildcard GROUPS, where a ** pair is
one group): 2 groups stays sub-second even at 32,000 adversarial chars; 3
groups is already dangerous (over 2s at ~4,000 chars for a chained-*
shape, over 100ms at ~1,600 chars for a chained-** shape); 4+ groups is
catastrophic (35s at 1,614 chars for 4 chained ** groups). Caps
MAX_GLOB_WILDCARD_GROUPS at 2 -- the highest value proven safe -- via a
new countWildcardGroups that mirrors globToRegExp's own tokenization
(consuming a ** pair, and its trailing /, as one group), so both the
original flagged case and real 2-group globs like public/**/*.json are
handled correctly.
@dosubot dosubot Bot added the size:M label Jul 2, 2026
@JSONbored
JSONbored merged commit 47f71f1 into claude/pr-b-content-lane-genericity Jul 2, 2026
9 checks passed
@JSONbored
JSONbored deleted the claude/pr-c-guardrail-redos-hardening branch July 2, 2026 01:35
@github-project-automation github-project-automation Bot moved this from Todo to Done in gittensory - v1 roadmap Jul 2, 2026
JSONbored added a commit that referenced this pull request Jul 2, 2026
…he registry-review engine (#2443)

* refactor(content-lane): remove metagraphed-specific hardcoding from the registry-review engine

gittensory is meant to be installed by any self-hosted repo maintainer, not
just JSONbored/metagraphed. The RegistryLaneSpec abstraction was already
generic, but nothing let a second maintainer actually reach it without
editing gittensory's own TypeScript source and redeploying.

- Rename runMetagraphedSurfaceGate to runRegistrySurfaceGate; stop
  re-exporting netuid-verification.ts's Bittensor-only helpers from the
  generic content-lane barrel (closes #2433).
- Add assessAppendedEntry/assessProviderEntry callback fields to
  RegistryLaneSpec; the orchestrator calls the spec-supplied validators
  instead of hardcoded imports, so a different registry can supply its own
  domain validator without touching shared engine code (closes #2434).
- Add a contentLane: block to .gittensory.yml (FocusManifestContentLaneConfig)
  and a resolveRegistryLaneSpec resolver mirroring resolveConvergedFeature's
  precedence (env kill-switch -> per-repo config -> allowlist default), so a
  second maintainer's registry repo can activate the deterministic surface
  lane purely from their own config, with today's zero-config behavior for
  metagraphed unchanged (closes #2435).
- Glob fields (entryFileGlob/providerFileGlob/artifactGlob) are capped at
  parse time to a safe wildcard count: an adversarial-review pass on this
  change found the shared glob-to-RegExp compiler is exponential-time on
  chained wildcards (empirically ~19s at 5 chained wildcards), so the new
  config surface rejects an over-complex glob before it ever reaches RegExp
  compilation.

* fix(content-lane): warn on an unregistered contentLane.validatorId

buildRegistryLaneSpecFromConfig already degraded an unregistered
validatorId to structural-only gating silently (a legitimate mode for
a registry with no validator yet), which made an operator typo (e.g.
"metagraph" instead of "metagraphed") indistinguishable from a
deliberate choice — no signal reached the maintainer.

Add unregisteredValidatorId()/registeredValidatorIds() to
spec-resolver.ts and surface a non-blocking advisory finding from
evaluateWithSurfaceLane so a bad validatorId shows up directly in the
PR comment, naming the offending id and the known registered ids.

Also switch the REGISTRY_VALIDATORS lookup to Object.hasOwn instead of
bracket-truthiness, so a validatorId matching an inherited
Object.prototype key (e.g. "toString") is correctly reported as
unregistered rather than silently matching a prototype method.

* fix(content-lane): hold the gate on an unreadable manifest, reject over-long globs

Two gaps flagged in evaluateWithSurfaceLane and normalizeOptionalGlob:

- A non-allowlisted repo's ONLY way to configure a registry content
  lane is its own .gittensory.yml. A manifest-load failure was caught
  as null and silently treated the same as "no contentLane configured"
  -- letting a registry-submission PR merge unevaluated on nothing
  more than a transient read blip. Now holds the gate neutral in that
  specific case (never overriding a real generic hard blocker, which
  is always preserved).

- An over-long contentLane glob (entryFileGlob/providerFileGlob/
  artifactGlob) was truncated to MAX_ITEM_LENGTH and still returned,
  silently compiling a DIFFERENT file-scope pattern than configured.
  Now rejected outright, matching the function's own doc comment and
  the established pattern used elsewhere in this file.

* fix(signals): cap globToRegExp wildcard count to prevent ReDoS (#2445)

* fix(signals): cap globToRegExp wildcard count to prevent ReDoS

hardGuardrailGlobs (src/review/guardrail-config.ts) and any future
maintainer-supplied glob compiled via globToRegExp are vulnerable to
catastrophic backtracking on chained `*` wildcards: 5 chained
wildcards against a 300-char adversarial path took ~19 seconds.

Cap wildcard count at compile time (MAX_GLOB_WILDCARDS = 6, well
above any of the ~10 real guardrail globs today). An over-complex
glob is treated as matching every path rather than failing open,
since a guardrail's job is to force manual review on uncertainty —
mirroring isGuardrailHit's existing "unknown ⇒ treat as a hit"
fail-safe direction.

* fix(signals): bake the ReDoS wildcard cap into globToRegExp itself

The wildcard-count guard previously lived only in matchesAny's
wrapper, so any other direct caller of the exported globToRegExp
(e.g. content-lane/spec-resolver.ts) could still compile and .test()
a pathological glob and hit catastrophic backtracking — the exact
gap this PR set out to close.

globToRegExp now short-circuits an over-complex glob to a
never-matching sentinel regex instead of compiling it, so every
caller (present or future, direct or indirect) is protected
automatically. "Never matches" (not "matches everything") is the
correct default for the general-purpose compiler, since a false
"matches everything" would misclassify unrelated files for a
non-guardrail caller; matchesAny keeps its own override to the
opposite fail direction for guardrail semantics specifically.

* fix(signals): count wildcard GROUPS, not raw * characters, for the ReDoS cap

The flagged blocker was correct: MAX_GLOB_WILDCARDS=6 let the PR's own
motivating example (5 chained wildcards, empirically catastrophic) through
uncapped. Lowering the raw-character cap to 2 fixed that but broke a real
consumer -- content-lane/spec-resolver.ts's artifactGlob "public/**/*.json"
has 3 raw '*' characters (** counts as two) despite being empirically
instant even against a 4,000-char adversarial path, because a `**`
globstar compiles to a single .* group, not two independent wildcards.

Re-benchmarked with the right unit (wildcard GROUPS, where a ** pair is
one group): 2 groups stays sub-second even at 32,000 adversarial chars; 3
groups is already dangerous (over 2s at ~4,000 chars for a chained-*
shape, over 100ms at ~1,600 chars for a chained-** shape); 4+ groups is
catastrophic (35s at 1,614 chars for 4 chained ** groups). Caps
MAX_GLOB_WILDCARD_GROUPS at 2 -- the highest value proven safe -- via a
new countWildcardGroups that mirrors globToRegExp's own tokenization
(consuming a ** pair, and its trailing /, as one group), so both the
original flagged case and real 2-group globs like public/**/*.json are
handled correctly.

* fix(content-lane): share the wildcard-safety predicate between glob parsing and compilation

normalizeOptionalGlob (focus-manifest.ts) capped contentLane globs at
3 raw '*' characters, but globToRegExp (change-guardrail.ts) rejects
any glob with more than 2 wildcard GROUPS (a '**' pair counts as one
group, not two) by compiling it to NEVER_MATCHES. A glob like
"a*b*c*.json" (3 groups, no '**' pairs) was accepted as configured but
silently could never match any file once compiled -- a content lane
that looks active but never fires.

Export hasUnsafeWildcardCount from change-guardrail.ts and reuse it
directly in normalizeOptionalGlob instead of an independently-counted
threshold, so the parser and compiler can never drift apart again.
JSONbored added a commit that referenced this pull request Jul 2, 2026
…oS (#2482)

labelPatternToRegExp compiled a registry-supplied label_multipliers
key into a RegExp with unbounded chained-wildcard translation and no
length/complexity cap -- the exact class of bug #2445 fixed in
change-guardrail.ts's globToRegExp, which caps chained wildcard groups
at 2 after benchmarking confirmed catastrophic backtracking (over 2s
at ~4,000 chars for 3 chained groups). This sibling was never patched.

The pattern originates from a repo's registryConfig.labelMultipliers,
sourced from the externally-fetched gittensor registry (registry/
sync.ts + registry/normalize.ts) with no validation -- not a value
a repo's own maintainer directly controls via .gittensory.yml, despite
an inaccurate in-code comment claiming the key set was "bounded, not
attacker-supplied". Reachable via the public score-preview API, the
MCP tool, and the per-PR label-audit signal, so one bad registry entry
could hang scoring for every PR on that repo.

Reuses change-guardrail.ts's exported hasUnsafeWildcardCount directly
(same *-group counting, same empirically-safe threshold) rather than
reimplementing it -- a `*` in this fnmatch-style pattern language has
the identical "any run of chars" semantics as that glob compiler's
`*`, so the same catastrophic-backtracking risk and safe boundary
apply. An over-complex pattern now compiles to a safe never-match
instead of a pathological RegExp.

Fixes #2456
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

No open projects
Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant