{"slug": "cursor-extensions-audit-12-steps-60-min-2026", "title": "Cursor Extensions Audit: 12 Steps, 60 Min [2026]", "summary": "A tampered build of Anthropic's Claude Code extension (Anthropic.claude-code versions 2.1.312 and 2.1.313) sat in Cursor's extension marketplace for roughly eleven hours on October 29, 2026, attempting to install a persistent SSH backdoor on every Mac and Windows machine that loaded it, according to Cursor's security notice. Cursor said the versions, live from about 2:27am to 1:35pm Pacific and not built by Anthropic, collected host and user details, tried to add an attacker-controlled SSH key labeled svc-telemetry to authorized_keys, and dropped LaunchAgent and scheduled-task persistence with retries roughly every 60 seconds; the company removed the versions, shut and rebuilt the marketplace, rotated keys, and said its audit found no other extensions tampered with. The incident prompted a published 12-step, 60-minute Python audit kit that inventories installed extensions, flags known-bad versions, checks for backdoor traces, and fails CI jobs that recommend unapproved extensions.", "body_md": "On October 29, 2026, a tampered build of the Claude Code extension sat in Cursor’s extension marketplace for about eleven hours, and it tried to leave a persistent SSH backdoor on every Mac and Windows machine that loaded it. [Security write-ups of the incident](https://www.orchestriai.com/blog/cursor-marketplace-claude-code-extension-malware-check) agree with Cursor’s own notice on the outline: versions 2.1.312 and 2.1.313 of Anthropic.claude-code, live between roughly 2:27am and 1:35pm Pacific, and not built by Anthropic.\n\nThat episode turned a boring housekeeping chore into an urgent one. Cursor extensions run with your user’s permissions, so each one can read your repos, your environment variable names, and your SSH folder. Most developers have never written down which ones they have installed, which is exactly the gap behind [the tampered Claude Code extension story](https://shattered.io/cursor-tampered-claude-code-extension-5000-users-2026/).\n\nThe fix is a small Python kit with no dependencies. It inventories every extension in your Cursor folder, flags known-bad versions and indicators of compromise, checks the machine for the backdoor traces Cursor described, pins the versions you trust, and fails a CI job when a repo recommends something unapproved. It is small enough to build and test in a single sitting.\n\nDon't miss new tech stories on Google\n\nAdd Shattered once in the Google app and our stories show up in your news suggestions.\n\n[Add now](https://www.google.com/preferences/source?hl=en&q=https%3A%2F%2Fshattered.io%2F)\n\n## What Happened on October 29 and Why Cursor Extensions Need an Audit\n\nCursor published a security notice the same day. It said the malicious versions collected host and user details, tried to add an attacker-controlled SSH key to `authorized_keys`, tried to enable the local SSH service, and tried to open a reverse tunnel. Cursor also said that turning on the SSH service needs administrator rights, which the extension lacks on most macOS and Windows machines, so on those hosts the login step fails. The key, the persistence and the host report still happened, which is why the notice told everyone to remediate regardless.\n\n| Item | What Cursor’s notice says | \n|---|---|\n| Affected package | Anthropic.claude-code 2.1.312 and 2.1.313, macOS and Windows | \n| Exposure window | About 2:27am to 1:35pm PT on October 29, 2026 | \n| Who built it | Not Anthropic, according to Cursor | \n| Key label | An SSH public key labelled svc-telemetry | \n| Persistence | LaunchAgent on macOS, scheduled task with a Startup-script fallback on Windows, retry roughly every 60 seconds | \n| Cursor’s response | Removed the versions, blocked distribution, shut the marketplace, audited access, rotated keys, rebuilt it and brought it back online | \n| Attacker server | Taken offline with the hosting provider, per Cursor | \n\nCursor also said its audit confirmed no other extensions were tampered with. Treat that as the vendor’s statement rather than an independent finding. The notice adds a warning that matters for everything below: uninstalling the extension may not be enough, because the payload drops standalone persistence scripts that keep running.\n\nOne report says the compromised version was replaced by a patched 2.1.314, and that [how the attackers got in remained unexplained](https://rocketnews.com/2026/11/anthropic-vs-code-extension-on-cursor-backdoored-for-nearly-12-hours-2/). Cursor’s notice does not confirm that, so read it as a reported detail.\n\nThe audience for this problem is large. In the 2025 Stack Overflow developer survey, per the [survey press release](https://stackoverflow.co/company/press/archive/stack-overflow-2025-developer-survey/), “Cursor was used by 18% of respondents in the survey.” The same release says “Three quarters of respondents, or 75.3%, said they do not trust AI answers.” Developers distrust the output yet hand the tool, and every extension beside it, wide access to their machines. The survey also found Visual Studio Code used by 75.9% of respondents, and Cursor inherits that extension model.\n\n### Why a publisher allowlist alone would not have caught it\n\nThe bad builds shipped under the real extension ID, Anthropic.claude-code. A policy that trusts the publisher `anthropic` would have waved them through. Only checks tied to the version, the file contents and the host state catch this class of attack, so the kit you build below layers a deny list, content scanning and a host check on top of the allowlist. Extensions are also not the only code surface in Cursor, so it is worth pairing this work with a pass to [audit your Cursor MCP servers](https://shattered.io/audit-cursor-mcp-servers-allowlist-hooks-12-steps-2026/).\n\n## Prerequisites and Versions\n\nThe kit uses only the Python standard library, so there is nothing to install beyond the tools below.\n\n- Python 3.10 or newer. The code in this walkthrough was run on Python 3.12.3.\n- The latest Cursor build, with the `cursor` command on your PATH. If your build lacks it, install the shell command from the Command Palette.\n- macOS, Windows or Linux. The October 29 incident affected macOS and Windows, but the file scans run everywhere.\n- PowerShell 5.1 or newer on Windows, for the host checks.\n- Git, so the policy and lock file live in version control.\n- Optional: a GitHub repository and Actions, for the pull request gate in Step 12.\n\nConfirm the basics before you start:\n\n```\npython3 --version\ncursor --version\ncursor --list-extensions --show-versions\n```\n\nThe last command prints one `publisher.name@version` line per extension. Save that output, since it is the baseline you compare the kit against in Step 3. Note that Cursor extensions live in `~/.cursor/extensions` on a default install. Each folder is named after the extension and its version, and each holds a `package.json`. If your install keeps them elsewhere, set the `CURSOR_EXT_DIR` environment variable and the kit follows it.\n\n## How Cursor Extensions Are Sourced and Vetted\n\nCursor’s help page on extensions explains where third-party extensions come from. Cursor uses the Open VSX registry rather than the Microsoft marketplace, and it routes search and downloads through its own marketplace proxy, which runs automated malware and supply-chain analysis before an extension is offered. Many popular VS Code extensions are available, and some Microsoft-only ones are replaced by audited builds from Anysphere.\n\nThe same page carries a warning that applies directly to this tutorial. The same `publisher.extension` name can point to different publishers or different code on Open VSX than on the Microsoft marketplace, so treat extension IDs like dependencies and install from publishers you trust. An ID is a claim, and the files behind it are the evidence.\n\n| Control layer | Who runs it | What it helps stop | Gap | \n|---|---|---|---|\n| Marketplace analysis | Cursor | Known-bad uploads before they appear in search | Cannot catch everything, as October 29 showed | \n| Verified publisher badge | Cursor, on request from publishers | Impersonation of a publisher name | Confirms identity, not code safety | \n| Install cooldown | Team admin or user setting | Short-lived malicious uploads | Does nothing for versions you already hold | \n| Allowed extensions | Team admin or MDM | Installs from unapproved publishers or IDs | A trusted ID can still ship a bad version | \n| Signature verification | Enterprise teams | Packages without a valid Open VSX signature | Client-side install control only | \n| Local audit and lock (this kit) | You | Known-bad versions, indicators, drift, host traces | Cannot see obfuscated payloads | \n\nCursor’s own page says no single control replaces the others, and the table backs that up. The kit fills the last row, the one only you can run on your own disk. It also keeps VS Code extensions separate from newer plugin surfaces. Reports describe Cursor plugins as a different system that can bundle rules, hooks and MCP servers, so audit those in their own pass, starting with how you [set up MCP servers in Cursor](https://shattered.io/set-up-cursor-mcp-servers-2026/).\n\n## Steps 1 to 3: Build the Project, Write the Policy, Inventory Installs\n\n**Step 1: Create the project.** Make a folder, initialize Git, and lay out the package. The empty `__init__.py` files make the folders importable.\n\n```\nmkdir cursor-ext-audit && cd cursor-ext-audit\ngit init\nmkdir ext_audit tests\ntouch ext_audit/__init__.py tests/__init__.py\n```\n\n**Step 2: Write the policy file.** Policy lives in one JSON file so a reviewer can read it in a pull request. The allowlist holds publishers you trust. The deny list holds exact bad versions, and the indicator list holds strings that should never appear in extension code. The two indicators below come from Cursor’s notice: the attacker IP found in the modified `extension.js`, and the key label.\n\n```\n{\n  \"allow_publishers\": [\"anysphere\", \"anthropic\", \"esbenp\", \"dbaeumer\", \"eamodio\", \"redhat\"],\n  \"allow_ids\": [],\n  \"deny\": [\n    {\n      \"id\": \"anthropic.claude-code\",\n      \"versions\": [\"2.1.312\", \"2.1.313\"],\n      \"reason\": \"Malicious builds named in Cursor's October 29, 2026 security notice\"\n    }\n  ],\n  \"ioc_strings\": [\"134.209.82.225\", \"svc-telemetry\"],\n  \"risky_activation\": [\"*\", \"onStartupFinished\"],\n  \"agent_surfaces\": [\"mcpServerDefinitionProviders\", \"languageModelTools\", \"chatParticipants\"]\n}\n```\n\nTwo choices deserve a second look. The `allow_publishers` list is an example, so replace it with the publishers your team actually uses. And `versions` in a deny rule can be left empty to ban an extension ID entirely, which is the right move for something you never want installed.\n\n**Step 3: Inventory every installed extension.** The inventory module walks the extensions folder, reads each `package.json`, and builds a normalized ID in the form publisher.name. It skips folders listed in the `.obsolete` file, since those are leftovers an update marked for removal. It also computes a tree hash, which is a [SHA-256](https://shattered.io/category/cryptography/) over every file path and file hash in an extension folder, so Step 7 can detect any byte-level change.\n\n``` python\nimport hashlib\nimport json\nimport os\nfrom pathlib import Path\n\ndef default_dir() -> Path:\n    env = os.environ.get(\"CURSOR_EXT_DIR\")\n    return Path(env) if env else Path.home() / \".cursor\" / \"extensions\"\n\ndef _obsolete(root: Path) -> set:\n    try:\n        return set(json.loads((root / \".obsolete\").read_text()))\n    except (OSError, ValueError):\n        return set()\n\ndef scan(root: Path) -> list:\n    skip = _obsolete(root)\n    found = []\n    for folder in sorted(root.iterdir()):\n        manifest = folder / \"package.json\"\n        if not folder.is_dir() or folder.name.startswith(\".\") or folder.name in skip:\n            continue\n        if not manifest.is_file():\n            continue\n        try:\n            data = json.loads(manifest.read_text(encoding=\"utf-8\"))\n        except (OSError, ValueError):\n            found.append({\"id\": folder.name, \"version\": \"?\", \"path\": folder,\n                          \"manifest\": {}, \"broken\": True})\n            continue\n        publisher = str(data.get(\"publisher\", \"unknown\")).lower()\n        name = str(data.get(\"name\", \"unknown\")).lower()\n        found.append({\"id\": f\"{publisher}.{name}\", \"version\": str(data.get(\"version\", \"?\")),\n                      \"path\": folder, \"manifest\": data, \"broken\": False})\n    return found\n\ndef _file_sha(path: Path) -> bytes:\n    digest = hashlib.sha256()\n    with path.open(\"rb\") as fh:\n        for chunk in iter(lambda: fh.read(1 << 20), b\"\"):\n            digest.update(chunk)\n    return digest.digest()\n\ndef tree_hash(folder: Path) -> str:\n    digest = hashlib.sha256()\n    for item in sorted(p for p in folder.rglob(\"*\") if p.is_file()):\n        rel = item.relative_to(folder).as_posix()\n        if rel.startswith(\".\"):\n            continue\n        digest.update(rel.encode())\n        digest.update(_file_sha(item))\n    return digest.hexdigest()\n```\n\nThe tree hash skips top-level dotfiles so Cursor’s own bookkeeping files do not cause false drift. Run the inventory against your real folder and compare the count with the baseline you saved in the prerequisites. A mismatch usually means a folder with a broken manifest, which the kit reports instead of hiding.\n\n## Steps 4 to 6: Score Risk, Scan for Indicators, Check the Host\n\n**Step 4: Write the rules.** Each extension runs through a handful of checks that produce findings with a severity. Some checks come straight from the October 29 behavior, such as an extension that touches `authorized_keys`. Others flag surfaces that deserve a human look, such as an extension that starts at launch or ships a native binary.\n\n``` python\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nSEVERITY = {\"low\": 1, \"medium\": 2, \"high\": 3, \"critical\": 4}\nCODE_SUFFIXES = {\".js\", \".cjs\", \".mjs\", \".json\", \".sh\", \".ps1\", \".bat\", \".py\", \".plist\"}\nNATIVE_SUFFIXES = {\".node\", \".exe\", \".dll\", \".dylib\"}\nPERSIST_MARKERS = (\"authorized_keys\", \"LaunchAgents\", \"schtasks\", \"Register-ScheduledTask\")\nMAX_BYTES = 5_000_000\n\n@dataclass\nclass Finding:\n    severity: str\n    ext_id: str\n    version: str\n    code: str\n    message: str\n\ndef _walk(folder: Path):\n    for item in folder.rglob(\"*\"):\n        if item.is_file():\n            yield item\n\ndef _read(item: Path) -> str:\n    try:\n        if item.stat().st_size > MAX_BYTES:\n            return \"\"\n        return item.read_text(encoding=\"utf-8\", errors=\"ignore\")\n    except OSError:\n        return \"\"\n\ndef evaluate(ext: dict, policy: dict) -> list:\n    ext_id, version = ext[\"id\"], ext[\"version\"]\n    manifest = ext[\"manifest\"]\n    out = []\n\n    def add(sev, code, msg):\n        out.append(Finding(sev, ext_id, version, code, msg))\n\n    if ext.get(\"broken\"):\n        add(\"medium\", \"BROKEN\", \"package.json is unreadable\")\n        return out\n\n    for rule in policy.get(\"deny\", []):\n        if rule[\"id\"] == ext_id and (not rule[\"versions\"] or version in rule[\"versions\"]):\n            add(\"critical\", \"DENY\", rule[\"reason\"])\n\n    publisher = ext_id.split(\".\", 1)[0]\n    if publisher not in policy[\"allow_publishers\"] and ext_id not in policy[\"allow_ids\"]:\n        add(\"medium\", \"PUBLISHER\", f\"publisher '{publisher}' is not on the allowlist\")\n\n    events = set(manifest.get(\"activationEvents\", []))\n    hits = events & set(policy[\"risky_activation\"])\n    if hits:\n        add(\"low\", \"ACTIVATION\", f\"starts early via {', '.join(sorted(hits))}\")\n\n    contributes = manifest.get(\"contributes\", {})\n    surfaces = [s for s in policy[\"agent_surfaces\"] if s in contributes]\n    if surfaces:\n        add(\"medium\", \"AGENT_SURFACE\", f\"contributes {', '.join(surfaces)}\")\n\n    iocs = policy[\"ioc_strings\"]\n    for item in _walk(ext[\"path\"]):\n        rel = item.relative_to(ext[\"path\"]).as_posix()\n        if item.suffix in NATIVE_SUFFIXES:\n            add(\"medium\", \"NATIVE\", f\"ships native binary {rel}\")\n        if item.suffix not in CODE_SUFFIXES:\n            continue\n        text = _read(item)\n        for ioc in iocs:\n            if ioc in text:\n                add(\"critical\", \"IOC\", f\"{rel} contains indicator '{ioc}'\")\n        for marker in PERSIST_MARKERS:\n            if marker in text:\n                add(\"high\", \"PERSIST\", f\"{rel} references '{marker}'\")\n    return out\n```\n\n| Finding code | Severity | Trigger | \n|---|---|---|\n| DENY | critical | ID and version match a deny rule | \n| IOC | critical | A file contains a string from the indicator list | \n| PERSIST | high | Code references authorized_keys, LaunchAgents, schtasks or Register-ScheduledTask | \n| PUBLISHER | medium | Publisher and ID are both missing from the allowlist | \n| AGENT_SURFACE | medium | Manifest contributes MCP server providers, language model tools or chat participants | \n| NATIVE | medium | Ships a .node, .exe, .dll or .dylib file | \n| BROKEN | medium | package.json cannot be parsed | \n| ACTIVATION | low | Activates on startup through the * or onStartupFinished events | \n\nThe agent-surface check exists because VS Code’s own documentation describes extensions that contribute agent tools and MCP servers. An extension that adds tools your agent can call has more reach than a color theme, and it should show up in review. Expect legitimate hits in the PERSIST and NATIVE rows too, for example a remote-development extension that really does handle SSH. The kit surfaces the file path so you can judge it.\n\n**Step 5: Run the rules against a planted fixture.** Never test a security scanner on real malware. Build a fake extensions folder that contains one clean extension, one that starts early, one that contributes an agent tool, and one that imitates the October 29 build with the two indicator strings.\n\n``` python\npython3 - <<'EOF'\nimport json\nfrom pathlib import Path\nroot = Path(\"/tmp/fixture-ext\")\nroot.mkdir(exist_ok=True)\n\ndef mk(pub, name, ver, js, extra=None):\n    d = root / f\"{pub}.{name}-{ver}\"\n    d.mkdir(exist_ok=True)\n    m = {\"publisher\": pub, \"name\": name, \"version\": ver, \"main\": \"./extension.js\"}\n    m.update(extra or {})\n    (d / \"package.json\").write_text(json.dumps(m))\n    (d / \"extension.js\").write_text(js)\n\nmk(\"esbenp\", \"prettier-vscode\", \"1.0.0\", \"module.exports = {}\")\nmk(\"redhat\", \"vscode-yaml\", \"1.0.0\", \"module.exports = {}\", {\"activationEvents\": [\"onStartupFinished\"]})\nmk(\"acme\", \"agent-tools\", \"0.4.1\", \"module.exports = {}\", {\"contributes\": {\"languageModelTools\": []}})\nmk(\"anthropic\", \"claude-code\", \"2.1.312\", \"const k = 'svc-telemetry'; const h = '134.209.82.225';\")\nEOF\nCURSOR_EXT_DIR=/tmp/fixture-ext python3 -m ext_audit.cli audit\n```\n\nRun it after Step 7 adds the CLI module. The output looks like this:\n\n```\nscanned 4 extensions in /tmp/fixture-ext\nCRITICAL DENY           [email protected]  Malicious builds named in Cursor's October 29, 2026 security notice\nCRITICAL IOC            [email protected]  extension.js contains indicator '134.209.82.225'\nCRITICAL IOC            [email protected]  extension.js contains indicator 'svc-telemetry'\nMEDIUM   PUBLISHER      [email protected]  publisher 'acme' is not on the allowlist\nMEDIUM   AGENT_SURFACE  [email protected]  contributes languageModelTools\nLOW      ACTIVATION     [email protected]  starts early via onStartupFinished\n```\n\nThe command exits with code 1, because the default `--fail-on` level is high and three findings are critical. Notice that the fake Anthropic build passes the publisher check, since `anthropic` sits on the allowlist, yet it still draws three critical findings. That is the layered design doing its job.\n\n**Step 6: Check the host.** File scans tell you what is installed. They cannot tell you whether the payload already ran. The host check looks for the traces Cursor described: the labelled key in the SSH authorized keys files, persistence entries that contain an indicator, and running processes that mention one. On macOS it reads the user’s LaunchAgents folder. On Windows it reads the Startup folder and the administrators key file under ProgramData.\n\n``` python\nimport os\nimport platform\nimport subprocess\nfrom pathlib import Path\n\nfrom .rules import Finding\n\ndef _read(path: Path) -> str:\n    try:\n        return path.read_text(encoding=\"utf-8\", errors=\"ignore\")\n    except OSError:\n        return \"\"\n\ndef _hit(text: str, iocs: list):\n    return next((i for i in iocs if i in text), None)\n\ndef _line_with(text: str, ioc: str) -> str:\n    line = next(ln for ln in text.splitlines() if ioc in ln)\n    return line.strip()[:100]\n\ndef key_files() -> list:\n    files = [Path.home() / \".ssh\" / \"authorized_keys\"]\n    if platform.system() == \"Windows\":\n        base = Path(os.environ.get(\"ProgramData\", r\"C:\\ProgramData\"))\n        files.append(base / \"ssh\" / \"administrators_authorized_keys\")\n    return files\n\ndef persistence_dirs() -> list:\n    system = platform.system()\n    if system == \"Darwin\":\n        return [Path.home() / \"Library\" / \"LaunchAgents\"]\n    if system == \"Windows\":\n        appdata = Path(os.environ.get(\"APPDATA\", \"\"))\n        return [appdata / \"Microsoft\" / \"Windows\" / \"Start Menu\" / \"Programs\" / \"Startup\"]\n    return []\n\ndef process_listing() -> str:\n    if platform.system() == \"Windows\":\n        cmd = [\"powershell\", \"-NoProfile\", \"-Command\",\n               \"Get-CimInstance Win32_Process | Select-Object -ExpandProperty CommandLine\"]\n    else:\n        cmd = [\"ps\", \"-axo\", \"command=\"]\n    try:\n        return subprocess.run(cmd, capture_output=True, text=True, timeout=30).stdout\n    except (OSError, subprocess.SubprocessError):\n        return \"\"\n\ndef run(iocs: list) -> list:\n    out = []\n    for f in key_files():\n        hit = _hit(_read(f), iocs)\n        if hit:\n            out.append(Finding(\"critical\", \"host\", \"-\", \"HOST_KEY\", f\"{f} contains '{hit}'\"))\n    for folder in persistence_dirs():\n        if not folder.is_dir():\n            continue\n        for item in folder.iterdir():\n            hit = _hit(_read(item), iocs) if item.is_file() else None\n            if hit:\n                out.append(Finding(\"critical\", \"host\", \"-\", \"HOST_PERSIST\", f\"{item} contains '{hit}'\"))\n    procs = process_listing()\n    hit = _hit(procs, iocs)\n    if hit:\n        out.append(Finding(\"critical\", \"host\", \"-\", \"HOST_PROC\",\n                           f\"process mentions '{hit}': {_line_with(procs, hit)}\"))\n    return out\n```\n\nCursor’s notice also gives a one-line check you can run without any of this code. On macOS:\n\n```\ngrep -qs svc-telemetry ~/.ssh/authorized_keys && echo \"svc-telemetry key found\" || echo \"No key found; machine not impacted\"\n```\n\nOn Windows, in PowerShell:\n\n``` php\n$files = \"$HOME\\.ssh\\authorized_keys\", \"C:\\ProgramData\\ssh\\administrators_authorized_keys\" | Where-Object { Test-Path $_ }\nif ($files -and (Select-String svc-telemetry $files)) { \"svc-telemetry key found\" } else { \"No key found; machine not impacted\" }\n```\n\nIf either command finds the key, treat the machine as impacted. Cursor’s notice provides remediation scripts for both platforms, `remediate-macos.sh` and `remediate-windows.ps1`, and says to rotate credentials and sessions reachable from the host from a trusted system. Use those scripts instead of improvising. Hosts that run agents and editors side by side benefit from the same isolation thinking that applies to [isolating AI agents](https://shattered.io/suse-ai-factory-nvidia-openshell-agent-security-2026/) from the rest of a workstation.\n\n### What the host check cannot prove\n\nA clean result means the traces from this one incident are absent. It does not prove the machine is clean. Cursor said the attacker’s server no longer works, so host details are not reaching anyone and the reverse shell is dead. It also said the SSH step fails on hosts where Cursor did not run with administrator privileges. Neither point makes a past exposure harmless, because the key and the persistence entries were still written. If the check finds anything, isolate the host and rotate credentials. If it finds nothing but you installed one of the bad versions inside the window, run Cursor’s remediation script anyway.\n\n## Steps 7 and 8: Lock Versions and Install From the Pinned List\n\n**Step 7: Add the command line and write the lock file.** The CLI module ties the pieces together. The `audit` command runs the rules and the optional host check. The `lock` command records every installed extension with its version and tree hash. The `verify` command compares the live folder against that record, and `install` replays it. The `recs` command, used in Step 12, checks a repo’s recommendation file against policy.\n\n``` python\nimport argparse\nimport json\nimport re\nimport subprocess\nimport sys\nfrom dataclasses import asdict\nfrom pathlib import Path\n\nfrom . import hostcheck, inventory, rules\n\ndef load_policy(path: Path) -> dict:\n    return json.loads(path.read_text(encoding=\"utf-8\"))\n\ndef load_jsonc(path: Path) -> dict:\n    text = re.sub(r\"^\\s*//.*$\", \"\", path.read_text(encoding=\"utf-8\"), flags=re.M)\n    return json.loads(re.sub(r\",(\\s*[}\\]])\", r\"\\1\", text))\n\ndef cmd_audit(args, policy, root) -> int:\n    findings = []\n    exts = inventory.scan(root)\n    for ext in exts:\n        findings += rules.evaluate(ext, policy)\n    if args.host:\n        findings += hostcheck.run(policy[\"ioc_strings\"])\n    findings.sort(key=lambda f: -rules.SEVERITY[f.severity])\n    if args.json:\n        print(json.dumps([asdict(f) for f in findings], indent=2))\n    else:\n        print(f\"scanned {len(exts)} extensions in {root}\")\n        for f in findings:\n            print(f\"{f.severity.upper():8} {f.code:14} {f.ext_id}@{f.version}  {f.message}\")\n        if not findings:\n            print(\"no findings\")\n    worst = max((rules.SEVERITY[f.severity] for f in findings), default=0)\n    return 1 if worst >= rules.SEVERITY[args.fail_on] else 0\n\ndef cmd_lock(args, policy, root) -> int:\n    entries = [{\"id\": e[\"id\"], \"version\": e[\"version\"], \"sha256\": inventory.tree_hash(e[\"path\"])}\n               for e in inventory.scan(root) if not e[\"broken\"]]\n    entries.sort(key=lambda e: e[\"id\"])\n    args.out.write_text(json.dumps({\"extensions\": entries}, indent=2) + \"\\n\")\n    print(f\"wrote {len(entries)} entries to {args.out}\")\n    return 0\n\ndef cmd_verify(args, policy, root) -> int:\n    locked = {e[\"id\"]: e for e in json.loads(args.lock.read_text())[\"extensions\"]}\n    live = {e[\"id\"]: e for e in inventory.scan(root) if not e[\"broken\"]}\n    drift = 0\n    for ext_id, ext in sorted(live.items()):\n        want = locked.get(ext_id)\n        if want is None:\n            print(f\"NEW      {ext_id}@{ext['version']} is not in the lock file\")\n            drift += 1\n        elif want[\"version\"] != ext[\"version\"]:\n            print(f\"VERSION  {ext_id} locked {want['version']}, installed {ext['version']}\")\n            drift += 1\n        elif want[\"sha256\"] != inventory.tree_hash(ext[\"path\"]):\n            print(f\"HASH     {ext_id}@{ext['version']} files differ from the lock\")\n            drift += 1\n    for ext_id in sorted(set(locked) - set(live)):\n        print(f\"MISSING  {ext_id} is locked but not installed\")\n        drift += 1\n    print(\"lock matches\" if not drift else f\"{drift} drift item(s)\")\n    return 1 if drift else 0\n\ndef cmd_install(args, policy, root) -> int:\n    for entry in json.loads(args.lock.read_text())[\"extensions\"]:\n        blocked = any(r[\"id\"] == entry[\"id\"] and (not r[\"versions\"] or entry[\"version\"] in r[\"versions\"])\n                      for r in policy[\"deny\"])\n        if blocked:\n            print(f\"REFUSED  {entry['id']}@{entry['version']} is on the deny list\")\n            return 2\n    for entry in json.loads(args.lock.read_text())[\"extensions\"]:\n        cmd = [args.cli, \"--install-extension\", f\"{entry['id']}@{entry['version']}\"]\n        print(\" \".join(cmd))\n        if not args.dry_run:\n            subprocess.run(cmd, check=True)\n    return 0\n\ndef cmd_recs(args, policy, root) -> int:\n    recs = load_jsonc(args.file).get(\"recommendations\", [])\n    bad = [r for r in recs if r.split(\".\", 1)[0].lower() not in policy[\"allow_publishers\"]\n           and r.lower() not in policy[\"allow_ids\"]]\n    for r in bad:\n        print(f\"UNAPPROVED  {r}\")\n    print(f\"{len(recs)} recommendations, {len(bad)} unapproved\")\n    return 1 if bad else 0\n\ndef main(argv=None) -> int:\n    p = argparse.ArgumentParser(prog=\"ext-audit\")\n    p.add_argument(\"--dir\", type=Path)\n    p.add_argument(\"--policy\", type=Path, default=Path(\"policy.json\"))\n    sub = p.add_subparsers(dest=\"cmd\", required=True)\n\n    a = sub.add_parser(\"audit\")\n    a.add_argument(\"--host\", action=\"store_true\")\n    a.add_argument(\"--json\", action=\"store_true\")\n    a.add_argument(\"--fail-on\", default=\"high\", choices=list(rules.SEVERITY))\n    a.set_defaults(fn=cmd_audit)\n\n    lk = sub.add_parser(\"lock\")\n    lk.add_argument(\"--out\", type=Path, default=Path(\"extensions.lock.json\"))\n    lk.set_defaults(fn=cmd_lock)\n\n    v = sub.add_parser(\"verify\")\n    v.add_argument(\"--lock\", type=Path, default=Path(\"extensions.lock.json\"))\n    v.set_defaults(fn=cmd_verify)\n\n    i = sub.add_parser(\"install\")\n    i.add_argument(\"--lock\", type=Path, default=Path(\"extensions.lock.json\"))\n    i.add_argument(\"--cli\", default=\"cursor\")\n    i.add_argument(\"--dry-run\", action=\"store_true\")\n    i.set_defaults(fn=cmd_install)\n\n    r = sub.add_parser(\"recs\")\n    r.add_argument(\"file\", type=Path, nargs=\"?\", default=Path(\".vscode/extensions.json\"))\n    r.set_defaults(fn=cmd_recs)\n\n    args = p.parse_args(argv)\n    policy = load_policy(args.policy)\n    root = args.dir or inventory.default_dir()\n    return args.fn(args, policy, root)\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\nRun `audit` on a machine you believe is clean, fix or approve every finding, and only then lock it. A lock file written from a dirty machine pins the dirt.\n\n```\npython3 -m ext_audit.cli audit --host\npython3 -m ext_audit.cli lock\npython3 -m ext_audit.cli verify\n```\n\nOn a fixture with three clean extensions, `lock` and `verify` print:\n\n```\nwrote 3 entries to extensions.lock.json\nlock matches\n```\n\nAfter one extension file changes and an unlisted extension appears, `verify` reports drift and exits with code 1:\n\n```\nNEW      [email protected] is not in the lock file\nHASH     [email protected] files differ from the lock\n2 drift item(s)\n```\n\nCommit `extensions.lock.json` next to the policy. A lock change then shows up in code review as a diff, with the version and hash on the line, which is far easier to question than a silent auto-update.\n\n**Step 8: Install from the pinned list.** The `install` command refuses to proceed if any locked entry matches a deny rule, then calls the Cursor CLI once per entry with an exact version. Start with a dry run to see what it would do.\n\n```\npython3 -m ext_audit.cli install --dry-run\ncursor --install-extension [email protected]\ncursor --install-extension [email protected]\ncursor --install-extension [email protected]\n```\n\nDrop `--dry-run` on a fresh machine to rebuild the same extension set. One caveat: Open VSX may not carry every historical version, and platform-specific builds can hash differently between macOS and Windows. Keep one lock file per operating system if your team mixes them, and expect an occasional version that needs to be bumped rather than pinned forever.\n\n## Steps 9 and 10: Harden Editor Settings and Team Controls\n\n**Step 9: Turn off silent updates and add a cooldown.** A pinned extension stays pinned only if the editor stops updating it behind your back. Open the user settings JSON from the Command Palette and add the following. Auto-update is off, update checks stay on so you still learn about new versions, and an install cooldown holds new marketplace releases for a day.\n\n```\n{\n  \"extensions.autoUpdate\": false,\n  \"extensions.autoCheckUpdates\": true,\n  \"extensions.installCooldownHours\": 24\n}\n```\n\nCursor’s help page documents `extensions.installCooldownHours` as a per-user setting. It defers installs and updates until a marketplace version has been published for the number of hours you set.\n\n### How long should the cooldown be?\n\nThe 24 above is an example, so pick the number your risk posture accepts. Remember that a cooldown does nothing for a version you already installed. On October 29 the bad builds were live for roughly eleven hours, so a cooldown longer than that would have kept a fresh install or update away from them, assuming the marketplace pulled the versions in time. Longer waits cost you fixes, because a legitimate security patch also sits in the queue. Most teams land between one day and three days and shorten it for urgent patches by hand.\n\n**Step 10: Apply team-wide controls.** Settings files protect one machine. Teams need controls developers cannot opt out of. According to Cursor’s help page, team owners and admins can set a Marketplace Install Cooldown under Team settings, then Security and automation, and the default is 0, meaning disabled. When the value is above 0 it applies fleet-wide and overrides the per-user setting, so developers cannot opt back into instant updates.\n\nTwo more controls sit beside it. Admins can restrict installs to approved publishers or extension IDs from the team dashboard or through MDM, using a setting called `AllowedExtensions`. Enterprise teams can also require valid Open VSX signatures before install. Cursor says the cooldown and signature checks are client-side install controls that complement marketplace analysis and do not replace it. If you manage a team, review the rest of your admin surface at the same time, starting with [locking down the Cursor dashboard](https://shattered.io/secure-cursor-ai-dashboard-2026/).\n\nYour allowlist in `policy.json` and the AllowedExtensions setting should say the same thing. Keep one source of truth, generate the other from it, and review both in the same pull request. Extensions also see whatever files the editor can see, so pair this step with a [.cursorignore file that keeps secrets away from agents](https://shattered.io/cursor-rules-linter-agents-md-cursorignore-12-steps-2026/).\n\n## Step 11: Test the Whole Kit\n\nA security tool that nobody tests drifts into false comfort. The test file below builds throwaway extension folders in a temporary directory and checks each rule, the lock hash, the obsolete-folder skip, the host key check with a planted file, and the CLI exit code.\n\n``` python\nimport json\nimport os\nimport tempfile\nimport unittest\nfrom pathlib import Path\n\nfrom ext_audit import cli, hostcheck, inventory, rules\n\nPOLICY = json.loads(Path(\"policy.json\").read_text())\n\ndef make_ext(root: Path, publisher, name, version, js=\"\", extra=None):\n    folder = root / f\"{publisher}.{name}-{version}\"\n    folder.mkdir(parents=True)\n    manifest = {\"publisher\": publisher, \"name\": name, \"version\": version, \"main\": \"./extension.js\"}\n    manifest.update(extra or {})\n    (folder / \"package.json\").write_text(json.dumps(manifest))\n    (folder / \"extension.js\").write_text(js)\n    return folder\n\nclass AuditTests(unittest.TestCase):\n    def setUp(self):\n        self.tmp = tempfile.TemporaryDirectory()\n        self.root = Path(self.tmp.name)\n\n    def tearDown(self):\n        self.tmp.cleanup()\n\n    def codes(self):\n        out = []\n        for ext in inventory.scan(self.root):\n            out += [f.code for f in rules.evaluate(ext, POLICY)]\n        return out\n\n    def test_clean_extension_has_no_findings(self):\n        make_ext(self.root, \"esbenp\", \"prettier-vscode\", \"1.0.0\", \"module.exports = {}\")\n        self.assertEqual(self.codes(), [])\n\n    def test_denied_version_is_critical(self):\n        make_ext(self.root, \"anthropic\", \"claude-code\", \"2.1.312\", \"x\")\n        self.assertIn(\"DENY\", self.codes())\n\n    def test_other_version_of_same_id_is_not_denied(self):\n        make_ext(self.root, \"anthropic\", \"claude-code\", \"9.9.9\", \"x\")\n        self.assertNotIn(\"DENY\", self.codes())\n\n    def test_ioc_string_is_critical(self):\n        make_ext(self.root, \"acme\", \"helper\", \"1.0.0\", \"const k = 'svc-telemetry';\")\n        self.assertIn(\"IOC\", self.codes())\n\n    def test_persistence_marker_is_high(self):\n        make_ext(self.root, \"esbenp\", \"prettier-vscode\", \"1.0.0\", \"fs.appendFile('authorized_keys', k)\")\n        self.assertIn(\"PERSIST\", self.codes())\n\n    def test_unknown_publisher_flagged(self):\n        make_ext(self.root, \"acme\", \"helper\", \"1.0.0\", \"ok\")\n        self.assertIn(\"PUBLISHER\", self.codes())\n\n    def test_agent_surface_and_activation(self):\n        make_ext(self.root, \"esbenp\", \"tools\", \"1.0.0\", \"ok\",\n                 {\"activationEvents\": [\"*\"], \"contributes\": {\"languageModelTools\": []}})\n        found = self.codes()\n        self.assertIn(\"ACTIVATION\", found)\n        self.assertIn(\"AGENT_SURFACE\", found)\n\n    def test_obsolete_folders_are_skipped(self):\n        folder = make_ext(self.root, \"acme\", \"old\", \"1.0.0\", \"svc-telemetry\")\n        (self.root / \".obsolete\").write_text(json.dumps({folder.name: True}))\n        self.assertEqual(inventory.scan(self.root), [])\n\n    def test_tree_hash_changes_when_a_file_changes(self):\n        folder = make_ext(self.root, \"esbenp\", \"prettier-vscode\", \"1.0.0\", \"a\")\n        before = inventory.tree_hash(folder)\n        (folder / \"extension.js\").write_text(\"b\")\n        self.assertNotEqual(before, inventory.tree_hash(folder))\n\n    def test_host_key_check_finds_planted_key(self):\n        home = self.root / \"home\"\n        (home / \".ssh\").mkdir(parents=True)\n        (home / \".ssh\" / \"authorized_keys\").write_text(\"ssh-ed25519 AAAA svc-telemetry\\n\")\n        old = os.environ.get(\"HOME\")\n        os.environ[\"HOME\"] = str(home)\n        try:\n            found = [f.code for f in hostcheck.run(POLICY[\"ioc_strings\"])]\n        finally:\n            os.environ[\"HOME\"] = old\n        self.assertIn(\"HOST_KEY\", found)\n\n    def test_cli_exit_code_follows_fail_on(self):\n        make_ext(self.root, \"acme\", \"helper\", \"1.0.0\", \"svc-telemetry\")\n        code = cli.main([\"--dir\", str(self.root), \"--policy\", \"policy.json\", \"audit\"])\n        self.assertEqual(code, 1)\n\nif __name__ == \"__main__\":\n    unittest.main()\n```\n\nRun the suite from the project root. The explicit `-t .` matters, because it makes the `tests` folder importable and keeps the `ext_audit` imports working.\n\n```\npython3 -m unittest discover -s tests -t . -v\nRan 11 tests in 0.053s\n\nOK\n```\n\nThe host key test sets the `HOME` variable to a temporary folder, plants an authorized keys file containing the label, and expects a critical finding. The same run proves that a clean file does not trigger one. I ran this suite on Linux. The Windows branches call standard PowerShell and read standard paths, but test them on one Windows machine before you roll the kit out.\n\n## Step 12: Schedule the Audit and Gate Pull Requests\n\n**Step 12: Run it on a schedule and in CI.** A one-time audit finds yesterday’s problem. A recurring one catches the next. On macOS and Linux, a cron entry runs the audit each weekday and appends to a log:\n\n```\n0 9 * * 1-5 cd ~/cursor-ext-audit && python3 -m ext_audit.cli audit --host --fail-on critical >> ~/ext-audit.log 2>&1\n```\n\nOn Windows, Task Scheduler does the same job:\n\n```\nschtasks /Create /SC DAILY /TN \"CursorExtAudit\" /ST 09:00 /TR \"cmd /c cd /d C:\\tools\\cursor-ext-audit && python -m ext_audit.cli audit --host --fail-on critical >> %USERPROFILE%\\ext-audit.log 2>&1\"\n```\n\nRepositories also recommend extensions. A workspace recommendation file is how a team nudges everyone toward the same tools, and it is a quiet way for an unvetted extension to spread. Cursor builds on VS Code, so the `.vscode/extensions.json` convention works the same way in practice, though you should confirm that on your own build. The `recs` command reads that file, tolerates comments and trailing commas, and fails when a recommendation is not on the allowlist:\n\n```\nUNAPPROVED  acme.agent-tools\n2 recommendations, 1 unapproved\n```\n\nWire it into GitHub Actions so a pull request that adds a recommendation has to pass policy first:\n\n```\nname: ext-audit\non:\n  pull_request:\n    paths:\n      - \".vscode/extensions.json\"\n      - \"policy.json\"\n      - \"ext_audit/**\"\n      - \"tests/**\"\njobs:\n  audit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n      - name: Unit tests\n        run: python -m unittest discover -s tests -t . -v\n      - name: Check workspace recommendations\n        run: python -m ext_audit.cli recs .vscode/extensions.json\n```\n\nThe action versions shown are examples, so pin them to the versions or commit hashes your organization has approved. If you already run the editor’s agent headless, the same job can sit beside a workflow for [Cursor CLI in GitHub Actions](https://shattered.io/cursor-cli-github-actions-headless-agent-2026/).\n\n## The Complete Working Project\n\nThe finished kit is a small repository with the files below.\n\n```\ncursor-ext-audit/\n  policy.json\n  extensions.lock.json        (generated by the lock command)\n  ext_audit/\n    __init__.py\n    inventory.py\n    rules.py\n    hostcheck.py\n    cli.py\n  tests/\n    __init__.py\n    test_audit.py\n  .github/workflows/ext-audit.yml\n```\n\nA short run order covers daily use. Start with the unit tests whenever you edit a rule. Run `audit --host` before you lock anything, then `lock`, commit the result, and rely on `verify` from that day forward. Use `install` to rebuild a fresh machine and `recs` in CI.\n\n- `audit` : weekly, and after any editor update. Exit code 1 means a finding at or above the fail-on level.\n- `audit --host` : after any security notice. Exit code 1 means a critical trace on the machine.\n- `lock` : after you approve a clean state.\n- `verify` : daily, or before a release. Exit code 1 means drift from the lock file.\n- `install` : on a new or rebuilt machine. Exit code 2 means a locked entry matched the deny list.\n- `recs` : in CI on every pull request. Exit code 1 means an unapproved recommendation.\n\nYou can also call the audit from [Cursor hooks](https://shattered.io/cursor-hooks-tutorial-guard-agent-12-steps-2026/) so every agent session starts with a quick check. Keep the host check out of the hook, since it shells out to list processes and slows every launch.\n\n## Common Pitfalls\n\nTeams that adopt this kind of tooling tend to trip over the same six things.\n\n- **Trusting the publisher name.** The October 29 builds used a real extension ID. Keep the deny list and content checks on even when the publisher is allowlisted.\n- **Locking a dirty machine.** Pinning versions does not clean them. Run the audit, resolve findings, then lock.\n- **Assuming a clean scan means a safe extension.** The scanner reads plain strings. Obfuscated or downloaded payloads slip past it, so treat results as a signal and keep marketplace controls on.\n- **Comparing the wrong version number.** According to the[Claude Code VS Code documentation](https://code.claude.com/docs/en/vs-code) , the extension’s version number is the Claude Code version it bundles. Deny rules for that extension compare against that number, not a separate extension release number.\n- **Sharing one lock file across operating systems.** Platform-specific builds hash differently. Lock per OS.\n- **Uninstalling and moving on.** Cursor’s notice warns that uninstalling may not remove standalone persistence scripts. If the host check fires, run the vendor remediation steps and rotate credentials.\n\nTreat indicator lists as perishable. The two strings in the policy file belong to one incident. Add new ones from vendor notices as they appear, and date them in the reason field so a reviewer knows why each entry exists.\n\n## Troubleshooting\n\nTen problems show up often enough to list.\n\n- **FileNotFoundError for the extensions folder.** Cursor is installed somewhere unusual, or you have never launched it. Set`CURSOR_EXT_DIR` to the folder that holds the extension subfolders.\n- **`scanned 0 extensions` on a machine full of them.** The folder layout differs from what the inventory expects. Check that each subfolder contains a`package.json` at its top level.\n- **`cursor: command not found`.** The shell command is not installed. Install it from the Command Palette, open a new terminal, and retry.\n- **PowerShell blocks the remediation script.** The notice runs it with`-ExecutionPolicy Bypass` from an elevated prompt. Use the vendor command exactly, and only for the vendor script.\n- **HOST_PROC fires on a clean machine.** A process listing includes the full command lines of every running process, so a shell or CI step that contains an indicator string matches. The message prints the matching line. If it is your own terminal or script, it is a false positive.\n- **HASH drift right after an update.** The extension changed on purpose. Review the new version, rerun`lock` , and commit the diff.\n- **install fails with a version not found.** Open VSX may not carry that exact build. Bump the entry to a version it does carry and relock.\n- **The cooldown setting seems ignored.** A team-level cooldown above 0 overrides per-user values. Ask an admin what the fleet value is.\n- **recs reports a JSON error.** The reader strips line comments and trailing commas but not block comments. Remove`/* */` blocks or convert the file to plain JSON.\n- **Permission denied reading authorized_keys.** The file belongs to another user or the machine hardens it. Run the check as the account that runs Cursor, not as root.\n\n## Advanced Tips\n\nOnce the basics run, a few upgrades pay off.\n\nVerify packages before you install them. When you install from a downloaded VSIX file, compute its SHA-256 with `shasum -a 256` on macOS or Linux, or `Get-FileHash` on Windows, and compare it against the hash the publisher posts. Cursor’s October 29 notice lists hashes for the malicious packages, which shows how defenders use them.\n\nReview the lock file like code. A pull request that changes `extensions.lock.json` is a supply-chain change. Require a second reviewer and ask for a link to the release notes of each bumped version.\n\nPair the cooldown with canaries. Keep one throwaway machine on the shortest cooldown and the others on a longer one. If something goes wrong in the canary, you learn before the rest of the team does.\n\nExport results into your SIEM. The `--json` flag on `audit` prints one object per finding, which feeds log pipelines without extra parsing. Alert on critical findings only and review the rest weekly.\n\nExtend the indicator list from vendor notices. Each time a vendor publishes indicators, add the file names, labels and addresses as new entries. For broader reading on editors and agent tooling, browse the [software category](https://shattered.io/category/software/).\n\n## Frequently Asked Questions\n\n**Which Cursor extensions were compromised on October 29, 2026?**\n\nCursor identified Anthropic.claude-code versions 2.1.312 and 2.1.313 for macOS and Windows, available between about 2:27am and 1:35pm Pacific. Cursor said they were not developed by Anthropic and that other extensions in the marketplace were not affected.\n\n**How do I check whether my machine was affected?**\n\nLook for an SSH key labelled svc-telemetry in your authorized keys file. The macOS and PowerShell one-liners in Step 6 do exactly that. If the key is present, follow Cursor’s remediation steps and rotate credentials from a trusted system.\n\n**Does uninstalling the extension fix it?**\n\nNot by itself. Cursor warned that the payload dropped standalone persistence scripts that keep running after removal, so run the vendor remediation script and check for the key and tunnel processes.\n\n**Where do Cursor extensions come from?**\n\nAccording to Cursor’s help page, from the Open VSX registry through a Cursor-run marketplace proxy that applies automated malware and supply-chain analysis. Some Microsoft marketplace extensions that are missing from Open VSX are replaced by audited Anysphere builds.\n\n**Is a cooldown enough to protect a team?**\n\nNo. A cooldown defers new versions, which helps against short-lived malicious uploads, but it does nothing for versions already installed. Combine it with an allowlist, signature verification where available, and the audit and lock workflow above.\n\n**Does the kit work on Windows and Linux?**\n\nThe file scans, lock, verify and install commands are plain Python and run anywhere. The host check has macOS, Windows and Linux branches, but I only ran the tests on Linux, so verify the Windows branch on one machine first.\n\n**Can I add other malicious extensions to the deny list?**\n\nYes. Add an entry with the extension ID, the exact versions, and a reason that cites the vendor notice. Leave the versions list empty to ban an ID outright, and add any indicator strings the notice publishes to `ioc_strings`.", "url": "https://wpnews.pro/news/cursor-extensions-audit-12-steps-60-min-2026", "canonical_source": "https://shattered.io/audit-cursor-extensions-12-steps-2026/", "published_at": "2026-11-12 02:11:40+00:00", "updated_at": "2026-11-12 02:22:25.077354+00:00", "lang": "en", "topics": ["ai-tools", "ai-safety", "developer-tools", "ai-products"], "entities": ["Cursor", "Anthropic", "Claude Code", "Stack Overflow", "Shattered"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/cursor-extensions-audit-12-steps-60-min-2026", "markdown": "https://wpnews.pro/news/cursor-extensions-audit-12-steps-60-min-2026.md", "text": "https://wpnews.pro/news/cursor-extensions-audit-12-steps-60-min-2026.txt", "jsonld": "https://wpnews.pro/news/cursor-extensions-audit-12-steps-60-min-2026.jsonld"}}