From 61aafcf6c19c0ebf1c79ce9de850a86476530294 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Sun, 9 Aug 2026 13:09:02 +0200 Subject: [PATCH] feat: added rules for classifying plugin behaviors --- README.md | 13 ++ docs/rules/behavior/clipboard-access.md | 33 +++++ docs/rules/behavior/no-electron-ipc.md | 31 +++++ docs/rules/behavior/no-filesystem-access.md | 38 ++++++ .../behavior/no-hardware-fingerprinting.md | 33 +++++ docs/rules/behavior/no-local-storage.md | 33 +++++ docs/rules/behavior/no-periodic-network.md | 35 ++++++ docs/rules/behavior/no-self-disable-enable.md | 30 +++++ docs/rules/behavior/no-self-update.md | 35 ++++++ docs/rules/behavior/no-shell-execution.md | 40 ++++++ docs/rules/behavior/no-system-identity.md | 35 ++++++ docs/rules/behavior/vault-enumeration.md | 33 +++++ docs/rules/behavior/vault-read.md | 33 +++++ docs/rules/behavior/vault-write.md | 35 ++++++ lib/index.ts | 27 +++++ lib/rules/behavior/clipboardAccess.ts | 56 +++++++++ lib/rules/behavior/index.ts | 29 +++++ lib/rules/behavior/noElectronIpc.ts | 75 ++++++++++++ lib/rules/behavior/noFilesystemAccess.ts | 77 ++++++++++++ .../behavior/noHardwareFingerprinting.ts | 74 ++++++++++++ lib/rules/behavior/noLocalStorage.ts | 75 ++++++++++++ lib/rules/behavior/noPeriodicNetwork.ts | 114 ++++++++++++++++++ lib/rules/behavior/noSelfDisableEnable.ts | 72 +++++++++++ lib/rules/behavior/noSelfUpdate.ts | 101 ++++++++++++++++ lib/rules/behavior/noShellExecution.ts | 87 +++++++++++++ lib/rules/behavior/noSystemIdentity.ts | 66 ++++++++++ lib/rules/behavior/vaultEnumeration.ts | 68 +++++++++++ lib/rules/behavior/vaultRead.ts | 84 +++++++++++++ lib/rules/behavior/vaultWrite.ts | 86 +++++++++++++ tests/behavior/clipboardAccess.test.ts | 52 ++++++++ tests/behavior/index.ts | 13 ++ tests/behavior/noElectronIpc.test.ts | 55 +++++++++ tests/behavior/noFilesystemAccess.test.ts | 63 ++++++++++ .../behavior/noHardwareFingerprinting.test.ts | 43 +++++++ tests/behavior/noLocalStorage.test.ts | 73 +++++++++++ tests/behavior/noPeriodicNetwork.test.ts | 63 ++++++++++ tests/behavior/noSelfDisableEnable.test.ts | 39 ++++++ tests/behavior/noSelfUpdate.test.ts | 40 ++++++ tests/behavior/noShellExecution.test.ts | 93 ++++++++++++++ tests/behavior/noSystemIdentity.test.ts | 73 +++++++++++ tests/behavior/vaultEnumeration.test.ts | 53 ++++++++ tests/behavior/vaultRead.test.ts | 73 +++++++++++ tests/behavior/vaultWrite.test.ts | 63 ++++++++++ 43 files changed, 2344 insertions(+) create mode 100644 docs/rules/behavior/clipboard-access.md create mode 100644 docs/rules/behavior/no-electron-ipc.md create mode 100644 docs/rules/behavior/no-filesystem-access.md create mode 100644 docs/rules/behavior/no-hardware-fingerprinting.md create mode 100644 docs/rules/behavior/no-local-storage.md create mode 100644 docs/rules/behavior/no-periodic-network.md create mode 100644 docs/rules/behavior/no-self-disable-enable.md create mode 100644 docs/rules/behavior/no-self-update.md create mode 100644 docs/rules/behavior/no-shell-execution.md create mode 100644 docs/rules/behavior/no-system-identity.md create mode 100644 docs/rules/behavior/vault-enumeration.md create mode 100644 docs/rules/behavior/vault-read.md create mode 100644 docs/rules/behavior/vault-write.md create mode 100644 lib/rules/behavior/clipboardAccess.ts create mode 100644 lib/rules/behavior/index.ts create mode 100644 lib/rules/behavior/noElectronIpc.ts create mode 100644 lib/rules/behavior/noFilesystemAccess.ts create mode 100644 lib/rules/behavior/noHardwareFingerprinting.ts create mode 100644 lib/rules/behavior/noLocalStorage.ts create mode 100644 lib/rules/behavior/noPeriodicNetwork.ts create mode 100644 lib/rules/behavior/noSelfDisableEnable.ts create mode 100644 lib/rules/behavior/noSelfUpdate.ts create mode 100644 lib/rules/behavior/noShellExecution.ts create mode 100644 lib/rules/behavior/noSystemIdentity.ts create mode 100644 lib/rules/behavior/vaultEnumeration.ts create mode 100644 lib/rules/behavior/vaultRead.ts create mode 100644 lib/rules/behavior/vaultWrite.ts create mode 100644 tests/behavior/clipboardAccess.test.ts create mode 100644 tests/behavior/index.ts create mode 100644 tests/behavior/noElectronIpc.test.ts create mode 100644 tests/behavior/noFilesystemAccess.test.ts create mode 100644 tests/behavior/noHardwareFingerprinting.test.ts create mode 100644 tests/behavior/noLocalStorage.test.ts create mode 100644 tests/behavior/noPeriodicNetwork.test.ts create mode 100644 tests/behavior/noSelfDisableEnable.test.ts create mode 100644 tests/behavior/noSelfUpdate.test.ts create mode 100644 tests/behavior/noShellExecution.test.ts create mode 100644 tests/behavior/noSystemIdentity.test.ts create mode 100644 tests/behavior/vaultEnumeration.test.ts create mode 100644 tests/behavior/vaultRead.test.ts create mode 100644 tests/behavior/vaultWrite.test.ts diff --git a/README.md b/README.md index 296b396..cbe2d3c 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,19 @@ You can also override or add rules: | Name                                          | Description | 💼 | ⚠️ | 🚫 | 🔧 | 💡 | | :----------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | :----- | :----- | :----- | :- | :- | +| [behavior/clipboard-access](docs/rules/behavior/clipboard-access.md) | Detect access to the system clipboard. | | | ✅ 🇬🇧 | | | +| [behavior/no-electron-ipc](docs/rules/behavior/no-electron-ipc.md) | Detect usage of Electron IPC for privileged inter-process communication. | | | ✅ 🇬🇧 | | | +| [behavior/no-filesystem-access](docs/rules/behavior/no-filesystem-access.md) | Detect usage of the Node.js fs module for direct filesystem access outside the Obsidian vault API. | | | ✅ 🇬🇧 | | | +| [behavior/no-hardware-fingerprinting](docs/rules/behavior/no-hardware-fingerprinting.md) | Detect usage of hardware fingerprinting libraries like node-machine-id. | | | ✅ 🇬🇧 | | | +| [behavior/no-local-storage](docs/rules/behavior/no-local-storage.md) | Detect usage of localStorage or sessionStorage instead of the Obsidian plugin data APIs. | | | ✅ 🇬🇧 | | | +| [behavior/no-periodic-network](docs/rules/behavior/no-periodic-network.md) | Detect periodic network calls via setInterval combined with fetch or requestUrl. | | | ✅ 🇬🇧 | | | +| [behavior/no-self-disable-enable](docs/rules/behavior/no-self-disable-enable.md) | Detect plugins that programmatically disable and re-enable themselves. | | | ✅ 🇬🇧 | | | +| [behavior/no-self-update](docs/rules/behavior/no-self-update.md) | Detect plugins that appear to overwrite their own files by extracting an archive. | | | ✅ 🇬🇧 | | | +| [behavior/no-shell-execution](docs/rules/behavior/no-shell-execution.md) | Detect usage of child_process for shell command execution. | | | ✅ 🇬🇧 | | | +| [behavior/no-system-identity](docs/rules/behavior/no-system-identity.md) | Detect reads of system identity information that could be used for fingerprinting. | | | ✅ 🇬🇧 | | | +| [behavior/vault-enumeration](docs/rules/behavior/vault-enumeration.md) | Detect enumeration of all files in the vault. | | | ✅ 🇬🇧 | | | +| [behavior/vault-read](docs/rules/behavior/vault-read.md) | Detect reads of individual vault files via the Obsidian API. | | | ✅ 🇬🇧 | | | +| [behavior/vault-write](docs/rules/behavior/vault-write.md) | Detect writes or modifications to vault files via the Obsidian API. | | | ✅ 🇬🇧 | | | | [commands/no-command-in-command-id](docs/rules/commands/no-command-in-command-id.md) | Disallow using the word 'command' in a command ID. | | ✅ 🇬🇧 | | | | | [commands/no-command-in-command-name](docs/rules/commands/no-command-in-command-name.md) | Disallow using the word 'command' in a command name. | | ✅ 🇬🇧 | | | | | [commands/no-default-hotkeys](docs/rules/commands/no-default-hotkeys.md) | Discourage providing default hotkeys for commands. | | ✅ 🇬🇧 | | | | diff --git a/docs/rules/behavior/clipboard-access.md b/docs/rules/behavior/clipboard-access.md new file mode 100644 index 0000000..d480044 --- /dev/null +++ b/docs/rules/behavior/clipboard-access.md @@ -0,0 +1,33 @@ +# obsidianmd/behavior/clipboard-access + +📝 Detect access to the system clipboard. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects access to the system clipboard via `navigator.clipboard`, `electron.clipboard`, or `remote.clipboard`. Clipboard access may expose content copied from outside Obsidian. + +## Examples + +### Invalid + +```js +navigator.clipboard.writeText('text'); + +navigator.clipboard.readText(); + +electron.clipboard.readText(); + +remote.clipboard.writeText('text'); +``` + +### Valid + +```js +navigator.userAgent; + +const clipboard = new MyClipboard(); +``` diff --git a/docs/rules/behavior/no-electron-ipc.md b/docs/rules/behavior/no-electron-ipc.md new file mode 100644 index 0000000..4b9e65d --- /dev/null +++ b/docs/rules/behavior/no-electron-ipc.md @@ -0,0 +1,31 @@ +# obsidianmd/behavior/no-electron-ipc + +📝 Detect usage of Electron IPC for privileged inter-process communication. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects usage of Electron's IPC modules (`ipcRenderer`, `ipcMain`), which allow privileged inter-process communication outside the normal plugin sandbox. + +## Examples + +### Invalid + +```js +import { ipcRenderer } from 'electron'; + +import { ipcMain } from 'electron'; + +electron.ipcRenderer.send('test'); +``` + +### Valid + +```js +import { Plugin } from 'obsidian'; + +const renderer = new Renderer(); +``` diff --git a/docs/rules/behavior/no-filesystem-access.md b/docs/rules/behavior/no-filesystem-access.md new file mode 100644 index 0000000..20fd5e5 --- /dev/null +++ b/docs/rules/behavior/no-filesystem-access.md @@ -0,0 +1,38 @@ +# obsidianmd/behavior/no-filesystem-access + +📝 Detect usage of the Node.js fs module for direct filesystem access outside the Obsidian vault API. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects imports of Node.js `fs`, `node:fs`, `fs/promises`, and `node:fs/promises` modules. Unlike `no-nodejs-modules`, this rule does not respect `Platform.isDesktop` guards — it always reports, since its purpose is behavior classification rather than platform safety. + +## Examples + +### Invalid + +```js +import fs from 'fs'; + +import { readFile } from 'fs/promises'; + +const fs = require('fs'); + +const fs = await import('fs'); + +// Still reported even with a platform guard +if (Platform.isDesktop) { const fs = await import('fs'); } +``` + +### Valid + +```js +import { Plugin } from 'obsidian'; + +import path from 'path'; + +const data = vault.read('test.md'); +``` diff --git a/docs/rules/behavior/no-hardware-fingerprinting.md b/docs/rules/behavior/no-hardware-fingerprinting.md new file mode 100644 index 0000000..4c0e3ca --- /dev/null +++ b/docs/rules/behavior/no-hardware-fingerprinting.md @@ -0,0 +1,33 @@ +# obsidianmd/behavior/no-hardware-fingerprinting + +📝 Detect usage of hardware fingerprinting libraries like node-machine-id. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects usage of the `node-machine-id` library and its exported functions (`machineId`, `machineIdSync`), which collect unique hardware identifiers that can be used to fingerprint the user's machine. + +## Examples + +### Invalid + +```js +import { machineIdSync } from 'node-machine-id'; + +const id = require('node-machine-id'); + +const id = machineIdSync(); + +const mod = await import('node-machine-id'); +``` + +### Valid + +```js +import { Plugin } from 'obsidian'; + +const id = generateId(); +``` diff --git a/docs/rules/behavior/no-local-storage.md b/docs/rules/behavior/no-local-storage.md new file mode 100644 index 0000000..4c5ee4b --- /dev/null +++ b/docs/rules/behavior/no-local-storage.md @@ -0,0 +1,33 @@ +# obsidianmd/behavior/no-local-storage + +📝 Detect usage of localStorage or sessionStorage instead of the Obsidian plugin data APIs. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects usage of `localStorage` and `sessionStorage` for data persistence. Obsidian plugins should use the plugin data APIs (`loadData`/`saveData`) instead. + +## Examples + +### Invalid + +```js +localStorage.setItem('key', 'value'); + +localStorage.getItem('key'); + +sessionStorage.setItem('key', 'value'); + +window.localStorage.setItem('key', 'value'); +``` + +### Valid + +```js +this.plugin.loadData(); + +localStorage.length; +``` diff --git a/docs/rules/behavior/no-periodic-network.md b/docs/rules/behavior/no-periodic-network.md new file mode 100644 index 0000000..3d5fb35 --- /dev/null +++ b/docs/rules/behavior/no-periodic-network.md @@ -0,0 +1,35 @@ +# obsidianmd/behavior/no-periodic-network + +📝 Detect periodic network calls via setInterval combined with fetch or requestUrl. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects when `setInterval` is combined with network calls (`fetch` or `requestUrl`) inside the callback. This pattern may indicate periodic background data transmission. + +Note: Named function references passed to `setInterval` are not resolved — this is a documented limitation. + +## Examples + +### Invalid + +```js +setInterval(() => { fetch('/api'); }, 60000); + +setInterval(function() { requestUrl({ url: '/api' }); }, 60000); + +window.setInterval(() => { fetch('/api'); }, 60000); +``` + +### Valid + +```js +setInterval(() => { console.log('tick'); }, 1000); + +fetch('/api/data'); + +setInterval(pollServer, 1000); +``` diff --git a/docs/rules/behavior/no-self-disable-enable.md b/docs/rules/behavior/no-self-disable-enable.md new file mode 100644 index 0000000..898f1c2 --- /dev/null +++ b/docs/rules/behavior/no-self-disable-enable.md @@ -0,0 +1,30 @@ +# obsidianmd/behavior/no-self-disable-enable + +📝 Detect plugins that programmatically disable and re-enable themselves. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects when a plugin calls both `disablePlugin` and `enablePlugin` in the same file. This pattern is a known technique for executing newly downloaded code without user awareness by restarting the plugin after modifying its own files. + +## Examples + +### Invalid + +```js +app.plugins.disablePlugin(this.manifest.id); +app.plugins.enablePlugin(this.manifest.id); +``` + +### Valid + +```js +// Only disabling is fine +app.plugins.disablePlugin(this.manifest.id); + +// Only enabling is fine +app.plugins.enablePlugin('some-id'); +``` diff --git a/docs/rules/behavior/no-self-update.md b/docs/rules/behavior/no-self-update.md new file mode 100644 index 0000000..85d0e2a --- /dev/null +++ b/docs/rules/behavior/no-self-update.md @@ -0,0 +1,35 @@ +# obsidianmd/behavior/no-self-update + +📝 Detect plugins that appear to overwrite their own files by extracting an archive. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule flags plugins that combine references to plugin files (`main.js`, `manifest.json`, `styles.css`), file-write operations, and zip/archive libraries. This combination indicates a self-update mechanism that bypasses Obsidian's official plugin update process. + +## Examples + +### Invalid + +```js +import AdmZip from 'adm-zip'; + +const f = "main.js"; +writeFileSync(f, data); +``` + +### Valid + +```js +// Only referencing a file name is fine +const f = "main.js"; + +// Only writing files is fine +writeFileSync(path, data); + +// Only importing a zip library is fine +import AdmZip from 'adm-zip'; +``` diff --git a/docs/rules/behavior/no-shell-execution.md b/docs/rules/behavior/no-shell-execution.md new file mode 100644 index 0000000..1ac0c09 --- /dev/null +++ b/docs/rules/behavior/no-shell-execution.md @@ -0,0 +1,40 @@ +# obsidianmd/behavior/no-shell-execution + +📝 Detect usage of child_process for shell command execution. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects imports of `child_process` and `node:child_process`, as well as standalone calls to `execSync()` and `spawnSync()`. Unlike `no-nodejs-modules`, this rule does not respect `Platform.isDesktop` guards — it always reports, since its purpose is behavior classification rather than platform safety. + +## Examples + +### Invalid + +```js +import { exec } from 'child_process'; + +const cp = require('child_process'); + +const cp = await import('child_process'); + +execSync('ls'); + +spawnSync('ls'); + +// Still reported even with a platform guard +if (Platform.isDesktop) { const cp = await import('child_process'); } +``` + +### Valid + +```js +import { Plugin } from 'obsidian'; + +const result = someFunction(); + +exec('command'); +``` diff --git a/docs/rules/behavior/no-system-identity.md b/docs/rules/behavior/no-system-identity.md new file mode 100644 index 0000000..eeab8fc --- /dev/null +++ b/docs/rules/behavior/no-system-identity.md @@ -0,0 +1,35 @@ +# obsidianmd/behavior/no-system-identity + +📝 Detect reads of system identity information that could be used for fingerprinting. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects reads of system identity information such as `os.hostname()`, `os.userInfo()`, `os.networkInterfaces()`, and identity-related environment variables (`process.env.HOME`, `process.env.USERNAME`, `process.env.USER`, `process.env.USERPROFILE`). + +## Examples + +### Invalid + +```js +const name = os.hostname(); + +const info = os.userInfo(); + +const ifaces = os.networkInterfaces(); + +const home = process.env.HOME; + +const user = process.env.USERNAME; +``` + +### Valid + +```js +const cpus = os.cpus(); + +const env = process.env.NODE_ENV; +``` diff --git a/docs/rules/behavior/vault-enumeration.md b/docs/rules/behavior/vault-enumeration.md new file mode 100644 index 0000000..30ea00e --- /dev/null +++ b/docs/rules/behavior/vault-enumeration.md @@ -0,0 +1,33 @@ +# obsidianmd/behavior/vault-enumeration + +📝 Detect enumeration of all files in the vault. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects enumeration of all vault files via `vault.getFiles()`, `vault.getAllLoadedFiles()`, `vault.recurseChildren()`, and `*.getMarkdownFiles()`. This gives the plugin access to every file path in the vault. + +## Examples + +### Invalid + +```js +vault.getFiles(); + +vault.getAllLoadedFiles(); + +vault.recurseChildren(folder); + +this.app.vault.getMarkdownFiles(); +``` + +### Valid + +```js +vault.read('test.md'); + +vault.getAbstractFileByPath('test.md'); +``` diff --git a/docs/rules/behavior/vault-read.md b/docs/rules/behavior/vault-read.md new file mode 100644 index 0000000..bbb96d9 --- /dev/null +++ b/docs/rules/behavior/vault-read.md @@ -0,0 +1,33 @@ +# obsidianmd/behavior/vault-read + +📝 Detect reads of individual vault files via the Obsidian API. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects reads of vault files via `vault.read()`, `vault.cachedRead()`, and `adapter.read()`. This is an informational behavior classification rule — it does not suggest the code is wrong, but flags that the plugin reads vault files. + +## Examples + +### Invalid + +```js +vault.read('test.md'); + +vault.cachedRead('test.md'); + +this.app.vault.read('test.md'); + +adapter.read('test.md'); +``` + +### Valid + +```js +file.read(); + +vault.create('test.md', 'content'); +``` diff --git a/docs/rules/behavior/vault-write.md b/docs/rules/behavior/vault-write.md new file mode 100644 index 0000000..bfec4c0 --- /dev/null +++ b/docs/rules/behavior/vault-write.md @@ -0,0 +1,35 @@ +# obsidianmd/behavior/vault-write + +📝 Detect writes or modifications to vault files via the Obsidian API. + +🚫 This rule is _disabled_ in the following configs: ✅ `recommended`, 🇬🇧 `recommendedWithLocalesEn`. + + + +## Rule details + +This rule detects writes and modifications to vault files via `vault.create()`, `vault.modify()`, `vault.delete()`, `vault.rename()`, `vault.copy()`, `vault.trash()`, and `adapter.write()`. This is an informational behavior classification rule. + +## Examples + +### Invalid + +```js +vault.create('test.md', 'content'); + +vault.modify(file, 'new content'); + +vault.delete(file); + +vault.rename(file, 'new.md'); + +adapter.write('path', 'data'); +``` + +### Valid + +```js +vault.read('test.md'); + +vault.getAbstractFileByPath('test.md'); +``` diff --git a/lib/index.ts b/lib/index.ts index f543241..ae81b48 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -2,6 +2,7 @@ import type { ESLint } from "eslint"; import { commands } from "./rules/commands/index.js"; import { settingsTab } from "./rules/settingsTab/index.js"; import { vault } from "./rules/vault/index.js"; +import { behavior } from "./rules/behavior/index.js"; import detachLeaves from "./rules/detachLeaves.js"; import editorDropPaste from "./rules/editorDropPaste.js"; import hardcodedConfigPath from "./rules/hardcodedConfigPath.js"; @@ -87,6 +88,19 @@ const plugin = { "settings-tab/no-deprecated-display": settingsTab.noDeprecatedDisplay, "vault/iterate": vault.iterate, + "behavior/no-hardware-fingerprinting": behavior.noHardwareFingerprinting, + "behavior/no-system-identity": behavior.noSystemIdentity, + "behavior/no-electron-ipc": behavior.noElectronIpc, + "behavior/clipboard-access": behavior.clipboardAccess, + "behavior/no-local-storage": behavior.noLocalStorage, + "behavior/vault-read": behavior.vaultRead, + "behavior/vault-write": behavior.vaultWrite, + "behavior/vault-enumeration": behavior.vaultEnumeration, + "behavior/no-self-disable-enable": behavior.noSelfDisableEnable, + "behavior/no-self-update": behavior.noSelfUpdate, + "behavior/no-periodic-network": behavior.noPeriodicNetwork, + "behavior/no-filesystem-access": behavior.noFilesystemAccess, + "behavior/no-shell-execution": behavior.noShellExecution, "detach-leaves": detachLeaves, "editor-drop-paste": editorDropPaste, "hardcoded-config-path": hardcodedConfigPath, @@ -169,6 +183,19 @@ const recommendedPluginRulesConfigBase: RulesConfig = { "obsidianmd/validate-manifest": "warn", "obsidianmd/validate-license": ["warn"], "obsidianmd/ui/sentence-case": ["warn", { enforceCamelCaseLower: true }], + "obsidianmd/behavior/no-hardware-fingerprinting": "off", + "obsidianmd/behavior/no-system-identity": "off", + "obsidianmd/behavior/no-electron-ipc": "off", + "obsidianmd/behavior/clipboard-access": "off", + "obsidianmd/behavior/no-local-storage": "off", + "obsidianmd/behavior/vault-read": "off", + "obsidianmd/behavior/vault-write": "off", + "obsidianmd/behavior/vault-enumeration": "off", + "obsidianmd/behavior/no-self-disable-enable": "off", + "obsidianmd/behavior/no-self-update": "off", + "obsidianmd/behavior/no-periodic-network": "off", + "obsidianmd/behavior/no-filesystem-access": "off", + "obsidianmd/behavior/no-shell-execution": "off", } // Combined rules for TypeScript files diff --git a/lib/rules/behavior/clipboardAccess.ts b/lib/rules/behavior/clipboardAccess.ts new file mode 100644 index 0000000..723e955 --- /dev/null +++ b/lib/rules/behavior/clipboardAccess.ts @@ -0,0 +1,56 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const CLIPBOARD_OBJECTS = new Set(["navigator", "electron", "remote"]); + +export default ruleCreator({ + meta: { + type: "suggestion", + docs: { + description: "Detect access to the system clipboard.", + url: docsUrl("clipboard-access", "behavior"), + }, + schema: [], + messages: { + clipboardAccess: + "Accessing the system clipboard ({{api}}) may expose content copied from outside Obsidian.", + }, + }, + defaultOptions: [], + create(context) { + return { + MemberExpression(node: TSESTree.MemberExpression) { + if ( + node.object.type !== AST_NODE_TYPES.Identifier || + !CLIPBOARD_OBJECTS.has(node.object.name) + ) { + return; + } + + let isClipboard = false; + if ( + !node.computed && + node.property.type === AST_NODE_TYPES.Identifier && + node.property.name === "clipboard" + ) { + isClipboard = true; + } else if ( + node.computed && + node.property.type === AST_NODE_TYPES.Literal && + node.property.value === "clipboard" + ) { + isClipboard = true; + } + + if (isClipboard) { + context.report({ + node, + messageId: "clipboardAccess", + data: { api: `${node.object.name}.clipboard` }, + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/index.ts b/lib/rules/behavior/index.ts new file mode 100644 index 0000000..2552215 --- /dev/null +++ b/lib/rules/behavior/index.ts @@ -0,0 +1,29 @@ +import noHardwareFingerprinting from "./noHardwareFingerprinting.js"; +import noSystemIdentity from "./noSystemIdentity.js"; +import noElectronIpc from "./noElectronIpc.js"; +import clipboardAccess from "./clipboardAccess.js"; +import noLocalStorage from "./noLocalStorage.js"; +import vaultRead from "./vaultRead.js"; +import vaultWrite from "./vaultWrite.js"; +import vaultEnumeration from "./vaultEnumeration.js"; +import noSelfDisableEnable from "./noSelfDisableEnable.js"; +import noSelfUpdate from "./noSelfUpdate.js"; +import noPeriodicNetwork from "./noPeriodicNetwork.js"; +import noFilesystemAccess from "./noFilesystemAccess.js"; +import noShellExecution from "./noShellExecution.js"; + +export const behavior = { + noHardwareFingerprinting, + noSystemIdentity, + noElectronIpc, + clipboardAccess, + noLocalStorage, + vaultRead, + vaultWrite, + vaultEnumeration, + noSelfDisableEnable, + noSelfUpdate, + noPeriodicNetwork, + noFilesystemAccess, + noShellExecution, +}; diff --git a/lib/rules/behavior/noElectronIpc.ts b/lib/rules/behavior/noElectronIpc.ts new file mode 100644 index 0000000..2a79b4d --- /dev/null +++ b/lib/rules/behavior/noElectronIpc.ts @@ -0,0 +1,75 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const IPC_APIS = new Set(["ipcRenderer", "ipcMain"]); + +export default ruleCreator({ + meta: { + type: "problem", + docs: { + description: + "Detect usage of Electron IPC for privileged inter-process communication.", + url: docsUrl("no-electron-ipc", "behavior"), + }, + schema: [], + messages: { + electronIpc: + "Usage of Electron IPC ({{api}}) allows privileged operations outside the normal plugin sandbox.", + }, + }, + defaultOptions: [], + create(context) { + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (node.source.value !== "electron") { + return; + } + for (const specifier of node.specifiers) { + if ( + specifier.type === AST_NODE_TYPES.ImportSpecifier && + specifier.imported.type === AST_NODE_TYPES.Identifier && + IPC_APIS.has(specifier.imported.name) + ) { + context.report({ + node: specifier, + messageId: "electronIpc", + data: { api: specifier.imported.name }, + }); + } + } + }, + + MemberExpression(node: TSESTree.MemberExpression) { + if ( + node.object.type !== AST_NODE_TYPES.Identifier || + node.object.name !== "electron" + ) { + return; + } + + let propertyName: string | undefined; + if ( + !node.computed && + node.property.type === AST_NODE_TYPES.Identifier + ) { + propertyName = node.property.name; + } else if ( + node.computed && + node.property.type === AST_NODE_TYPES.Literal && + typeof node.property.value === "string" + ) { + propertyName = node.property.value; + } + + if (propertyName && IPC_APIS.has(propertyName)) { + context.report({ + node, + messageId: "electronIpc", + data: { api: propertyName }, + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/noFilesystemAccess.ts b/lib/rules/behavior/noFilesystemAccess.ts new file mode 100644 index 0000000..5a318ca --- /dev/null +++ b/lib/rules/behavior/noFilesystemAccess.ts @@ -0,0 +1,77 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const FS_MODULES = new Set([ + "fs", + "node:fs", + "fs/promises", + "node:fs/promises", +]); + +export default ruleCreator({ + meta: { + type: "problem", + docs: { + description: + "Detect usage of the Node.js fs module for direct filesystem access outside the Obsidian vault API.", + url: docsUrl("no-filesystem-access", "behavior"), + }, + schema: [], + messages: { + filesystemAccess: + 'Direct filesystem access via Node.js "{{module}}" module. This can read and write any file on the system.', + }, + }, + defaultOptions: [], + create(context) { + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (FS_MODULES.has(node.source.value)) { + context.report({ + node, + messageId: "filesystemAccess", + data: { module: node.source.value }, + }); + } + }, + + ImportExpression(node: TSESTree.ImportExpression) { + if ( + node.source.type === AST_NODE_TYPES.Literal && + typeof node.source.value === "string" && + FS_MODULES.has(node.source.value) + ) { + context.report({ + node, + messageId: "filesystemAccess", + data: { module: node.source.value }, + }); + } + }, + + CallExpression(node: TSESTree.CallExpression) { + if ( + node.callee.type === AST_NODE_TYPES.Identifier && + node.callee.name === "require" && + node.arguments.length > 0 && + node.arguments[0].type === AST_NODE_TYPES.Literal && + typeof (node.arguments[0] as TSESTree.Literal).value === + "string" && + FS_MODULES.has( + (node.arguments[0] as TSESTree.Literal).value as string + ) + ) { + context.report({ + node, + messageId: "filesystemAccess", + data: { + module: (node.arguments[0] as TSESTree.Literal) + .value as string, + }, + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/noHardwareFingerprinting.ts b/lib/rules/behavior/noHardwareFingerprinting.ts new file mode 100644 index 0000000..03afcb3 --- /dev/null +++ b/lib/rules/behavior/noHardwareFingerprinting.ts @@ -0,0 +1,74 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +export default ruleCreator({ + meta: { + type: "problem", + docs: { + description: + "Detect usage of hardware fingerprinting libraries like node-machine-id.", + url: docsUrl("no-hardware-fingerprinting", "behavior"), + }, + schema: [], + messages: { + hardwareFingerprinting: + 'Usage of hardware fingerprinting library "node-machine-id" violates Obsidian\'s developer policies and user privacy.', + }, + }, + defaultOptions: [], + create(context) { + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (node.source.value === "node-machine-id") { + context.report({ + node, + messageId: "hardwareFingerprinting", + }); + } + }, + + ImportExpression(node: TSESTree.ImportExpression) { + if ( + node.source.type === AST_NODE_TYPES.Literal && + node.source.value === "node-machine-id" + ) { + context.report({ + node, + messageId: "hardwareFingerprinting", + }); + } + }, + + CallExpression(node: TSESTree.CallExpression) { + // require("node-machine-id") + if ( + node.callee.type === AST_NODE_TYPES.Identifier && + node.callee.name === "require" && + node.arguments.length > 0 && + node.arguments[0].type === AST_NODE_TYPES.Literal && + (node.arguments[0] as TSESTree.Literal).value === + "node-machine-id" + ) { + context.report({ + node, + messageId: "hardwareFingerprinting", + }); + return; + } + + // machineId() or machineIdSync() + if ( + node.callee.type === AST_NODE_TYPES.Identifier && + (node.callee.name === "machineId" || + node.callee.name === "machineIdSync") + ) { + context.report({ + node, + messageId: "hardwareFingerprinting", + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/noLocalStorage.ts b/lib/rules/behavior/noLocalStorage.ts new file mode 100644 index 0000000..ebfd61b --- /dev/null +++ b/lib/rules/behavior/noLocalStorage.ts @@ -0,0 +1,75 @@ +import { TSESTree, AST_NODE_TYPES } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const STORAGE_NAMES = new Set(["localStorage", "sessionStorage"]); +const STORAGE_METHODS = new Set(["setItem", "getItem", "removeItem"]); + +function getStorageName( + node: TSESTree.Expression, +): string | undefined { + if ( + node.type === AST_NODE_TYPES.Identifier && + STORAGE_NAMES.has(node.name) + ) { + return node.name; + } + + if ( + node.type === AST_NODE_TYPES.MemberExpression && + node.object.type === AST_NODE_TYPES.Identifier && + node.object.name === "window" && + node.property.type === AST_NODE_TYPES.Identifier && + STORAGE_NAMES.has(node.property.name) + ) { + return node.property.name; + } + + return undefined; +} + +export default ruleCreator({ + meta: { + type: "suggestion" as const, + docs: { + description: + "Detect usage of localStorage or sessionStorage instead of the Obsidian plugin data APIs.", + url: docsUrl("no-local-storage", "behavior"), + }, + schema: [], + messages: { + localStorage: + "Using {{api}} to persist data. Consider using Obsidian's plugin data APIs instead.", + }, + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node: TSESTree.CallExpression) { + const callee = node.callee; + if (callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + if ( + callee.property.type !== AST_NODE_TYPES.Identifier || + !STORAGE_METHODS.has(callee.property.name) + ) { + return; + } + + const storageName = getStorageName(callee.object); + if (!storageName) { + return; + } + + context.report({ + node, + messageId: "localStorage", + data: { + api: `${storageName}.${callee.property.name}()`, + }, + }); + }, + }; + }, +}); diff --git a/lib/rules/behavior/noPeriodicNetwork.ts b/lib/rules/behavior/noPeriodicNetwork.ts new file mode 100644 index 0000000..093880f --- /dev/null +++ b/lib/rules/behavior/noPeriodicNetwork.ts @@ -0,0 +1,114 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const NETWORK_APIS = new Set(["fetch", "requestUrl"]); +const SKIP_KEYS = new Set(["parent", "loc", "range", "tokens", "comments"]); + +function containsNetworkCall(node: TSESTree.Node): string | null { + if (node.type === AST_NODE_TYPES.CallExpression) { + if ( + node.callee.type === AST_NODE_TYPES.Identifier && + NETWORK_APIS.has(node.callee.name) + ) { + return node.callee.name; + } + if ( + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.property.type === AST_NODE_TYPES.Identifier && + NETWORK_APIS.has(node.callee.property.name) + ) { + return node.callee.property.name; + } + } + + const nodeRecord = node as unknown as Record; + for (const key of Object.keys(nodeRecord)) { + if (SKIP_KEYS.has(key)) continue; + const value = nodeRecord[key]; + if (value && typeof value === "object") { + if (Array.isArray(value)) { + for (const item of value as unknown[]) { + if ( + item && + typeof item === "object" && + "type" in item && + typeof item.type === "string" + ) { + const result = containsNetworkCall( + item as TSESTree.Node, + ); + if (result) return result; + } + } + } else if ( + "type" in value && + typeof (value as { type: unknown }).type === "string" + ) { + const result = containsNetworkCall(value as TSESTree.Node); + if (result) return result; + } + } + } + return null; +} + +export default ruleCreator({ + meta: { + type: "problem", + docs: { + description: + "Detect periodic network calls via setInterval combined with fetch or requestUrl.", + url: docsUrl("no-periodic-network", "behavior"), + }, + schema: [], + messages: { + periodicNetwork: + "Plugin combines setInterval with network calls ({{api}}). This may indicate periodic background data transmission.", + }, + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node: TSESTree.CallExpression) { + let isSetInterval = false; + + if ( + node.callee.type === AST_NODE_TYPES.Identifier && + node.callee.name === "setInterval" + ) { + isSetInterval = true; + } else if ( + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.object.type === AST_NODE_TYPES.Identifier && + node.callee.object.name === "window" && + node.callee.property.type === AST_NODE_TYPES.Identifier && + node.callee.property.name === "setInterval" + ) { + isSetInterval = true; + } + + if (!isSetInterval || node.arguments.length === 0) { + return; + } + + const callback = node.arguments[0]; + if ( + callback.type !== AST_NODE_TYPES.ArrowFunctionExpression && + callback.type !== AST_NODE_TYPES.FunctionExpression + ) { + return; + } + + const api = containsNetworkCall(callback.body); + if (api) { + context.report({ + node, + messageId: "periodicNetwork", + data: { api }, + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/noSelfDisableEnable.ts b/lib/rules/behavior/noSelfDisableEnable.ts new file mode 100644 index 0000000..42dd3cb --- /dev/null +++ b/lib/rules/behavior/noSelfDisableEnable.ts @@ -0,0 +1,72 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +export default ruleCreator({ + meta: { + type: "problem", + docs: { + description: + "Detect plugins that programmatically disable and re-enable themselves.", + url: docsUrl("no-self-disable-enable", "behavior"), + }, + schema: [], + messages: { + selfDisableEnable: + "Plugin programmatically disables and re-enables itself. This is a known technique for executing newly downloaded code without user awareness.", + }, + }, + defaultOptions: [], + create(context) { + let disableNode: TSESTree.Node | null = null; + let enableNode: TSESTree.Node | null = null; + + return { + CallExpression(node: TSESTree.CallExpression) { + if ( + node.callee.type !== AST_NODE_TYPES.MemberExpression || + node.callee.property.type !== AST_NODE_TYPES.Identifier + ) { + return; + } + + const methodName = node.callee.property.name; + if ( + methodName !== "disablePlugin" && + methodName !== "enablePlugin" + ) { + return; + } + + // Check that the object is *.plugins + if ( + node.callee.object.type !== AST_NODE_TYPES.MemberExpression || + node.callee.object.property.type !== + AST_NODE_TYPES.Identifier || + node.callee.object.property.name !== "plugins" + ) { + return; + } + + if (methodName === "disablePlugin") { + disableNode = node; + } else { + enableNode = node; + } + }, + + "Program:exit"() { + if (disableNode && enableNode) { + context.report({ + node: disableNode, + messageId: "selfDisableEnable", + }); + context.report({ + node: enableNode, + messageId: "selfDisableEnable", + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/noSelfUpdate.ts b/lib/rules/behavior/noSelfUpdate.ts new file mode 100644 index 0000000..6e59c1d --- /dev/null +++ b/lib/rules/behavior/noSelfUpdate.ts @@ -0,0 +1,101 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const PLUGIN_FILES = new Set(["main.js", "manifest.json", "styles.css"]); +const WRITE_FUNCTIONS = new Set([ + "writeFileSync", + "writeFile", + "createWriteStream", +]); +const ZIP_MODULES = new Set(["adm-zip", "jszip"]); +const DECOMPRESS_METHODS = new Set(["unzip", "decompress"]); + +export default ruleCreator({ + meta: { + type: "problem", + docs: { + description: + "Detect plugins that appear to overwrite their own files by extracting an archive.", + url: docsUrl("no-self-update", "behavior"), + }, + schema: [], + messages: { + selfUpdate: + "Plugin appears to overwrite its own files by extracting an archive. This is a self-update mechanism that bypasses Obsidian's plugin update process.", + }, + }, + defaultOptions: [], + create(context) { + let hasFileRefs = false; + let hasFileWrite = false; + let zipNode: TSESTree.Node | null = null; + + return { + Literal(node: TSESTree.Literal) { + if ( + typeof node.value === "string" && + PLUGIN_FILES.has(node.value) + ) { + hasFileRefs = true; + } + }, + + CallExpression(node: TSESTree.CallExpression) { + if ( + node.callee.type === AST_NODE_TYPES.Identifier && + WRITE_FUNCTIONS.has(node.callee.name) + ) { + hasFileWrite = true; + } + + if ( + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.property.type === AST_NODE_TYPES.Identifier && + WRITE_FUNCTIONS.has(node.callee.property.name) + ) { + hasFileWrite = true; + } + + // require("adm-zip") or require("jszip") + if ( + node.callee.type === AST_NODE_TYPES.Identifier && + node.callee.name === "require" && + node.arguments.length > 0 && + node.arguments[0].type === AST_NODE_TYPES.Literal && + typeof node.arguments[0].value === "string" && + ZIP_MODULES.has(node.arguments[0].value) + ) { + zipNode ??= node; + } + + // *.unzip() or *.decompress() + if ( + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.property.type === AST_NODE_TYPES.Identifier && + DECOMPRESS_METHODS.has(node.callee.property.name) + ) { + zipNode ??= node; + } + }, + + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if ( + typeof node.source.value === "string" && + ZIP_MODULES.has(node.source.value) + ) { + zipNode ??= node; + } + }, + + "Program:exit"() { + if (hasFileRefs && hasFileWrite && zipNode) { + context.report({ + node: zipNode, + messageId: "selfUpdate", + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/noShellExecution.ts b/lib/rules/behavior/noShellExecution.ts new file mode 100644 index 0000000..18114ab --- /dev/null +++ b/lib/rules/behavior/noShellExecution.ts @@ -0,0 +1,87 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const CP_MODULES = new Set(["child_process", "node:child_process"]); + +export default ruleCreator({ + meta: { + type: "problem", + docs: { + description: + "Detect usage of child_process for shell command execution.", + url: docsUrl("no-shell-execution", "behavior"), + }, + schema: [], + messages: { + shellExecution: + "Shell execution via {{api}} gives the plugin full control over the system.", + }, + }, + defaultOptions: [], + create(context) { + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (CP_MODULES.has(node.source.value)) { + context.report({ + node, + messageId: "shellExecution", + data: { api: node.source.value }, + }); + } + }, + + ImportExpression(node: TSESTree.ImportExpression) { + if ( + node.source.type === AST_NODE_TYPES.Literal && + typeof node.source.value === "string" && + CP_MODULES.has(node.source.value) + ) { + context.report({ + node, + messageId: "shellExecution", + data: { api: node.source.value }, + }); + } + }, + + CallExpression(node: TSESTree.CallExpression) { + // require("child_process") or require("node:child_process") + if ( + node.callee.type === AST_NODE_TYPES.Identifier && + node.callee.name === "require" && + node.arguments.length > 0 && + node.arguments[0].type === AST_NODE_TYPES.Literal && + typeof (node.arguments[0] as TSESTree.Literal).value === + "string" && + CP_MODULES.has( + (node.arguments[0] as TSESTree.Literal).value as string + ) + ) { + context.report({ + node, + messageId: "shellExecution", + data: { + api: (node.arguments[0] as TSESTree.Literal) + .value as string, + }, + }); + return; + } + + // execSync() or spawnSync() + if ( + node.callee.type === AST_NODE_TYPES.Identifier && + (node.callee.name === "execSync" || + node.callee.name === "spawnSync") + ) { + context.report({ + node, + messageId: "shellExecution", + data: { api: `${node.callee.name}()` }, + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/noSystemIdentity.ts b/lib/rules/behavior/noSystemIdentity.ts new file mode 100644 index 0000000..ec8aab7 --- /dev/null +++ b/lib/rules/behavior/noSystemIdentity.ts @@ -0,0 +1,66 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import type { TSESTree } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const OS_IDENTITY_METHODS = new Set(["hostname", "userInfo", "networkInterfaces"]); +const ENV_IDENTITY_VARS = new Set(["HOME", "USERNAME", "USER", "USERPROFILE"]); + +export default ruleCreator({ + meta: { + type: "problem", + docs: { + description: + "Detect reads of system identity information that could be used for fingerprinting.", + url: docsUrl("no-system-identity", "behavior"), + }, + schema: [], + messages: { + systemIdentity: + "Reading system identity information ({{api}}) may be used to fingerprint the user's machine.", + }, + }, + defaultOptions: [], + create(context) { + return { + // os.hostname(), os.userInfo(), os.networkInterfaces() + CallExpression(node: TSESTree.CallExpression) { + if ( + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.object.type === AST_NODE_TYPES.Identifier && + node.callee.object.name === "os" && + node.callee.property.type === AST_NODE_TYPES.Identifier && + OS_IDENTITY_METHODS.has(node.callee.property.name) + ) { + context.report({ + node, + messageId: "systemIdentity", + data: { + api: `os.${node.callee.property.name}()`, + }, + }); + } + }, + + // process.env.HOME, process.env.USERNAME, etc. + MemberExpression(node: TSESTree.MemberExpression) { + if ( + node.object.type === AST_NODE_TYPES.MemberExpression && + node.object.object.type === AST_NODE_TYPES.Identifier && + node.object.object.name === "process" && + node.object.property.type === AST_NODE_TYPES.Identifier && + node.object.property.name === "env" && + node.property.type === AST_NODE_TYPES.Identifier && + ENV_IDENTITY_VARS.has(node.property.name) + ) { + context.report({ + node, + messageId: "systemIdentity", + data: { + api: `process.env.${node.property.name}`, + }, + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/vaultEnumeration.ts b/lib/rules/behavior/vaultEnumeration.ts new file mode 100644 index 0000000..e1e99e3 --- /dev/null +++ b/lib/rules/behavior/vaultEnumeration.ts @@ -0,0 +1,68 @@ +import { TSESTree, AST_NODE_TYPES } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const VAULT_ENUMERATION_METHODS = new Set([ + "getFiles", + "getAllLoadedFiles", + "recurseChildren", +]); + +function isVaultObject(node: TSESTree.Expression): boolean { + if (node.type === AST_NODE_TYPES.Identifier && node.name === "vault") + return true; + if ( + node.type === AST_NODE_TYPES.MemberExpression && + node.property.type === AST_NODE_TYPES.Identifier && + node.property.name === "vault" + ) + return true; + return false; +} + +export default ruleCreator({ + meta: { + type: "suggestion", + docs: { + description: "Detect enumeration of all files in the vault.", + url: docsUrl("vault-enumeration", "behavior"), + }, + schema: [], + messages: { + vaultEnumeration: + "Plugin enumerates vault files via {{api}}, giving access to every file path in the vault.", + }, + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node: TSESTree.CallExpression) { + if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return; + + const callee = node.callee; + if (callee.property.type !== AST_NODE_TYPES.Identifier) return; + + const methodName = callee.property.name; + + if ( + isVaultObject(callee.object) && + VAULT_ENUMERATION_METHODS.has(methodName) + ) { + context.report({ + node, + messageId: "vaultEnumeration", + data: { api: `vault.${methodName}()` }, + }); + return; + } + + if (methodName === "getMarkdownFiles") { + context.report({ + node, + messageId: "vaultEnumeration", + data: { api: "*.getMarkdownFiles()" }, + }); + } + }, + }; + }, +}); diff --git a/lib/rules/behavior/vaultRead.ts b/lib/rules/behavior/vaultRead.ts new file mode 100644 index 0000000..64b4f33 --- /dev/null +++ b/lib/rules/behavior/vaultRead.ts @@ -0,0 +1,84 @@ +import { TSESTree, AST_NODE_TYPES } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const READ_METHODS = new Set(["read", "cachedRead"]); + +function isVaultObject(node: TSESTree.Expression): boolean { + if (node.type === AST_NODE_TYPES.Identifier && node.name === "vault") { + return true; + } + if ( + node.type === AST_NODE_TYPES.MemberExpression && + node.property.type === AST_NODE_TYPES.Identifier && + node.property.name === "vault" + ) { + return true; + } + return false; +} + +function isAdapterObject(node: TSESTree.Expression): boolean { + if (node.type === AST_NODE_TYPES.Identifier && node.name === "adapter") { + return true; + } + if ( + node.type === AST_NODE_TYPES.MemberExpression && + node.property.type === AST_NODE_TYPES.Identifier && + node.property.name === "adapter" + ) { + return true; + } + return false; +} + +export default ruleCreator({ + meta: { + type: "suggestion" as const, + docs: { + description: + "Detect reads of individual vault files via the Obsidian API.", + url: docsUrl("vault-read", "behavior"), + }, + schema: [], + messages: { + vaultRead: "Plugin reads vault files via {{api}}.", + }, + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node: TSESTree.CallExpression) { + const callee = node.callee; + if (callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + if ( + callee.property.type !== AST_NODE_TYPES.Identifier || + !READ_METHODS.has(callee.property.name) + ) { + return; + } + + let objectLabel: string | undefined; + if (isVaultObject(callee.object)) { + objectLabel = "vault"; + } else if (isAdapterObject(callee.object)) { + objectLabel = "adapter"; + } + + if (!objectLabel) { + return; + } + + context.report({ + node, + messageId: "vaultRead", + data: { + api: `${objectLabel}.${callee.property.name}()`, + }, + }); + }, + }; + }, +}); diff --git a/lib/rules/behavior/vaultWrite.ts b/lib/rules/behavior/vaultWrite.ts new file mode 100644 index 0000000..799d317 --- /dev/null +++ b/lib/rules/behavior/vaultWrite.ts @@ -0,0 +1,86 @@ +import { TSESTree, AST_NODE_TYPES } from "@typescript-eslint/utils"; +import { docsUrl, ruleCreator } from "../../ruleCreator.js"; + +const VAULT_WRITE_METHODS = new Set([ + "create", + "modify", + "delete", + "rename", + "copy", + "trash", +]); + +function isVaultObject(node: TSESTree.Expression): boolean { + if (node.type === AST_NODE_TYPES.Identifier && node.name === "vault") + return true; + if ( + node.type === AST_NODE_TYPES.MemberExpression && + node.property.type === AST_NODE_TYPES.Identifier && + node.property.name === "vault" + ) + return true; + return false; +} + +function isAdapterObject(node: TSESTree.Expression): boolean { + if (node.type === AST_NODE_TYPES.Identifier && node.name === "adapter") + return true; + if ( + node.type === AST_NODE_TYPES.MemberExpression && + node.property.type === AST_NODE_TYPES.Identifier && + node.property.name === "adapter" + ) + return true; + return false; +} + +export default ruleCreator({ + meta: { + type: "suggestion", + docs: { + description: + "Detect writes or modifications to vault files via the Obsidian API.", + url: docsUrl("vault-write", "behavior"), + }, + schema: [], + messages: { + vaultWrite: "Plugin modifies vault files via {{api}}.", + }, + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node: TSESTree.CallExpression) { + if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return; + + const callee = node.callee; + if (callee.property.type !== AST_NODE_TYPES.Identifier) return; + + const methodName = callee.property.name; + + if ( + isVaultObject(callee.object) && + VAULT_WRITE_METHODS.has(methodName) + ) { + context.report({ + node, + messageId: "vaultWrite", + data: { api: `vault.${methodName}()` }, + }); + return; + } + + if ( + isAdapterObject(callee.object) && + methodName === "write" + ) { + context.report({ + node, + messageId: "vaultWrite", + data: { api: "adapter.write()" }, + }); + } + }, + }; + }, +}); diff --git a/tests/behavior/clipboardAccess.test.ts b/tests/behavior/clipboardAccess.test.ts new file mode 100644 index 0000000..fe17b85 --- /dev/null +++ b/tests/behavior/clipboardAccess.test.ts @@ -0,0 +1,52 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/clipboardAccess.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-clipboard-access", rule, { + valid: [ + { + name: "different navigator property is allowed", + code: "navigator.userAgent;", + }, + { + name: "unrelated clipboard variable is allowed", + code: "const clipboard = new MyClipboard();", + }, + { + name: "unrelated DOM access is allowed", + code: "document.getElementById('clipboard');", + }, + { + name: "clipboard property on unknown object is allowed", + code: "myObj.clipboard.readText();", + }, + ], + invalid: [ + { + name: "navigator.clipboard.writeText is forbidden", + code: "navigator.clipboard.writeText('test');", + errors: [{ messageId: "clipboardAccess", data: { api: "navigator.clipboard" } }], + }, + { + name: "navigator.clipboard.readText is forbidden", + code: "navigator.clipboard.readText();", + errors: [{ messageId: "clipboardAccess", data: { api: "navigator.clipboard" } }], + }, + { + name: "electron.clipboard.readText is forbidden", + code: "electron.clipboard.readText();", + errors: [{ messageId: "clipboardAccess", data: { api: "electron.clipboard" } }], + }, + { + name: "remote.clipboard.writeText is forbidden", + code: "remote.clipboard.writeText('test');", + errors: [{ messageId: "clipboardAccess", data: { api: "remote.clipboard" } }], + }, + { + name: "bracket notation navigator['clipboard'] is forbidden", + code: 'navigator["clipboard"].writeText(\'test\');', + errors: [{ messageId: "clipboardAccess", data: { api: "navigator.clipboard" } }], + }, + ], +}); diff --git a/tests/behavior/index.ts b/tests/behavior/index.ts new file mode 100644 index 0000000..d321c5d --- /dev/null +++ b/tests/behavior/index.ts @@ -0,0 +1,13 @@ +import "./noHardwareFingerprinting.test"; +import "./noSystemIdentity.test"; +import "./noElectronIpc.test"; +import "./clipboardAccess.test"; +import "./noLocalStorage.test"; +import "./vaultRead.test"; +import "./vaultWrite.test"; +import "./vaultEnumeration.test"; +import "./noSelfDisableEnable.test"; +import "./noSelfUpdate.test"; +import "./noPeriodicNetwork.test"; +import "./noFilesystemAccess.test"; +import "./noShellExecution.test"; diff --git a/tests/behavior/noElectronIpc.test.ts b/tests/behavior/noElectronIpc.test.ts new file mode 100644 index 0000000..9c02a87 --- /dev/null +++ b/tests/behavior/noElectronIpc.test.ts @@ -0,0 +1,55 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/noElectronIpc.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-no-electron-ipc", rule, { + valid: [ + { + name: "normal obsidian import is allowed", + code: "import { app } from 'obsidian';", + }, + { + name: "unrelated identifier is allowed", + code: "const renderer = new Renderer();", + }, + { + name: "different package with electron in name is allowed", + code: "import electron from 'some-electron-utils';", + }, + { + name: "importing non-IPC APIs from electron is allowed", + code: "import { app, BrowserWindow } from 'electron';", + }, + ], + invalid: [ + { + name: "importing ipcRenderer from electron is forbidden", + code: "import { ipcRenderer } from 'electron';", + errors: [{ messageId: "electronIpc", data: { api: "ipcRenderer" } }], + }, + { + name: "importing ipcMain from electron is forbidden", + code: "import { ipcMain } from 'electron';", + errors: [{ messageId: "electronIpc", data: { api: "ipcMain" } }], + }, + { + name: "accessing electron.ipcRenderer is forbidden", + code: "electron.ipcRenderer.send('test');", + errors: [{ messageId: "electronIpc", data: { api: "ipcRenderer" } }], + }, + { + name: "accessing electron.ipcMain is forbidden", + code: "electron.ipcMain.on('test', () => {});", + errors: [{ messageId: "electronIpc", data: { api: "ipcMain" } }], + }, + { + name: "importing both ipcRenderer and ipcMain from electron reports two errors", + code: "import { ipcRenderer, ipcMain } from 'electron';", + errors: [ + { messageId: "electronIpc", data: { api: "ipcRenderer" } }, + { messageId: "electronIpc", data: { api: "ipcMain" } }, + ], + }, + ], +}); diff --git a/tests/behavior/noFilesystemAccess.test.ts b/tests/behavior/noFilesystemAccess.test.ts new file mode 100644 index 0000000..cdd2710 --- /dev/null +++ b/tests/behavior/noFilesystemAccess.test.ts @@ -0,0 +1,63 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/noFilesystemAccess.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-no-filesystem-access", rule, { + valid: [ + { + name: "importing obsidian is allowed", + code: "import { Plugin } from 'obsidian';", + }, + { + name: "importing a different Node module is allowed", + code: "import path from 'path';", + }, + { + name: "using the Obsidian vault API is allowed", + code: "const data = vault.read('test.md');", + }, + ], + invalid: [ + { + name: "static import of fs is forbidden", + code: "import fs from 'fs';", + errors: [{ messageId: "filesystemAccess", data: { module: "fs" } }], + }, + { + name: "static import of node:fs is forbidden", + code: "import fs from 'node:fs';", + errors: [ + { + messageId: "filesystemAccess", + data: { module: "node:fs" }, + }, + ], + }, + { + name: "static import of fs/promises is forbidden", + code: "import { readFile } from 'fs/promises';", + errors: [ + { + messageId: "filesystemAccess", + data: { module: "fs/promises" }, + }, + ], + }, + { + name: "require of fs is forbidden", + code: "const fs = require('fs');", + errors: [{ messageId: "filesystemAccess", data: { module: "fs" } }], + }, + { + name: "dynamic import of fs is forbidden", + code: "const fs = await import('fs');", + errors: [{ messageId: "filesystemAccess", data: { module: "fs" } }], + }, + { + name: "Platform.isDesktop guard does NOT suppress the report", + code: "if (Platform.isDesktop) { const fs = await import('fs'); }", + errors: [{ messageId: "filesystemAccess", data: { module: "fs" } }], + }, + ], +}); diff --git a/tests/behavior/noHardwareFingerprinting.test.ts b/tests/behavior/noHardwareFingerprinting.test.ts new file mode 100644 index 0000000..0c9c5df --- /dev/null +++ b/tests/behavior/noHardwareFingerprinting.test.ts @@ -0,0 +1,43 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/noHardwareFingerprinting.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-no-hardware-fingerprinting", rule, { + valid: [ + { + name: "importing obsidian is allowed", + code: "import { Plugin } from 'obsidian';", + }, + { + name: "unrelated function call is allowed", + code: "const id = generateId();", + }, + { + name: "importing machineId from a different package is allowed", + code: "import machineId from 'some-other-package';", + }, + ], + invalid: [ + { + name: "static import of node-machine-id is forbidden", + code: "import { machineIdSync } from 'node-machine-id';", + errors: [{ messageId: "hardwareFingerprinting" }], + }, + { + name: "require of node-machine-id is forbidden", + code: "const id = require('node-machine-id');", + errors: [{ messageId: "hardwareFingerprinting" }], + }, + { + name: "calling machineIdSync is forbidden", + code: "const id = machineIdSync();", + errors: [{ messageId: "hardwareFingerprinting" }], + }, + { + name: "dynamic import of node-machine-id is forbidden", + code: "const id = await import('node-machine-id');", + errors: [{ messageId: "hardwareFingerprinting" }], + }, + ], +}); diff --git a/tests/behavior/noLocalStorage.test.ts b/tests/behavior/noLocalStorage.test.ts new file mode 100644 index 0000000..8b19ad9 --- /dev/null +++ b/tests/behavior/noLocalStorage.test.ts @@ -0,0 +1,73 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/noLocalStorage.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-no-local-storage", rule, { + valid: [ + { + name: "property access on localStorage is allowed", + code: "localStorage.length;", + }, + { + name: "setItem on unrelated object is allowed", + code: "myStorage.setItem('key', 'value');", + }, + { + name: "Obsidian plugin data API is allowed", + code: "this.plugin.loadData();", + }, + ], + invalid: [ + { + name: "localStorage.setItem is flagged", + code: "localStorage.setItem('key', 'value');", + errors: [ + { + messageId: "localStorage", + data: { api: "localStorage.setItem()" }, + }, + ], + }, + { + name: "localStorage.getItem is flagged", + code: "localStorage.getItem('key');", + errors: [ + { + messageId: "localStorage", + data: { api: "localStorage.getItem()" }, + }, + ], + }, + { + name: "localStorage.removeItem is flagged", + code: "localStorage.removeItem('key');", + errors: [ + { + messageId: "localStorage", + data: { api: "localStorage.removeItem()" }, + }, + ], + }, + { + name: "sessionStorage.setItem is flagged", + code: "sessionStorage.setItem('key', 'value');", + errors: [ + { + messageId: "localStorage", + data: { api: "sessionStorage.setItem()" }, + }, + ], + }, + { + name: "window.localStorage.setItem is flagged", + code: "window.localStorage.setItem('key', 'value');", + errors: [ + { + messageId: "localStorage", + data: { api: "localStorage.setItem()" }, + }, + ], + }, + ], +}); diff --git a/tests/behavior/noPeriodicNetwork.test.ts b/tests/behavior/noPeriodicNetwork.test.ts new file mode 100644 index 0000000..0edb9d6 --- /dev/null +++ b/tests/behavior/noPeriodicNetwork.test.ts @@ -0,0 +1,63 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/noPeriodicNetwork.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-no-periodic-network", rule, { + valid: [ + { + name: "setInterval without network call is allowed", + code: "setInterval(() => { console.log('tick'); }, 1000);", + }, + { + name: "fetch without setInterval is allowed", + code: "fetch('/api/data');", + }, + { + name: "named function ref is allowed (documented limitation)", + code: "setInterval(pollServer, 1000);", + }, + ], + invalid: [ + { + name: "setInterval with fetch in arrow function", + code: "setInterval(() => { fetch('/api'); }, 60000);", + errors: [ + { + messageId: "periodicNetwork", + data: { api: "fetch" }, + }, + ], + }, + { + name: "setInterval with requestUrl in function expression", + code: "setInterval(function() { requestUrl({ url: '/api' }); }, 60000);", + errors: [ + { + messageId: "periodicNetwork", + data: { api: "requestUrl" }, + }, + ], + }, + { + name: "window.setInterval with fetch", + code: "window.setInterval(() => { fetch('/api'); }, 60000);", + errors: [ + { + messageId: "periodicNetwork", + data: { api: "fetch" }, + }, + ], + }, + { + name: "nested fetch inside conditional", + code: "setInterval(() => { if (condition) { fetch('/api'); } }, 60000);", + errors: [ + { + messageId: "periodicNetwork", + data: { api: "fetch" }, + }, + ], + }, + ], +}); diff --git a/tests/behavior/noSelfDisableEnable.test.ts b/tests/behavior/noSelfDisableEnable.test.ts new file mode 100644 index 0000000..00ce147 --- /dev/null +++ b/tests/behavior/noSelfDisableEnable.test.ts @@ -0,0 +1,39 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/noSelfDisableEnable.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-no-self-disable-enable", rule, { + valid: [ + { + name: "only disablePlugin without enablePlugin is allowed", + code: "app.plugins.disablePlugin(this.manifest.id);", + }, + { + name: "only enablePlugin without disablePlugin is allowed", + code: "app.plugins.enablePlugin('some-id');", + }, + { + name: "unrelated code is allowed", + code: "doSomething(); doSomethingElse();", + }, + ], + invalid: [ + { + name: "disablePlugin and enablePlugin together is forbidden", + code: "app.plugins.disablePlugin(this.manifest.id); app.plugins.enablePlugin(this.manifest.id);", + errors: [ + { messageId: "selfDisableEnable" }, + { messageId: "selfDisableEnable" }, + ], + }, + { + name: "this.app.plugins variant is also forbidden", + code: "this.app.plugins.disablePlugin(id); this.app.plugins.enablePlugin(id);", + errors: [ + { messageId: "selfDisableEnable" }, + { messageId: "selfDisableEnable" }, + ], + }, + ], +}); diff --git a/tests/behavior/noSelfUpdate.test.ts b/tests/behavior/noSelfUpdate.test.ts new file mode 100644 index 0000000..ad64856 --- /dev/null +++ b/tests/behavior/noSelfUpdate.test.ts @@ -0,0 +1,40 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/noSelfUpdate.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-no-self-update", rule, { + valid: [ + { + name: "file reference alone is allowed", + code: 'const f = "main.js";', + }, + { + name: "file write alone is allowed", + code: "writeFileSync(path, data);", + }, + { + name: "zip import alone is allowed", + code: "import AdmZip from 'adm-zip';", + }, + { + name: "two of three groups is allowed (file ref + write)", + code: 'const f = "main.js"; writeFileSync(f, data);', + }, + { + name: "two of three groups is allowed (file ref + zip)", + code: `const f = "main.js"; import AdmZip from 'adm-zip';`, + }, + { + name: "two of three groups is allowed (write + zip)", + code: "writeFileSync(path, data); import AdmZip from 'adm-zip';", + }, + ], + invalid: [ + { + name: "all three groups present triggers error", + code: `const f = "main.js"; writeFileSync(f, data); import AdmZip from 'adm-zip';`, + errors: [{ messageId: "selfUpdate" }], + }, + ], +}); diff --git a/tests/behavior/noShellExecution.test.ts b/tests/behavior/noShellExecution.test.ts new file mode 100644 index 0000000..f71401b --- /dev/null +++ b/tests/behavior/noShellExecution.test.ts @@ -0,0 +1,93 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/noShellExecution.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-no-shell-execution", rule, { + valid: [ + { + name: "importing obsidian is allowed", + code: "import { Plugin } from 'obsidian';", + }, + { + name: "unrelated function call is allowed", + code: "const result = someFunction();", + }, + { + name: "exec is allowed (not execSync or spawnSync)", + code: "exec('command');", + }, + ], + invalid: [ + { + name: "static import of child_process is forbidden", + code: "import { exec } from 'child_process';", + errors: [ + { + messageId: "shellExecution", + data: { api: "child_process" }, + }, + ], + }, + { + name: "static import of node:child_process is forbidden", + code: "import cp from 'node:child_process';", + errors: [ + { + messageId: "shellExecution", + data: { api: "node:child_process" }, + }, + ], + }, + { + name: "require of child_process is forbidden", + code: "const cp = require('child_process');", + errors: [ + { + messageId: "shellExecution", + data: { api: "child_process" }, + }, + ], + }, + { + name: "dynamic import of child_process is forbidden", + code: "const cp = await import('child_process');", + errors: [ + { + messageId: "shellExecution", + data: { api: "child_process" }, + }, + ], + }, + { + name: "execSync call is forbidden", + code: "execSync('ls');", + errors: [ + { + messageId: "shellExecution", + data: { api: "execSync()" }, + }, + ], + }, + { + name: "spawnSync call is forbidden", + code: "spawnSync('ls');", + errors: [ + { + messageId: "shellExecution", + data: { api: "spawnSync()" }, + }, + ], + }, + { + name: "Platform.isDesktop guard does NOT suppress the report", + code: "if (Platform.isDesktop) { const cp = await import('child_process'); }", + errors: [ + { + messageId: "shellExecution", + data: { api: "child_process" }, + }, + ], + }, + ], +}); diff --git a/tests/behavior/noSystemIdentity.test.ts b/tests/behavior/noSystemIdentity.test.ts new file mode 100644 index 0000000..636c8a2 --- /dev/null +++ b/tests/behavior/noSystemIdentity.test.ts @@ -0,0 +1,73 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/noSystemIdentity.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-no-system-identity", rule, { + valid: [ + { + name: "os.cpus() is allowed", + code: "os.cpus();", + }, + { + name: "process.env.NODE_ENV is allowed", + code: "const env = process.env.NODE_ENV;", + }, + { + name: "unrelated hostname function is allowed", + code: "const hostname = getHostname();", + }, + ], + invalid: [ + { + name: "os.hostname() is forbidden", + code: "os.hostname();", + errors: [ + { + messageId: "systemIdentity", + data: { api: "os.hostname()" }, + }, + ], + }, + { + name: "os.userInfo() is forbidden", + code: "os.userInfo();", + errors: [ + { + messageId: "systemIdentity", + data: { api: "os.userInfo()" }, + }, + ], + }, + { + name: "os.networkInterfaces() is forbidden", + code: "os.networkInterfaces();", + errors: [ + { + messageId: "systemIdentity", + data: { api: "os.networkInterfaces()" }, + }, + ], + }, + { + name: "process.env.HOME is forbidden", + code: "const home = process.env.HOME;", + errors: [ + { + messageId: "systemIdentity", + data: { api: "process.env.HOME" }, + }, + ], + }, + { + name: "process.env.USERNAME is forbidden", + code: "const user = process.env.USERNAME;", + errors: [ + { + messageId: "systemIdentity", + data: { api: "process.env.USERNAME" }, + }, + ], + }, + ], +}); diff --git a/tests/behavior/vaultEnumeration.test.ts b/tests/behavior/vaultEnumeration.test.ts new file mode 100644 index 0000000..73dadb0 --- /dev/null +++ b/tests/behavior/vaultEnumeration.test.ts @@ -0,0 +1,53 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/vaultEnumeration.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-vault-enumeration", rule, { + valid: [ + { + name: "vault.read is not enumeration", + code: "vault.read('test.md')", + }, + { + name: "vault.getAbstractFileByPath is a single file lookup", + code: "vault.getAbstractFileByPath('test.md')", + }, + { + name: "non-vault object calling getFiles is allowed", + code: "files.getFiles()", + }, + ], + invalid: [ + { + name: "vault.getFiles enumerates all files", + code: "vault.getFiles()", + errors: [{ messageId: "vaultEnumeration", data: { api: "vault.getFiles()" } }], + }, + { + name: "vault.getAllLoadedFiles enumerates all files", + code: "vault.getAllLoadedFiles()", + errors: [{ messageId: "vaultEnumeration", data: { api: "vault.getAllLoadedFiles()" } }], + }, + { + name: "vault.recurseChildren enumerates all files", + code: "vault.recurseChildren(folder)", + errors: [{ messageId: "vaultEnumeration", data: { api: "vault.recurseChildren()" } }], + }, + { + name: "this.app.vault.getFiles enumerates via member expression", + code: "this.app.vault.getFiles()", + errors: [{ messageId: "vaultEnumeration", data: { api: "vault.getFiles()" } }], + }, + { + name: "getMarkdownFiles on any object enumerates markdown files", + code: "something.getMarkdownFiles()", + errors: [{ messageId: "vaultEnumeration", data: { api: "*.getMarkdownFiles()" } }], + }, + { + name: "this.app.vault.getMarkdownFiles enumerates markdown files", + code: "this.app.vault.getMarkdownFiles()", + errors: [{ messageId: "vaultEnumeration", data: { api: "*.getMarkdownFiles()" } }], + }, + ], +}); diff --git a/tests/behavior/vaultRead.test.ts b/tests/behavior/vaultRead.test.ts new file mode 100644 index 0000000..2bf48b9 --- /dev/null +++ b/tests/behavior/vaultRead.test.ts @@ -0,0 +1,73 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/vaultRead.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-vault-read", rule, { + valid: [ + { + name: "read on unrelated object is allowed", + code: "file.read();", + }, + { + name: "vault.create is allowed", + code: "vault.create('test.md', 'content');", + }, + { + name: "stream.read is allowed", + code: "stream.read();", + }, + ], + invalid: [ + { + name: "vault.read is flagged", + code: "vault.read('test.md');", + errors: [ + { + messageId: "vaultRead", + data: { api: "vault.read()" }, + }, + ], + }, + { + name: "vault.cachedRead is flagged", + code: "vault.cachedRead('test.md');", + errors: [ + { + messageId: "vaultRead", + data: { api: "vault.cachedRead()" }, + }, + ], + }, + { + name: "this.app.vault.read is flagged", + code: "this.app.vault.read('test.md');", + errors: [ + { + messageId: "vaultRead", + data: { api: "vault.read()" }, + }, + ], + }, + { + name: "adapter.read is flagged", + code: "adapter.read('test.md');", + errors: [ + { + messageId: "vaultRead", + data: { api: "adapter.read()" }, + }, + ], + }, + { + name: "app.vault.cachedRead is flagged", + code: "app.vault.cachedRead('test.md');", + errors: [ + { + messageId: "vaultRead", + data: { api: "vault.cachedRead()" }, + }, + ], + }, + ], +}); diff --git a/tests/behavior/vaultWrite.test.ts b/tests/behavior/vaultWrite.test.ts new file mode 100644 index 0000000..9769c4b --- /dev/null +++ b/tests/behavior/vaultWrite.test.ts @@ -0,0 +1,63 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../../lib/rules/behavior/vaultWrite.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("behavior-vault-write", rule, { + valid: [ + { + name: "vault.read is not a write method", + code: "vault.read('test.md')", + }, + { + name: "non-vault object calling create is allowed", + code: "file.create('test.md')", + }, + { + name: "vault.getAbstractFileByPath is not a write method", + code: "vault.getAbstractFileByPath('test.md')", + }, + ], + invalid: [ + { + name: "vault.create is a write operation", + code: "vault.create('test.md', 'content')", + errors: [{ messageId: "vaultWrite", data: { api: "vault.create()" } }], + }, + { + name: "vault.modify is a write operation", + code: "vault.modify(file, 'new content')", + errors: [{ messageId: "vaultWrite", data: { api: "vault.modify()" } }], + }, + { + name: "vault.delete is a write operation", + code: "vault.delete(file)", + errors: [{ messageId: "vaultWrite", data: { api: "vault.delete()" } }], + }, + { + name: "vault.rename is a write operation", + code: "vault.rename(file, 'new.md')", + errors: [{ messageId: "vaultWrite", data: { api: "vault.rename()" } }], + }, + { + name: "vault.copy is a write operation", + code: "vault.copy(file, 'copy.md')", + errors: [{ messageId: "vaultWrite", data: { api: "vault.copy()" } }], + }, + { + name: "vault.trash is a write operation", + code: "vault.trash(file, true)", + errors: [{ messageId: "vaultWrite", data: { api: "vault.trash()" } }], + }, + { + name: "adapter.write is a write operation", + code: "adapter.write('path', 'data')", + errors: [{ messageId: "vaultWrite", data: { api: "adapter.write()" } }], + }, + { + name: "this.app.vault.modify is detected via member expression", + code: "this.app.vault.modify(file, 'content')", + errors: [{ messageId: "vaultWrite", data: { api: "vault.modify()" } }], + }, + ], +});