RFC: Architecture for a Secure Plugin Ecosystem

Changelog :

  • Edited: Section 3 - Committed to LLM as the selected scanner. Added Socket.dev testing conclusion and open gap for malicious dependency detection. Clarified @joplin chain-based filter as the solution for transitive dependency noise.

  • Included few questions at the end of section 3

1. Problem Statement :

The current Joplin plugin publishing pipeline relies on a "trust-by-default" architecture. When a developer publishes an update to the public NPM registry, an automated bot blindly pulls that package into the central Joplin ecosystem.

While this creates a frictionless Developer Experience (DX), it completely lacks automated security scanning, human reviews, and traceability back to the original source code. If a malicious or compromised package hits NPM, it is immediately distributed to Joplin users without any checking.

The Objective: Transition the Joplin plugin registry to a GitHub Actions-based submission queue. To minimize risk, the immediate deliverable for this project is a fully functional Proof of Concept (PoC) that operates in parallel to the existing system using a test repository, ensuring the architecture can be evaluated safely before affecting the live ecosystem.


2. Proposed Architecture & Developer Experience (DX) :

2.1 Current State Analysis :

The current Joplin plugin registry operates on a strictly automated architecture tied to the NPM registry.

The Automated Pipeline (update-repo.yml)
The central registry relies entirely on a 30-minute cron job that executes the @joplin/plugin-repo-cli. This backend tool continuously scans the public NPM registry for packages tagged with joplin-plugin. It fetches the .tgz payload, extracts the manifest.json, generates the .jpl artifact, and updates the master manifests.json registry with tracking fields. Because it directly mutates the registry and README.md, this workflow operates with high-privilege contents: write permissions.

Manifest Data Structures
The ecosystem relies on two core JSON configurations to manage registry state:

  • manifests.json: The generated master registry containing all metadata.

  • manifestOverrides.json: A manual control file allowing maintainers to override the automated pipeline (e.g., flagging plugins as _recommended: true or forcefully deprecating them via _obsolete: true).

2.2 The Proposed DX: A Dedicated CLI Gateway :

Previously, I was suggesting to build a seperate npm package to handle the new publish flow, depriciation error, authentication, etc. This was suggested thinking that the logic will be then isolated from the reach of the developer (As it will be in a dedicated package so dev cannot edit the file to edit the logic.. etc)
I was treating the CLI as the absolute safegaurd before the issue is created.
By your feedback :-

I came to realize treating CLI as the safety boundary is not important. It can purely be used for developer convinience... So, even if some dev try to do something malicious... The github action will work as the ultimate security boundary.

So, Instead of making a seperate package we can just inject the logic in generator-joplin package. We will still not use @joplin/plugin-repo-cli to divide the server side work and client side work (publsih flow).

As part of this transition, commandBuild in @joplin/plugin-repo-cli which currently excecute the NPM package getting cron job will be removed. The GitHub Releases upload logic from commandUpdateRelease will be migrated directly into the GitHub Actions publish job, eliminating the need for the CLI to hold GitHub credentials.

2.3 Authentication :

To maximize Developer Experience (DX), the CLI will use GitHub Device Flow to authenticate the user creating the submission issue.
This eliminates the need for developers to manually generate Personal Access Tokens (PATs). The CLI simply provides a code, opens the user's browser, and securely requests the minimum permissions required to open an issue in the Joplin registry repository on their behalf.
Here's a POC :


3. Threat Model & Security Scanning Pipeline :

3.1 The Threat Model :

For a deeper research please see :
Plugin Security Tool Comparison -- CodeQL, Semgrep & Gemini CLI

3.2 Tooling Selection: (LLM-assisted review)

Since, the threat model is more of a data flow kind now, AST scanner tools like semgrep , sonarQube (primary code quality scanner), etc does not have ability to scan them. Even if extra complex peice of custom rules are written, the main trade off will be that they will be doing something they are not designed for, hence a huge blind spot will always be left for the senarious which are not kept in mind while writting the rules + they will produce a lot of noise on normal plugins too.

The tool which stand chance was CodeQl, after several tests I came to realize the same things as the above tools, even though it has ability of taint tracking, there will always be blind spots for the senarious not taken in mind while writting the rules.

The LLM performed well in all of these cases, not only it was accurate at scanning.. It seldomly warned me about new things which are potential warning too in the plugins.
LLM would be a great call for use in this pipeline.

Pricing : (Taking plugin-yesyoukan as a base)

Gemini Pro API pricing for contexts larger than 128k tokens.

Input Cost: $2.50 per 1 Million tokens : 210,000 tokens = ~$0.53 USD

Output Cost: $7.50 per 1 Million tokens : 500 tokens = ~$0.004 USD
Total : ~$0.534 USD

The most token utilzed was in package-lock.json (~130,000 tokens)
By replacing the massive package-lock.json with a custom, SCA summary by filtering out useless noise from the package-lock.json file, we reduce the AI's context payload to roughly 82,000 tokens. The SCA summary will include :

  • Packages with Install Scripts: Any dependency marked with hasInstallScript: true

  • Suspicious Origins: Any dependency where the resolved URL points to a random GitHub repository.

  • Direct Root Dependencies: The packages listed in the package.json to check for typosquatting.

Using this the cost drops to :
Input Cost: $1.25 per 1 Million tokens : 80,000 tokens = ~$0.10 USD
Output Cost: $3.75 per 1 Million tokens : Assuming ~500 tokens for the report = ~$0.002 USD
Total : ~$0.102 USD

On dependency scanning

This seems to be due to "@joplin/utils": "^3.0.1" dep which explicitly lists @joplin/fork-htmlparser2 as a direct dependency. The security tool looks at that entire chain.

Suppressing them :

The 1st method would be ignoring the package-lock.json file and only scanning the package.json but that would leave a blindspot, as we should scan every code the plugin is using... that include what package the package in package.json is using`.

We cannot rely on the ommiting dev dependencies (npx snyk test --production ) either as the package was present in the dependecy.

On Snyk : The only remaining way left is to use .snyk yaml file which has an ignore: flag to ignore certain packages, I tried supressing these joplin/* :


The result was manually supressing each package in the .snyk file. This will break if in future more packages are added, as we need to add the package here too.

For semgrep there was no such way to supress certain package. The only way was to use the .semgrepignore file to ignore the package-lock.json.

Solution :

Since, joplin packages like "@joplin/utils": "^3.0.1" are trusted we can run a filter for the SCA scan result and filter out the @joplin starting packages.

Lastly :

Both semgrep sca and snyk works same way as npm audit - flagging the known CVE's with a little larger CVE database.
The scanning dependencies for actual malicious code is actually done by the tool Socket.dev.
Socket.dev was tested against real published plugins and a purpose-built malicious postinstall script exfiltrating process.env.AWS_SECRET_ACCESS_KEY. It failed to detect the script because it only analyzes packages published to the npm registry, not author written scripts.

Its only alerts were criticalCVE warnings for Joplin dependencies and obfuscatedFile false positives on minified bundles.

The LLM scanner partially addresses this it can detect if a plugin's source code directly invokes suspicious functions from a third-party dependency. However it cannot detect malicious logic that runs entirely within a dependency's own lifecycle scripts (e.g. postinstall).

LLM will be used for both source code scanning and dependency analysis in the PoC, also keeping it convinient to replace it with any tool if needed further.


4. Approval, Registry Mutation, and UI Integration :

Automated scanning alone cannot prevent targeted attacks if a bad actor updates their logic to bypass static analysis. Therefore, human review remains the final gatekeeper.

Once a Joplin maintainer reviews the automated Markdown report, they approve the plugin by applying a specific GitHub Label (e.g., status: approved) to the Issue. This label triggers the secure mutation pipeline.

The build workflow will be kept in an group to prevent concurrency :

concurrency:
  group: global-joplin-registry-mutation
  cancel-in-progress: false

4.1 Separating Build and Publish Jobs :

Previously, due to too much complex wording I think I was not able to make you understand what does this part meant.

I've simplified the language.

Building a plugin requires the CI to run the developer's local npm run dist script. Because this is untrusted, third-party code, a malicious build script could potentially read the CI environment variables and steal the GITHUB_TOKEN to push unauthorized changes to the Joplin repository.
To prevent token theft, the GitHub Actions workflow is split into two separate jobs:

  1. The Build Step (No Permissions): When a maintainer applies the status: approved label, the CI clones the repository and runs npm ci and npm run dist to compile the .jpl file. This job is explicitly stripped of all repository permissions (permissions: read-all). The runner then saves the compiled .jpl file as a temporary GitHub Artifact.

  2. The Publish Step (Has Permissions): A second job starts. This job is granted the GITHUB_TOKEN permissions needed to update the registry, but it does not execute any third-party code. It simply downloads the .jpl , pushes it to GitHub Releases, compiles the updated download counts into stats.json, and updates the central registry via the REST API.

Note: Unlike the current pipeline which installs packages with --ignore-scripts, the new build job must run npm ci and npm run dist to compile the .jpl from source. This necessarily executes developer-defined scripts, which is why the build job is stripped of all write permissions cause it can contain any malicious script that attempts to steal the GITHUB_TOKEN.

4.2 Error Handling & CI Failure Recovery :

Because this architecture relies heavily on GitHub Actions event triggers, it must gracefully account for runner failures or API timeouts.
To ensure maintainers can recover from failures without requiring developers to resubmit their plugins, the architecture provides two recovery mechanisms:

  1. Native Job Re-runs: For standard failures, maintainers will utilize GitHub's native "Re-run failed jobs" button. Because the workflow context is preserved, the CI will re-evaluate the original Issue payload or Label event without requiring any manual data entry.

  2. Manual Override (workflow_dispatch): To protect against outages where an event trigger is completely dropped by GitHub, both the Review CI and Approval CI workflows will include a workflow_dispatch hook. This provides maintainers with a manual "Run Workflow" button in the GitHub UI, allowing them to explicitly trigger a scan or approval by manually inputting the target GitHub Issue number.

4.3 Parsing & Update Lifecycle :

Structured Issue Payloads: To avoid the fragility of parsing free-form Markdown, the CLI programmatically generates the submission Issue with a JSON payload embedded in the body.

Comprehensive Update Scanning: When a developer submits a version update, the pipeline scans the entire codebase at the new commit, not just the diff. This guarantees that cross-file vulnerabilities (where malicious logic is split between old approved code and new updates) are fully detected.

The publish job will also replicate the existing updateReadme.ts logic , regenerating the plugin table in README.md after every approved submission, maintaining consistency with the current behavior.


5. Project Scope: Proof of Concept (PoC) :

Given the timeline, the immediate focus is to build a Proof of Concept that can be evaluated without disrupting the existing ecosystem.

  1. Parallel Submission Flow: The generator-joplin CLI will be updated to include the npm run publish command. However, the existing npm publish workflow will not be disabled.
  2. Test Target: The new CLI will authenticate and open submission Issues strictly against a dedicated joplin-plugin-test-registry repository, leaving the live joplin/plugins registry untouched.
  3. Workflow Evaluation: All labeling, scanning, and Split-Job CI mechanics will be built and tested within this sandbox environment to allow the maintainer team to evaluate the generated security reports before full integration.

6. Plugin ID Uniqueness :

The Current Mechanism:
The existing pipeline already enforces plugin ID uniqueness by using _npm_package_name in manifests.json via checkIfPluginCanBeAdded.ts. By moving away from NPM, we lose this anchor. The new system replaces _npm_package_name with repository_url as the binding key, enforcing the same "first-come, first-served" guarantee through the CI check instead.

The Proposed Mechanism:
By shifting the publishing pipeline away from NPM and directly to GitHub Actions, we lose NPM's global id protection. Therefore, the central manifests.json file inside the joplin/plugins repository must become the Ultimate Source of Truth for identity and uniqueness.

To enforce uniqueness, the CI will utilize a "First-Come, First-Served" locking mechanism :

  1. New Plugins (Registration): When a developer submits a brand-new plugin, the CI checks the manifests.json registry. If the plugin_id does not exist, the submission proceeds. Upon approval, the ID is permanently bound to the developer's repository_url in the registry. Once published, a plugin's registered repository cannot be changed or moved to a different URL. In case user want to change the repository_url, he would have to contact someone from the Joplin's team to get the url manually changed and verified.

  2. URL Normalization: Before any comparison, the CI will normalize all incoming URLs (stripping https://, trailing .git, etc.) to prevent duplicate registrations like github.com/Foo/Bar vs https://github.com/Foo/Bar.git.

  3. Plugin Updates: When an update is submitted for an existing plugin, the CI performs a lookup. It compares the GitHub issue Repository URL of the incoming submission against the repository_url registered to that plugin_id in manifests.json.

  4. If a user attempts to submit a plugin using an ID they do not own, the normalized URLs will not match. The CI will instantly reject the payload and close the submission issue with an automated comment:

"Error: This Plugin ID is already registered to another repository."


7. Conclusion: The Complete End-to-End Lifecycle :

To summarize how this architecture secures the ecosystem without sacrificing Developer Experience (DX), here is the complete journey of a plugin, from the developer's terminal to the end-user's application:

  1. A developer finishes coding and runs npm run publish. The CLI takes over, verifying the build, authenticating the user via GitHub Device Flow, and securely submitting the payload to the central repository as a structured Issue.

  2. The event-driven CI instantly wakes up and executes the scanner security scan, and generate the report.

  3. A Joplin maintainer reviews the report and applies a status: approved label. This triggers the build job, which compiles the .jpl artifact in a standard GitHub Actions runner without write permissions.

  4. The publish job (which has registry write permissions) downloads the .jpl artifact, uploads it to GitHub Releases, and updates the manifest.json registry via the REST API.

  5. An end-user opens their Joplin Desktop app and browses the plugins. They see the newly published plugin with a crown badge, and install it normally.

By moving from NPM polling to a GitHub Actions submission queue, this project introduces necessary review steps to protect Joplin's users without adding excessive friction for plugin developers.


After looking more closely at how plugins are currently processed, I realized there can be a better method by using the existing scheduled NPM-based build instead of a PR workflow.

Earlier, my proposal assumed a PR-based validation flow, where the developer has to raise a pr. I’ve now updated it to fit the existing cron pipeline by moving the validation and review logic into a post-build auditing layer.

This adds a quarantine + exception mechanism for high-risk plugins (with manual review), while safe plugins continue to flow automatically. The core Zero Trust runtime enforcement and permission system remain unchanged.

Thanks for looking into this however this proposal is not addressing the actual project requirements.

We are not looking to implement plugin sandboxing or a zero-trust runtime architecture. The goal of the project is described here:

https://github.com/joplin/gsoc/blob/master/ideas.md#6-strengthen-the-security-of-the-plugin-ecosystem

And the related RFC is here:

https://github.com/laurent22/joplin/issues/9582

The main idea is to strengthen the plugin review and publishing process, which could include:

  • review plugin source repositories
  • review dependencies
  • build plugins from reviewed commits
  • reduce reliance on npm as a trusted distribution channel

Your proposal instead focuses on runtime sandboxing, IPC isolation, permission systems, Electron lockdown, etc. That's a very different architectural direction and not something we are currently planning to implement.

So before going further, could you clarify whether you had read the project description and RFC? I'm asking because the proposal seems largely unrelated to the requested project scope.

Also what is this refering to? This is closer to what we want, but where is it?

Thank you for the direct feedback. To answer your question: Yes, I did read the project description and RFC, but I completely misinterpreted the core objective.

Regarding your second question "Where is it?" That part was me trying to adjust my plan, but I was still stuck on the wrong sandbox/ NPM heavy idea.

Proposed Architecture Overview

This needs to be significantly expanded with how it's meant to work and why, potentially with diagrams if it helps. You should not jump into describing code in a proposal.

What's the reasoning for this apparently external tool? When the developer setups their plugin they install our code (with yo joplin) so why not bundle that publish tool with it and add it to package.json? And the command then will be npm something. Also is it possible to override npm publish for backward compatibility?

The detailed implementation plan is not relevant if you don't explain in English what you want to do first, and I don't want to piece together your intention by reading git commands, json keys and yml files, most of which will be useless anyway once you start working on it. What would be useful is a high level discussion of what you want to do and why.

The reviewing tool is an important part of this project so you also need to provide a comparison of the possible tools, with your recommendations and why.

The primary thing that an external package holding the publish/update logic would be better is in case of future changes.
If all the pipeline code is written in the boilerplate plugin repo generated by yo joplin and ran using node for publish/update , in case of future changes to the pipeline it would be very hard for the developer who already have the repository created to have an updated pipeline code.

Also, it just clicked into my mind that in case of future update of the external package version will increase,
so during the publish/update execution there can be a check which validates if the external package is up to date or not and stop the publish/update and tell the dev respectively to run an update.

I don't think it is possible to override the npm publish but what we can do is have a script called publish: joplin-plugin publish, so dev have to write npm run publish

I think that make sense, we can have the external package in devDependencies, so publish: joplin-plugin publish will be valid as soon as the yo joplin run finishes.

It can be a separate package, but not something that needs to be installed separately with npm i -g because that's error prone and an additional step. What I mean is that you can bundle it by adding it to the package.json template.

In general please take your time and investigate issues before answering, to avoid "I think" / "I don't think" answers. If you check our existing plugin templating tool, we control publishing via the files and prepare key in package.json - can that be done here too?

npm run publish still breaks backward compatibility.

Sorry and thanks for clarification, I realized I was looking an outdated package template

To be clear it's been working like this since day 1. What package template were you looking at? Just want to make sure you're not looking at the wrong thing. It's in generator-joplin package in the main repo.

I was actually seeing the repository of generator-joplin which was archived + my local plugin setup, So i was originally confused too that why my local pkg.json and the template one was too different , but after following the original link from the npm package and readme I got the real template.
Sorry for that

I’ve updated the proposal based on your feedback. I reworked it to focus more on the higher-level architecture, DX/workflow decisions, tooling tradeoffs, migration strategy, and the reasoning behind the design choices instead of going too deep into implementation specifics.

Thanks for the update, this is way more readable than the previous proposal. Keeping it high level like this means we can discuss the important parts without losing ourselves in implementation details.

I think we will let it publish something to npm like we do now. I don't think ending with NPM ERR even with additional messages is proper. Not to mention that's going to hide actual errors.

Opt-in Privacy Strategy The yo joplin generator will include a configuration prompt: "Do you want to mirror this plugin on the public NPM registry? (y/N)". Based on this input, the generator modifies the package.json. By default (No), it adds the "private": true flag and appends && exit 1 to the prepublishOnly script to firmly block public leaks. If the user opts in (Yes), it omits the private flag and the exit code, allowing the Joplin submission to succeed right before the standard NPM public upload continues.

Let's not do that. This is implementation details that's going to confuse the user. Let's publish to npm if we have to.

If npm publish is a problem, another approach to solve backward compatibility is to make it throw an error that says what should be used instead. i.e. As of August 2026, to publish your plugin, use "npm run publish"

For Static Application Security Testing , the pipeline will utilize Semgrep .
While tools like CodeQL offer deeper semantic analysis it takes a lot of time to scan (often 10+ min), we need something that is fast and easy to maintain in future too.

I think what may be missing in your proposal is that you don't ask much questions to the people who will do the reviews. Please try to integrate this into your analysis to confirm (or not) your assumptions. The reason I bring that up is that here you assume that an automated tool running for 10+ min is a problem, but nobody said that. I would be fine with something that run for one hour if the analysis is good. And then if you work based on these invalid assumptions you end up with the wrong implementation.

The paragraph below is also about the fact that's it's fast, which is irrelevant.

So, how long the tool runs is not a problem. What could be the factors that could decide what is a good tool or not? I was going to write down a list but it's probably better if you think through this or maybe ask around, or create a forum post to discuss it with the community.

It's ok to make assumptions - but please validate them.

By the way, how relevant these tools are in the age of LLMs? Claude for example is good at spotting security vulnerabilities - should LLMs be considered here? Or do these tools already use them?

I like your idea of aggregating the output of all tools in a clear Markdown report. We'll have to make sure we include only what's important in these reports so as to keep them lean and allow us to quickly review plugins.

Thank you for the tool pricing table, but it's standing there without any explanation or label.

YAML-Based Issue Forms

That's a reasonable approach, but let's see once you get to that part. How much data are we talking about? The reason I'm asking is that actual Markdown is more readable, and perhaps data is not so complex that we require YAML. But we don't have to decide now, just something to keep in mind

Good point, we indeed need to scan everything every time.

Would it make sense to use GitHub labels to track the status of the review? Comments are often lost in the middle of other comments so while the bot will find them, it's often more difficult for a human to do so.

This split-job design creates a clear separation between building untrusted plugin code and modifying the official registry. The build step runs with restricted read-only permissions, while the publishing step has write access but only handles builded static artifacts. This reduces the risk of a compromised build environment affecting the Joplin registry itself.

What's missing in your proposal is to describe how the current publishing process works - how does it go from the developer's computer to everybody's applications. Please add a section about this.

I'm not too sure about your two step idea to have CI build and publish the plugins. I'm not certain it makes things more secure and we don't want to overcomplicate things.

Using labels I believe would solve all this without any check since only admins can add or remove labels.

In the event of a post-approval security breach, maintainers can update a plugin's status to suspended in the registry. The Joplin UI will react by hiding these plugins from the store and automatically disabling existing installations to protect the user's local environment.

Suspending a compromised plugin, will be handled via a workflow_dispatch GitHub Action, providing maintainers with a safe, UI-driven way to mutate the registry without risking manual JSON syntax errors.

Please make sure you review carefully how it currently works. This is already implemented and ideally we don't want to change features that already work. The current repo features are documented in /readme: https://github.com/joplin/plugins

Hmm, maybe. Would it be crazy to have the tool push all existing plugins for review? If the process is designed in an efficient way it might be manageable for us to check them all. This should at least be considered as an option.

Thanks for the detailed feedback. I'll make sure to consider all of them in the next updated proposal.

That makes sense. Earlier I was treating execution time as a much bigger constraint than it actually is, although there were also other factors I was considering like maintainability, complexity, and how easy writing custom rules is.

I agree it would be better to gather direct feedback from the maintainers/reviewers instead of assuming those priorities myself. I’ll open a dedicated discussion soon around the tooling comparisons and reviewer workflow tradeoffs I currently have in mind.

I wanted to ask quick question acc to the feedback.

you mentioned :

I think we will let it publish something to npm like we do now. I don't think ending with NPM ERR even with additional messages is proper. Not to mention that's going to hide actual errors.

which is a direct solution to :

If npm publish is a problem, another approach to solve backward compatibility is to make it throw an error that says what should be used instead. i.e. As of August 2026, to publish your plugin, use "npm run publish"

though if we use the custom error As of August 2026, to publish your plugin, use "npm run publish", the developer who want to pubish to npm would still be left with an failed npm publish and would have to use npm publish --ignore-scripts.
Which would means he will also have to run npm prepare manually before publish.

So, It would be better to just let it publish to npm with the publish flow...
or should we have both approaches publish to npm and publish to joplin seperate?

Why would they want this? I think we need to clarify this first

Same question, it's not clear from your proposal why you want to support both, what's the benefit of it for this project or for the user?

Realistically, they don't need to if their only purpose is to generate a plugin. But there might be people who treat npm packages as a public protfolio to display what they have done, and having a working plugin on joplin would be a good showcase.

Though they will be not resrticted directly from publishing as alternative like npm publish --no script works, But since DX was important so I thought this might be a query to raise.

I think we can leave this aside if having the package on npm is just cosmetic. One thing to keep in mind too is that publishing to npm is hard now because you need to do the oauth dance and enter your MFA code, so if we don't really need publishing to npm then we should just skip this part.

Understood.

I've updated my proposal with the recent changes. I'll just quickly summarize the new changes for you

I have extended the reasoning of the tooling section, considering LLM too and also have raised a discussion, once I get some feedback I'll use them to finalize the toolings.

I have kept this section same , though just to clarify right now we are only sending plugin_id, commit_hash and repository_url

I've added the current workflow and the workflow that we'll acheive after this system is live at the first and last section of the proposal.

I have added a little more reasoning to this section in the updated proposal just to add a little more context.
The main motive of this is so that, while the CI build the code , if there is any malicious package that might try to steal github_token and gain access to the github registry, since it only has read permission it won't be able to make any changes.

But if this is something which overcomplicate the process we can drop it and the pipeline would still work fine.

I've shifted the /approve based CI to label based execution, so now only user with Triage access, Write access and Maintainer/Admin access can trigger the approval flow using status : approved label.

Thanks for the clarification, the proposal now focus on another label based approach using status : revoked label to trigger another CI that can do the process of revoking the plugin automatically.

Other major changes are :

Identity & Repo Ownership Validation (Anti-Spoofing) and CLI Version Sync in section 2.

Handling Security Reports and Error Handling & CI Failure Recovery in section 4.

Namespace Locking (Immutable Identity Verification): and Migration Gap (Existing Developers) in section 5.