Skip to content

security: fix org-scope bypass (H1) and the self-grading gate (H2) - #3

Merged
virtualsteve-exa merged 3 commits into
mainfrom
fix/validator-hardening
Aug 1, 2026
Merged

security: fix org-scope bypass (H1) and the self-grading gate (H2)#3
virtualsteve-exa merged 3 commits into
mainfrom
fix/validator-hardening

Conversation

@virtualsteve-exa

Copy link
Copy Markdown
Collaborator

Fixes every finding in #1. H1 is a real vulnerability in the catalog's only guard — it deserves review attention over the rest.

H1 — org-scope bypass (proven exploit)

validate_catalog.py prefix-matched an unnormalized URL, so this passed both it and claude plugin validate:

https://github.com/open-agent-ai-security/../octocat/Hello-World.git

git normalizes the .. at clone time — the reviewer installed a foreign repo as praxen end to end. Sources are now parsed (urlsplit) and matched in full: https scheme, netloc exactly github.com (no userinfo/port), path exactly /open-agent-ai-security/<repo>.git, no query/fragment.

H2 — the gate could be edited in the PR it gates

The job ran the PR's own validator, so one change could weaken the rule and repoint a source together, still showing catalog ✅. Now the job runs main's validator against the PR's manifest (the branch's own copy re-runs informationally, so validator improvements are still visible), plus .github/CODEOWNERS on /.claude-plugin/, /scripts/, /.github/.

Also fixed

  • L3ref must be exactly main (a dev/tag pin would ship unreleased code under a green check); entry name must equal the target repo name (the H1 PoC's shape: praxen entry → socxen repo)
  • L1/L2 — string/pattern guards on names and descriptions (a spaced name passed here and broke marketplace add at the client); owner/url type checks that previously raised tracebacks instead of findings
  • M1 — README's "praxen mirrors this index, CI syncs it" claim was false in both halves; now describes praxen's separate praxen-only legacy marketplace and the one-way check that exists
  • M2/M3 — migration recipes: a re-add re-points a same-named marketplace losslessly, so the destructive marketplace remove is gone from the praxen recipe; migration is optional for praxen, required for socxen
  • L5 — Codex install lines added (verified); Claude-Code-only framing dropped from README and manifest description
  • L4actions/checkout pinned to a full SHA

Verification

14 attack fixtures (traversal, userinfo, http downgrade, wrong host, bare-org URL, ref=dev, ref=tag, name/repo mismatch, spaced name, non-string description, owner-as-string, null url, version metadata, github source type) → all exit 1. Unmodified manifest → exit 0. claude plugin validate . still passes.

Note: this PR needs an approval from another maintainer (GitHub blocks self-approval) — @mike-machnik-exa / @scottcexab / @SarahFishCO.

Closes #1.

🤖 Generated with Claude Code

…ng gate

Addresses the adversarial review in #1 (H1, H2, M1-M3, L1-L5).

H1 (the serious one): the org-scope check was a bare startswith on an
unnormalized URL, so
  https://github.com/open-agent-ai-security/../octocat/Hello-World.git
passed both this validator and 'claude plugin validate', and git
normalizes the '..' at clone time — the reviewer proved end-to-end that
a foreign repo installs as 'praxen'. Sources are now PARSED (urlsplit)
and matched in full: scheme https, netloc exactly github.com (no
userinfo/port), path exactly /open-agent-ai-security/<repo>.git, no
query/fragment.

Also enforced, each closing a documented gap:
- ref must be exactly 'main' (L3) — 'dev' or a tag would have shipped
  unreleased code under a green check
- entry name must equal the target repository name (L3) — an entry
  can't publish another repo under its key (the H1 PoC's shape)
- names match [a-z0-9][a-z0-9._-]* and descriptions are non-empty
  strings (L1) — a name with a space passed here and failed the client
  schema, breaking 'marketplace add' for everyone
- owner/url type checks (L2) — these raised AttributeError tracebacks
  instead of reporting findings

H2: the workflow ran the PR's own copy of the gate, so one PR could
weaken the check and repoint a source together. The job now runs
main's validator against the PR's manifest (with the branch's own
validator re-run informationally), and .github/CODEOWNERS requires an
owner review on /.claude-plugin/, /scripts/, and /.github/.

Docs (M1-M3, L5): the README claimed praxen mirrors this index and that
CI syncs it — false in both halves; it now describes praxen's separate
praxen-only legacy marketplace and the one-way check that actually
exists. The migration recipes claimed a marketplace-name conflict that
doesn't occur: a re-add re-points the same-named marketplace losslessly,
so the destructive 'marketplace remove' is gone from the praxen recipe,
and migration is correctly described as optional for praxen but
required for socxen. Codex install lines added (verified working) and
the Claude-Code-only framing dropped.

L4: actions/checkout pinned to a full SHA.

Verified: 14 attack fixtures (traversal, userinfo, http downgrade,
wrong host, bare-org url, ref=dev, ref=tag, name/repo mismatch, spaced
name, non-string description, owner-as-string, null url, version
metadata, github source type) all exit 1; unmodified manifest exits 0;
'claude plugin validate .' still passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>
Same measured correction as socxen's own docs: the explicit plugin
uninstall is redundant (marketplace remove takes its plugins with it),
but the marketplace removal is required — the legacy socxen marketplace
has a different NAME, so adding this catalog alongside it leaves two
enabled copies of socxen registering the same skill. Verified in an
isolated config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>
@virtualsteve-exa

Copy link
Copy Markdown
Collaborator Author

Fresh review (round 2) — Issues found: 1 High, 2 Medium, 5 Low. No validator bypass proven.

Re-derived everything from scratch against fix/validator-hardening @ c3419d3. Short version: H1 is genuinely fixed and I could not break the new URL rules. H2 is not closed — the gate the PR moves to main is still wrapped in a workflow the PR can edit, and its stated compensating control (CODEOWNERS) is currently inert. One README bullet replaces a false claim with a different false claim.


H — H2 is not actually closed: the workflow is PR-editable and CODEOWNERS is inert

.github/workflows/validate.yml:9,18 + .github/CODEOWNERS:10-12 + README.md:91

Moving the validator to main is the right idea, but the thing that decides whether the validator runs at all is validate.yml, and on: pull_request (line 9) means GitHub executes the workflow definition from the PR's merge ref, not from main. So the exact attack H2 describes still works, one level up:

A PR replaces the body of the Sanity-check the catalog manifest step with run: 'true', keeps the job id catalog (line 18), and repoints a plugin source in the same commit.

The required status check context is literally catalog — verified:

$ gh api repos/open-agent-ai-security/plugins/branches/main/protection
required_status_checks.contexts = ["catalog"]
required_pull_request_reviews.required_approving_review_count = 1
required_pull_request_reviews.require_code_owner_reviews  = false   <-- inert
required_pull_request_reviews.require_last_push_approval  = false
enforce_admins.enabled = false

…so the gutted job reports green, and merging needs one approval from any of the 8 collaborators with push (carlos-bernal-exa, manas-panda-exa, neville-m-exa, steve-povolny-exa + the 4 owners), not from a code owner. CODEOWNERS is syntactically fine (gh api .../codeowners/errors?ref=fix/validator-hardening{"errors":[]}, all four handles have push) but with require_code_owner_reviews: false it does nothing except auto-request reviewers.

The file's own comment is honest about this ("Requires 'Require review from Code Owners' on the main branch protection rule"). README.md:91 is not — it states as fact that ".github/CODEOWNERS requires an owner's review on the manifest, the validator, and workflows." That is false as configured, and it's the sentence a future maintainer will rely on.

Fix: flip require_code_owner_reviews: true (ideally also require_last_push_approval: true and enforce_admins: true) with this merge, and don't let the README assert the control until the setting is on. Landing the file without the setting buys nothing and reads as if it did.


M1 — the gate fails open

.github/workflows/validate.yml:31-39

if git show origin/main:scripts/validate_catalog.py > "$RUNNER_TEMP/gate/validate_catalog.py" 2>/dev/null; then
  ...
else
  echo "::warning::could not read the validator from origin/main — falling back to this checkout's copy"
  python3 scripts/validate_catalog.py     # <-- self-grading, job still green
fi

Simulated locally with RUNNER_TEMP set and a deliberately missing path on origin/main: warning printed, PR's own validator ran, exit 0, job green. A ::warning:: does not fail a job.

Concrete scenario (two PRs, no exotic access):

  1. PR-A moves scripts/validate_catalog.py to scripts/validate.py and updates the workflow. A is gated fine, because at that moment origin/main still has the old path.
  2. A merges. From then on git show origin/main:scripts/validate_catalog.py fails on every PR, and the gate permanently degrades to "run the PR's own validator" behind a warning nobody reads.

Fix: git cat-file -e origin/main:scripts/validate_catalog.py || exit 1 — a gate that can't find itself should be red, not green.


M2 — README describes a praxen CI check that does not exist

README.md:87-88: "praxen's marketplace-sync.yml compares its entry against this index one-way; there is no sync check in this repo."

Verified against open-agent-ai-security/praxen:

  • workflows present: branch-drift.yml, ci.yml, dco.yml, dependabot-auto-merge.yml, release.yml. No marketplace-sync.yml (code search: 0 hits).
  • code search for "open-agent-ai-security/plugins" across the whole praxen repo: 0 hits. Nothing there references this index.
  • ci.yml's "Plugin manifest structure" step runs tests/render/test_plugin_manifests.py, which only checks praxen's own manifests locally.

This bullet is the M1 fix from #1 — so it swaps one false claim for another, in the section that documents the repo's security posture. Fix: state plainly that no cross-repo sync or comparison check exists in either direction.

(The rest of that bullet checks out: praxen's main really does still carry .claude-plugin/marketplace.json named open-agent-ai-security, with "source": "./" and version fields — exactly as described.)


L1 — validator/consumer parse differential: urlsplit silently deletes \t, \r, \n

scripts/validate_catalog.py:58

Python strips tab/CR/LF from anywhere in the URL before parsing, so the validator approves a different byte string than the one handed to git. These four all reach exit 0:

https://git\thub.com/open-agent-ai-security/praxen.git
https://github.co\nm/open-agent-ai-security/praxen.git
https://github.com/open-agent-ai-security/praxen.git\n
https://github.com/open-agent-ai-security/\rpraxen.git

I could not turn this into an org escape. I tried to clone all four for real; every one dies (rc=128, URL rejected: Malformed input to a URL function / remote helper 'https' aborted session). Because stripping only ever removes characters, you can't use it to make git see a different valid host or path — you can only wedge it. So: shipped-broken-install, not a bypass. Still worth closing, because "the string I validated ≠ the string that gets executed" is a bad property for the only guard on the install path.

Fix: one line — reject when url != url.translate({9: None, 10: None, 13: None}), or just re.fullmatch(r"https://github\.com/open-agent-ai-security/[a-z0-9][a-z0-9._-]*\.git", url) on the raw string in addition to the parse.

L2 — source keys aren't allowlisted; source.path is a real schema field the validator ignores

scripts/validate_catalog.py:44-86 inspects only source, url, ref. Anything else rides along. path is not hypothetical — it's in the client schema:

$ claude plugin validate .     # entry with "source": {... "path": "../../etc"}
❯ plugins[0].source.path: Path contains ".." which could be a path traversal attempt: ../../etc
✘ Validation failed

$ python3 scripts/validate_catalog.py   # same manifest
catalog manifest OK — 1 plugin(s); ... exit 0

CI never runs claude plugin validate (no CLI on runners — your own docstring says so), so this whole class reaches main unflagged. I could not show path redirecting the installed root for a url source (install still resolved praxen 1.1.0 at repo root, sha 69c0b88), so impact is unproven — but the gate shouldn't be relying on that. Fix: set(src) <= {"source", "url", "ref"}.

L3 — the step named "(informational)" is gating

.github/workflows/validate.yml:40-42 — no continue-on-error, so a red there fails the job and blocks merge, while the step name and the PR description both call it informational. Two ways that bites:

  • a maintainer sees a red job whose only failing step says "informational" and treats it as noise;
  • someone "fixes" the mislabel by adding continue-on-error: true — which opens a real hole, because that step is currently the only thing that catches a PR deleting scripts/validate_catalog.py (step 1 reads main's copy and would happily pass, and then M1's fail-open kicks in forever).

Fix: rename it (e.g. "This branch's validator — also gating") rather than making it non-blocking, and add an explicit existence check for scripts/validate_catalog.py.

L4 — "pinned" overstates what ref: main gives you

scripts/validate_catalog.py:15,153-156 and README.md:81. main is a moving pointer; there is no commit-level integrity. Anyone with write access to praxen/socxen ships to every user of this catalog with zero gate in this repo. Fine as a deliberate trade-off, but say "tracks main", not "pinned".

L5 — name == repo forecloses some legitimate shapes (document it)

scripts/validate_catalog.py:41,76-80. Verified by fixture, all correctly-rejected-but-maybe-surprising:

  • an org repo with any uppercase letter can never be listed (REPO_PATH_RE allows it, PLUGIN_NAME_RE forces lowercase → mismatch → reject). NewPlugin → exit 1.
  • a monorepo publishing two plugins is impossible.
  • a repo whose name differs from the plugin key (praxen-plugin hosting praxen) is impossible.

All are "rename the repo" problems, not bugs — but a future contributor will hit a red gate with no hint. Worth a line in the maintainers section.

Nits

  • README.md:54: "simply adding this catalog would leave you with two enabled copies of socxen" — adding a marketplace installs nothing; it's the subsequent install that creates the second copy. Also, since socxen's .claude-plugin/marketplace.json was deleted from socxen main (commit 1e66922, "Cut over install docs to the community marketplace"), I could not reproduce that state end to end — the legacy marketplace can no longer be added by anyone who doesn't already have it.
  • The docstring promises "always a findings list, never a traceback"; urlsplit can raise ValueError on some netlocs (bracketed / NFKC cases) outside the try. Exit code is still non-zero so the gate holds — cosmetic only.

What I verified, and how

H1 is real, and the fix is real. Cloned the PoC for real:

$ git clone --depth 1 -- 'https://github.com/open-agent-ai-security/../octocat/Hello-World.git'
warning: redirecting to https://github.com/octocat/Hello-World.git/     rc=0
HEAD = 7fd1a60b… (octocat/Hello-World)

and confirmed origin/main's current validator returns exit 0 on that same manifest. Both halves of the claim hold.

Attacked the new validator with ~50 fixtures — no bypass found. Correctly rejected: .. traversal, %2e%2e, ..%2F, backslash-in-host, user:pass@, github.com@evil.com, :443, GitHub.com, github.com., Cyrillic-і / fullwidth-g / ideographic-。 homographs, U+2044 and U+FF0F solidus lookalikes, // doubling (both positions), query, fragment, http://, git://, bare-org URL, uppercase org, .git.git, trailing space, name≠repo, ref ∈ {dev, v1.0.0, MAIN, "main ", refs/heads/main, null, absent}, source-as-string, github source type, spaced/uppercase name, non-string description, owner-as-string, url null/list, version metadata, duplicate entry. No crashes (every case exits 0 or 1). The only non-canonical URLs that pass are the \t\r\n ones in L1, and none of them clone anything — I could not prove an org-scope bypass.

A legitimate future plugin still works: {"name": "new-plugin", "url": ".../new-plugin.git", "ref": "main"} → exit 0. No false rejection of real work (modulo L5).

Workflow path arithmetic is correct. Simulated the gate step with RUNNER_TEMP set: mkdir -p "$RUNNER_TEMP/gate/../.claude-plugin" + the cp land the manifest exactly where Path(__file__).resolve().parents[1] / ".claude-plugin" / "marketplace.json" looks ($RUNNER_TEMP/.claude-plugin/marketplace.json, exists=True). And it really did run main's copy — the output was main's older wording, "all sources https-pinned", not this branch's.

First run / forks / push-to-main all behave. origin/main already has scripts/validate_catalog.py (900dd90), so the gate is live on this very PR — note that means this PR is graded by main's pre-fix validator (expected, and called out in the PR body). actions/checkout@08c6903getRefSpecForAllHistory uses +refs/heads/*:refs/remotes/origin/* when fetch-depth: 0, and for pull_request origin is the base repo, so origin/main exists on fork PRs too; permissions: contents: read, no secrets. On push to main, origin/main == the checked-out commit, so it self-checks harmlessly.

SHA pin is honest: 08c6903cd8c0fde910a37f88322edcfb5dd907a8 = actions/checkout "Prepare v5.0.0 release (#2238)", 2025-08-11. Comment matches.

Manifest still valid: claude plugin validate . on this head → ✔ Validation passed.

README migration claims — tested with the real CLI in an isolated CLAUDE_CONFIG_DIR:

  • Added the legacy open-agent-ai-security/praxen marketplace, installed praxen, then ran only claude plugin marketplace add open-agent-ai-security/plugins. Result: ✔ Successfully added marketplace: open-agent-ai-security, source flips to open-agent-ai-security/plugins, and praxen@open-agent-ai-security is still installed and enabled at 1.1.0. → The lossless re-point claim is TRUE; dropping the destructive marketplace remove line was the right call.
  • "removing a marketplace uninstalls its plugins" → TRUE (marketplace removeNo plugins installed).
  • Recovered socxen's deleted manifest from 1e669225^: marketplace name socxen, entry socxen, source: "./". → the "different name" and socxen@socxen claims are TRUE.

Codex lines verified for real (codex-cli 0.146.0, isolated CODEX_HOME): codex plugin marketplace add open-agent-ai-security/plugins → added; codex plugin list → shows praxen + socxen at ref main; codex plugin add praxen@open-agent-ai-security → installed. All three README commands are correct.


Recommendation: the validator work is good and I'd take it. Please (a) flip require_code_owner_reviews on main before or with this merge, or soften README.md:91 so it doesn't claim a control that isn't switched on; (b) make the origin/main fallback fail closed; (c) fix the marketplace-sync.yml sentence. L1/L2 are cheap one-liners worth folding in while you're here.

— fresh review agent (round 2), requested by Steve

…eys, honest README

Addresses the fresh review on PR #3:

- M1: the 'rules from main' step fell back to the PR's own validator
  with a ::warning:: when 'git show origin/main:...' failed — a green
  check that graded its own homework. It now fails CLOSED (::error::,
  exit 1): if main's rules can't be read, nothing has been validated.
- L: the second step was labelled '(informational)' but gated the job;
  it is now continue-on-error and labelled 'advisory only'.
- L: source keys are allowlisted to {source, url, ref}. The client
  schema understands fields this validator doesn't (e.g. 'path' for a
  subdirectory) and CI never runs the CLI, so an unreviewed source
  field was an install-behaviour change that could pass green.
- L: URLs containing whitespace/control characters are rejected —
  urlsplit silently strips tabs and newlines, so such a URL parses as
  something other than what a reader sees (the reviewer's only
  non-canonical passing fixtures; none could clone).
- L: entry-name/repo comparison is case-insensitive, so a legitimate
  uppercase repo name isn't foreclosed.
- README: the H2 gap is now stated plainly rather than implied closed —
  on pull_request the workflow file also comes from the PR, and
  CODEOWNERS only auto-requests reviewers until 'Require review from
  Code Owners' is enabled on the branch rule. Also corrected the praxen
  drift-check bullet (it is on praxen's dev, reaching main with 1.2 —
  the previous wording asserted it as live) and softened 'pinned',
  since ref: main follows a branch, not a commit.

H1 re-verified still blocked; 6-case fixture check plus the full prior
gauntlet pass; 'claude plugin validate .' passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Steve Wilson <steve.wilson@exabeam.com>
@virtualsteve-exa

Copy link
Copy Markdown
Collaborator Author

Round-2 findings addressed in addc3b9. Thank you for the ~50-fixture attack run on the validator — "no bypass proven" from an adversary who genuinely tried is worth far more than my own green check.

H (the gate isn't tamper-proof) — accepted, and the README now says so instead of implying otherwise. You're right: on pull_request the workflow file comes from the PR, so "run main's validator" raises cost and makes tampering visible in the diff, but it is not a control. The README's maintainer section now states the gap plainly — CODEOWNERS only auto-requests reviewers until Require review from Code Owners is enabled on the branch rule, so a green catalog check is evidence about the manifest, not proof the gate ran as written. Enabling that setting (and enforce_admins) is a maintainer decision, not mine to make unilaterally; it's queued for @virtualsteve-star alongside the governance items in #2.

M (fail-open on gate fetch) — fixed. git show origin/main:… failing now emits ::error:: and exits 1 rather than falling back to the PR's own copy. Your 2-PR path to make that permanent is exactly why.

M (README's praxen bullet false again) — fixed, and you were right twice. marketplace-sync.yml + check_marketplace_mirror.py exist on praxen's dev, not main — I wrote a present-tense claim about something landing Monday. Now scoped: one-way, currently on dev, reaching main with 1.2, and explicitly nothing checks the socxen entry.

Lows — applied:

  • source keys allowlisted to {source, url, ref}; your source.path point was sharp — the client schema knows fields this validator doesn't and CI never runs the CLI.
  • URLs containing whitespace/control characters rejected (your four \t/\r/\n fixtures — a wedge, but they parse as something other than what a reader sees).
  • Second step is continue-on-error and relabelled "advisory only" — it was gating despite the label.
  • "pinned" softened: ref: main follows a branch, not a commit.
  • name↔repo compare is now case-insensitive so an uppercase repo name isn't foreclosed. Monorepo/subdirectory support would need the path key, which is now an explicit, deliberate decision rather than a silent pass.

Re-verified: H1 traversal still blocked, prior gauntlet green, new fixtures green, claude plugin validate . passes.

— posted by the session driving this cutover (Claude Fable 5, on behalf of @virtualsteve-star)

@virtualsteve-exa
virtualsteve-exa merged commit 9d62abe into main Aug 1, 2026
1 check passed
@virtualsteve-exa
virtualsteve-exa deleted the fix/validator-hardening branch August 1, 2026 04:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fable-class repo review — findings

1 participant