critical Threat analysis

TanStack CI/CD Release Pipeline Poisoning

On May 11, 2026, the popular open-source project TanStack fell victim to a CI/CD release pipeline poisoning attack. Threat actors hijacked the release pipeline via a pull request exploitation vector and OIDC token theft to publish 84 backdoored versions across 42 packages.

#npm#supply-chain#compromise#github-actions#oidc#teampcp
On this page 0% read

    Executive Summary

    On May 11, 2026, the highly popular open-source project TanStack (used widely for state management, routing, and data fetching) fell victim to a highly sophisticated CI/CD pipeline poisoning attack TanStack Advisory. The threat actor group TeamPCP (also tracked as UNC6780) bypassed direct registry controls by exploiting a pull_request_target “Pwn Request” vulnerability and GitHub Actions cache poisoning Snyk Research. This allowed them to hijack the automated release pipeline and publish 84 backdoored versions across 42 legitimate @tanstack/* npm packages TanStack Advisory. The malicious versions executed a credential-stealing loader (router_init.js) during package installation, which subsequently led to the compromise of downstream development assets—most notably, the theft of an Nx contributor’s GitHub CLI credentials StepSecurity. Start with the persistence-file, install-time payload, and contributor-token audit recipes below before rotating identities from confirmed runs TanStack Advisory.

    Key Facts

    threat_type: CI/CD compromise, GitHub Actions compromise, poisoned release, artifact tampering, credential theft, token exfiltration
    ecosystem: npm
    registry: npmjs.com
    affected_packages:
      - "@tanstack/zod-adapter"
      - "@tanstack/router"
      - "@tanstack/react-router"
      - "@tanstack/react-query"
      - "@tanstack/table-core"
    malicious_versions:
      - "1.166.12"
      - "1.166.15"
    fixed_versions:
      - "1.166.16"
      - "1.166.17"
    safe_versions:
      - "v1.166.16 and later"
    exposure_window: 2026-05-11T19:20:00Z to 2026-05-11T20:15:00Z (55 minutes)
    execution_trigger: install-time execution (npm lifecycle scripts)
    primary_impact: Credential theft (AWS/GCP tokens, npm configs, SSH keys, GitHub PATs) and lateral worm propagation
    known_iocs:
      - "router_init.js"
      - "ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c"
      - "git-tanstack[.]com"
    confidence: high
    canonical_source: https://tanstack.com

    Source Confidence & Evidence Mapping

    • confirmed: Hijack of GitHub Actions automated release pipeline via a pull request exploitation vector, resulting in 84 backdoored releases across 42 @tanstack/* npm packages. TanStack Advisory
    • likely: Chaining of pull_request_target workflow vulnerabilities and cache poisoning to forge signed OIDC identity tokens, enabling authorized publishing to the npm registry. Snyk Research
    • unclear: The complete list of developer environments or downstream organizations that downloaded and executed the malicious version during the 55-minute exposure window.
    • not_observed: No direct maintainer account passwords or active MFA sessions were bypassed; the threat actor interacted solely with the automated pipeline’s credentials.

    Impact Determination

    ClassificationCriteriaRequired evidenceRequired actionClosure condition
    Confirmed compromisea compromised @tanstack/* release is present and npm install executes the injected TanStack loader or the reported process, file, or network indicators is observed.Artifact inventory plus runtime telemetry showing npm install executes the injected TanStack loader or listed C2/process/file indicators.Isolate affected hosts or runners, preserve artifacts, and rotate reachable credentials from a clean environment.Affected artifacts are removed, exposed credentials are replaced, and downstream audit modules show no suspicious follow-on use.
    Presumed exposeda compromised @tanstack/* release was installed, pulled, imported, built, or executed during the exposure window, but telemetry cannot prove exfiltration.Lockfile, package cache, workflow, image pull, extension inventory, build log, or deployment record tied to the exposure window.Rebuild from clean artifacts and rotate credentials available to the affected environment.Credential owners confirm revocation of old material and clean artifacts are deployed.
    Potentially exposedThe package, workflow, image, extension, or module appears in dependency or deployment records, but workflow, action, release, or runner execution is not established.Manifest, lockfile, build, deployment, or endpoint records plus a named telemetry gap.Collect the missing execution and telemetry evidence before narrowing scope.Every hit is dispositioned as confirmed compromise, presumed exposed, or not exposed.
    Not exposedNo affected version, artifact, mutable reference, or indicator appears in source, lockfiles, build outputs, deployments, package caches, or runtime telemetry.Repository search, dependency inventory, build/deployment export, package cache query, and runtime telemetry query results.Preserve the negative search output and keep the prevention controls active.Search evidence covers developer endpoints, CI runners, production deployments, and package or image caches.
    UnknownRequired inventory, build, endpoint, network, or audit telemetry is unavailable.A gap statement naming unavailable systems, owners, and time windows.Keep the asset in scope and make conservative rotation or rebuild decisions for high-value environments.The missing evidence is recovered or the risk owner accepts residual uncertainty.

    Minimum Evidence To Collect

    minimum_evidence:
      - "Dependency, workflow, extension, image, or module inventory covering developer endpoints, CI runners, and production deployments."
      - "Positive or negative search results for 84 compromised @tanstack versions, @tanstack/[email protected], @tanstack/[email protected]."
      - "Execution evidence for npm install executes the injected TanStack loader."
      - "Process, file, DNS, proxy, firewall, or package-manager telemetry for listed indicators."
      - "Inventory of credentials, tokens, deployment paths, and downstream systems reachable from exposed environments."

    Timeline

    • 2026-05-11T19:20:00Z Attackers submit a malicious pull request, triggering the poisoned GitHub Actions workflow. Source: Snyk Research
    • 2026-05-11T19:23:00Z Attackers successfully poison the GitHub Actions runner cache and extract valid OIDC identity tokens. Source: StepSecurity
    • 2026-05-11T19:26:00Z First batch of 84 malicious @tanstack/* packages (including @tanstack/[email protected]) are published to npmjs.com. Source: TanStack Advisory
    • 2026-05-11T19:45:00Z Maintainers detect anomaly in automated release logs and initiate active incident response. Source: TanStack Advisory
    • 2026-05-11T20:15:00Z npm registry removes the 84 backdoored versions and revokes the associated publishing tokens. Source: TanStack Advisory
    • 2026-05-11T20:30:00Z TanStack releases clean, official patches (e.g., @tanstack/[email protected]) and issues an all-clear. Source: TanStack Advisory
    • 2026-05-11T21:00:00Z Official security advisory is published under tracking ID GHSA-g7cv-rxg3-hmpx. Source: GHSA Database

    What Happened

    On May 11, 2026, the threat group TeamPCP compromised the trusted release flow of the @tanstack/* project Snyk Research. By crafting a pull request that triggered a poorly isolated pull_request_target GitHub Actions runner, the attackers executed malicious code within a privileged context StepSecurity. The runner’s OIDC tokens were intercepted, allowing the attackers to authenticate directly to the npm registry as a trusted publisher Palo Alto Networks. Within minutes, the attackers pushed 84 compromised packages before maintainers noticed the rogue build logs and intervened to take down the releases TanStack Advisory.

    Technical Analysis

    Initial Access

    Initial access was achieved via a “Pwn Request” targeting the project’s GitHub Actions workflow StepSecurity. The workflow, configured with pull_request_target permissions, allowed untrusted forks to execute code with access to repository secrets and OIDC scopes. The attacker poisoned the GitHub Actions runner cache, inserting a malicious dependency loader that hijacked subsequent release stages.

    Package or Artifact Manipulation

    The attackers did not modify the main branch codebase. Instead, they manipulated the build runtime, injecting a malicious payload loader directly into the build artifact compilation step. The resulting package tarball included an injected optionalDependencies pointer redirecting to a rogue fork ("@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c") and planted a heavily obfuscated payload named router_init.js in the root of the packages Snyk Research.

    Execution Trigger

    The malware utilized npm install lifecycle hooks (e.g., preinstall or postinstall script triggers) defined in package.json. Upon running npm install on a developer workstation or a CI runner, Node.js executed the lifecycle hooks, launching router_init.js through the system shell.

    Payload Behavior

    The payload, router_init.js (a ~2.3 MB obfuscated loader), functioned as a credential-harvesting worm (“Mini Shai-Hulud”) Palo Alto Networks. Once active, it profiled the host environment, searching for local files containing secrets. It harvested:

    • AWS, Azure, and Google Cloud API credentials
    • Kubernetes service account tokens
    • HashiCorp Vault access configurations
    • Local .npmrc publishing tokens
    • SSH private keys
    • GitHub personal access tokens (PATs) and gh CLI OAuth sessions

    The malware also established persistence via local .vscode/tasks.json configurations and macOS LaunchAgents (gh-token-monitor). To deter response efforts, the payload monitored for token revocation and featured a destructive “dead man’s switch” capable of wiping filesystems if its access was severed TanStack Advisory.

    Exfiltration / C2

    domains:
      - "git-tanstack[.]com"
    ips: []
    urls:
      - "hxxps://git-tanstack[.]com"
    protocols:
      - "https"
      - "session"
    endpoints:
      - "/api/v1/exfil"
    confidence: high

    Stolen secrets were exfiltrated via HTTPS to the typosquatted C2 server git-tanstack[.]com and routed securely over the decentralized Session/Oxen messenger network.

    Propagation

    The malware featured autonomous worm capabilities, attempting to use the stolen npm and GitHub tokens to automatically publish malicious package updates to other downstream packages owned by the compromised developer or organization.

    Obfuscation or Evasion

    The router_init.js loader used complex multi-layered control-flow flattening, string encryption, and dead-code insertion to evade static analysis filters. Additionally, it attempted to download the Bun runtime (setup_bun.js) to execute its subsequent phases, circumventing Node-specific endpoint detection products.

    Affected Assets and Blast Radius

    affected_assets:
      ecosystems:
        - "npm"
      packages:
        - "@tanstack/zod-adapter"
        - "@tanstack/router"
        - "@tanstack/react-router"
        - "@tanstack/react-query"
        - "@tanstack/table-core"
      versions:
        - "1.166.12"
        - "1.166.15"
      repositories:
        - "github.com/tanstack/router"
        - "github.com/tanstack/query"
      container_images: []
      CI_CD_systems:
        - "GitHub Actions"
      developer_tools:
        - "npm CLI"
        - "VS Code"
      environments:
        - developer workstations
        - CI runners
        - build pipelines
    
    credentials_at_risk:
      - npm tokens
      - GitHub tokens
      - cloud credentials
      - SSH keys
      - environment variables
    
    not_currently_known_to_affect:
      - Production environments where install scripts are disabled (`--ignore-scripts`).

    Indicators of Compromise

    domains:
      - value: git-tanstack[.]com
        source: https://tanstack.com
        confidence: high
    ips: []
    urls:
      - value: hxxps://git-tanstack[.]com
        source: https://tanstack.com
        confidence: high
    hashes:
      - value: ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c
        source: https://snyk.io
        confidence: high
    files:
      - value: router_init.js
        source: https://tanstack.com
        confidence: high
      - value: tanstack_runner.js
        source: https://tanstack.com
        confidence: high
    package_versions:
      - value: @tanstack/[email protected]
        source: https://tanstack.com
        confidence: high
      - value: @tanstack/[email protected]
        source: https://tanstack.com
        confidence: high

    Detection and Hunting

    Script: local repository and exported telemetry scope

    #!/usr/bin/env python3
    import os
    import sys
    import json
    import subprocess
    from pathlib import Path
    
    ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
    LOG_ROOT = os.environ.get("LOG_ROOT", "")
    OUT = Path(os.environ.get("OUT", "hp-tanstack-pipeline-poisoning-scope"))
    SINCE = "2026-05-11T19:20:00Z"
    UNTIL = "2026-05-11T23:59:59Z"
    
    PACKAGES = [
      "@tanstack/zod-adapter",
      "@tanstack/router",
      "@tanstack/react-router",
      "@tanstack/react-query",
      "@tanstack/table-core",
    ]
    VERSIONS = [
      "1.166.12",
      "1.166.15",
      "@tanstack/[email protected]",
      "@tanstack/[email protected]",
    ]
    FILES = [
      "router_init.js",
      "tanstack_runner.js",
    ]
    DOMAINS = [
      "git-tanstack.com",
    ]
    URLS = [
      "https://git-tanstack.com",
      "https://tanstack.com",
      "https://snyk.io",
    ]
    IPS = [
    ]
    HASHES = [
      "ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c",
    ]
    PROCESS_PATTERNS = [
    ]
    NETWORK_PATTERNS = [
    ]
    
    # Positive signal: repository, lockfile, artifact, process, or network telemetry contains one of the exact incident selectors above.
    # Escalation: any match tied to a production build, CI run, deployed asset, or secret-bearing host moves the asset to presumed exposed.
    
    OUT.mkdir(parents=True, exist_ok=True)
    indicators_file = OUT / "indicators.txt"
    
    # Collect unique indicators
    indicators = set()
    for group in [PACKAGES, VERSIONS, FILES, DOMAINS, URLS, IPS, HASHES, PROCESS_PATTERNS, NETWORK_PATTERNS]:
        for val in group:
            if val:
                indicators.add(val)
    
    with open(indicators_file, "w") as f:
        for ind in sorted(indicators):
            f.write(ind + "\n")
    
    print(f"[+] Written unique selectors to {indicators_file}")
    
    # Walk local directory
    print(f"[+] Scanning directory: {ROOT} for selectors...")
    matches = []
    exclude_dirs = {"node_modules", "vendor", "dist", ".git"}
    for root, dirs, filenames in os.walk(ROOT):
        dirs[:] = [d for d in dirs if d not in exclude_dirs]
        for filename in filenames:
            filepath = Path(root) / filename
            try:
                content = filepath.read_text(errors="ignore")
                for ind in indicators:
                    if ind in content:
                        matches.append(f"{filepath}: found '{ind}'")
            except Exception:
                pass
    
    if matches:
        (OUT / "repository-indicator-matches.txt").write_text("\n".join(matches) + "\n")
        print(f"[!] Found {len(matches)} matches in codebase!")
    
    # Optional Log Scanning
    if LOG_ROOT and os.path.exists(LOG_ROOT):
        print(f"[+] Scanning telemetry log directory: {LOG_ROOT}...")
        log_matches = []
        for root, _, filenames in os.walk(LOG_ROOT):
            for filename in filenames:
                filepath = Path(root) / filename
                try:
                    content = filepath.read_text(errors="ignore")
                    for ind in indicators:
                        if ind in content:
                            log_matches.append(f"{filepath}: found '{ind}'")
                except Exception:
                    pass
        if log_matches:
            (OUT / "exported-telemetry-indicator-matches.txt").write_text("\n".join(log_matches) + "\n")
            print(f"[!] Found {len(log_matches)} matches in logs!")
    
        if PACKAGES:
            registry_dir = OUT / "registry"
            registry_dir.mkdir(exist_ok=True)
            for package in PACKAGES:
                if not package: continue
                safe_name = package.replace("/", "__")
                print(f"[+] Querying npm view for {package}...")
                res = subprocess.run(["npm", "view", package, "name", "version", "time", "versions", "dist-tags", "maintainers", "dist.tarball", "dist.integrity", "scripts", "--json"], capture_output=True, text=True)
                if res.returncode == 0:
                    (registry_dir / f"npm-{safe_name}.json").write_text(res.stdout)
    
    print(f"[+] Wrote scope artifacts under {OUT}")

    Downstream Abuse Audits

    Script: GitHub organization run, release, secret, and workflow audit

    #!/usr/bin/env python3
    import os
    import sys
    import json
    import subprocess
    from pathlib import Path
    
    if "ORG" not in os.environ:
        print("ERROR: Set ORG environment variable to the GitHub organization to audit", file=sys.stderr)
        sys.exit(1)
    
    ORG = os.environ["ORG"]
    SINCE = "2026-05-11T19:20:00Z"
    UNTIL = "2026-05-11T23:59:59Z"
    OUT = Path(os.environ.get("OUT", "hp-tanstack-pipeline-poisoning-github-audit"))
    
    SELECTORS = [
      "@tanstack/zod-adapter",
      "@tanstack/router",
      "@tanstack/react-router",
      "@tanstack/react-query",
      "@tanstack/table-core",
      "1.166.12",
      "1.166.15",
      "@tanstack/[email protected]",
      "@tanstack/[email protected]",
      "router_init.js",
      "tanstack_runner.js",
      "git-tanstack.com",
      "https://git-tanstack.com",
      "https://tanstack.com",
      "https://snyk.io",
      "ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c",
    ]
    
    # Positive signal: a workflow run, release, secret, key, package, or workflow change overlaps the exposure window and references an incident selector.
    # Remediation trigger: unauthorized post-exposure write activity or a secret-bearing run matching an incident selector requires token revocation and downstream cloud/registry review.
    
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "runs").mkdir(exist_ok=True)
    (OUT / "logs").mkdir(exist_ok=True)
    (OUT / "repos").mkdir(exist_ok=True)
    
    # 1. Write incident-selectors file
    selectors_file = OUT / "incident-selectors.txt"
    with open(selectors_file, "w") as sf:
        for s in SELECTORS:
            if s:
                sf.write(s + "\n")
    
    # 2. Get list of repos
    print(f"[+] Fetching repositories for organization: {ORG}")
    repo_res = subprocess.run(["gh", "repo", "list", ORG, "--limit", "1000", "--json", "nameWithOwner"], capture_output=True, text=True)
    if repo_res.returncode != 0:
        print(f"[-] Failed to fetch repos: {repo_res.stderr}", file=sys.stderr)
        sys.exit(1)
    
    repos = [r["nameWithOwner"] for r in json.loads(repo_res.stdout)]
    
    for repo in repos:
        safe_repo = repo.replace("/", "__")
        print(f"[+] Auditing repository: {repo}")
    
        # Check runs in the window
        runs_res = subprocess.run([
            "gh", "api", f"/repos/{repo}/actions/runs",
            "-f", "per_page=100",
            "-f", f"created=>={SINCE}",
            "--paginate"
        ], capture_output=True, text=True)
    
        if runs_res.returncode == 0:
            try:
                all_runs = json.loads(runs_res.stdout).get("workflow_runs", [])
                filtered_runs = [r for r in all_runs if r["created_at"] <= UNTIL]
    
                if filtered_runs:
                    with open(OUT / "runs" / f"{safe_repo}-runs.jsonl", "w") as rf:
                        for run in filtered_runs:
                            rf.write(json.dumps(run) + "\n")
    
                            # Fetch log dynamically
                            run_id = str(run["id"])
                            log_res = subprocess.run(["gh", "run", "view", run_id, "--repo", repo, "--log"], capture_output=True, text=True)
                            if log_res.returncode == 0:
                                (OUT / "logs" / f"{safe_repo}-{run_id}.log").write_text(log_res.stdout)
    
                            # Fetch details
                            view_res = subprocess.run(["gh", "run", "view", run_id, "--repo", repo, "--json", "databaseId,workflowName,headSha,event,createdAt,jobs"], capture_output=True, text=True)
                            if view_res.returncode == 0:
                                (OUT / "runs" / f"{safe_repo}-{run_id}.json").write_text(view_res.stdout)
            except Exception as e:
                print(f"[-] Error parsing runs for {repo}: {e}")
    
        # Check releases in window
        subprocess.run(["gh", "api", f"/repos/{repo}/releases", "-f", "per_page=100", "--paginate"], capture_output=True)
        # Check repo secrets updated in window
        subprocess.run(["gh", "api", f"/repos/{repo}/actions/secrets", "-f", "per_page=100", "--paginate"], capture_output=True)
        # Check deploy keys
        subprocess.run(["gh", "api", f"/repos/{repo}/keys", "-f", "per_page=100", "--paginate"], capture_output=True)
    
    # Scan output directory for any indicator selector matches
    print("[+] Scanning gathered telemetry for indicator matches...")
    subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(selectors_file), str(OUT)], capture_output=False)
    
    print(f"[+] Wrote GitHub audit artifacts under {OUT}")

    Script: cloud OIDC and deployment credential follow-on audit

    #!/usr/bin/env python3
    import os
    import json
    import subprocess
    from pathlib import Path
    
    SINCE = "2026-05-11T19:20:00Z"
    UNTIL = "2026-05-11T23:59:59Z"
    OUT = Path(os.environ.get("OUT", "hp-tanstack-pipeline-poisoning-cloud-audit"))
    AWS_REGIONS = os.environ.get("AWS_REGIONS", "us-east-1").split(",")
    
    # Positive signal: token exchange or privileged write activity occurs in the exposure window from GitHub, CI/CD, package registry, or deployment automation identity.
    # Remediation trigger: unexpected write, deploy, IAM, secret, or registry activity tied to an exposed CI/CD path requires trust-policy disablement and credential rotation.
    
    OUT.mkdir(parents=True, exist_ok=True)
    
    # 1. AWS CloudTrail Audit
    print("[+] Querying AWS CloudTrail for Web Identity token exchanges...")
    aws_events = []
    for region in AWS_REGIONS:
        res = subprocess.run([
            "aws", "cloudtrail", "lookup-events",
            "--region", region,
            "--start-time", SINCE,
            "--end-time", UNTIL,
            "--lookup-attributes", "AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity",
            "--output", "json"
        ], capture_output=True, text=True)
    
        if res.returncode == 0:
            try:
                events = json.loads(res.stdout).get("Events", [])
                for event in events:
                    ct = json.loads(event.get("CloudTrailEvent", "{}"))
                    ct["region"] = region
                    aws_events.append(ct)
            except Exception as e:
                print(f"[-] Error parsing AWS CloudTrail events: {e}")
    
    if aws_events:
        with open(OUT / "aws-assume-role-with-web-identity.jsonl", "w") as f:
            for ev in aws_events:
                f.write(json.dumps(ev) + "\n")
    
        # Audit follow-on events for returned access keys
        for ev in aws_events:
            access_key = ev.get("responseElements", {}).get("credentials", {}).get("accessKeyId")
            region = ev.get("region", "us-east-1")
            if access_key:
                print(f"[+] Enumerating AWS events for AccessKey: {access_key}")
                f_res = subprocess.run([
                    "aws", "cloudtrail", "lookup-events",
                    "--region", region,
                    "--start-time", SINCE,
                    "--end-time", UNTIL,
                    "--lookup-attributes", f"AttributeKey=AccessKeyId,AttributeValue={access_key}",
                    "--output", "json"
                ], capture_output=True, text=True)
                if f_res.returncode == 0:
                    try:
                        f_events = json.loads(f_res.stdout).get("Events", [])
                        with open(OUT / "aws-follow-on-api-calls.jsonl", "a") as ff:
                            for fe in f_events:
                                ff.write(fe.get("CloudTrailEvent", "{}") + "\n")
                    except Exception as e:
                        print(f"[-] Error writing follow-on events: {e}")
    
    # 2. Azure Activity Log Audit
    print("[+] Querying Azure activity logs...")
    az_res = subprocess.run([
        "az", "monitor", "activity-log", "list",
        "--start-time", SINCE,
        "--end-time", UNTIL,
        "--query", "[?contains(operationName.value, 'write') || contains(operationName.value, 'delete') || contains(operationName.value, 'Microsoft.ManagedIdentity')]",
        "-o", "json"
    ], capture_output=True, text=True)
    
    if az_res.returncode == 0:
        (OUT / "azure-write-delete-activity.json").write_text(az_res.stdout)
    
    # 3. GCP Logging Audit
    print("[+] Querying GCP Cloud Logging...")
    gcp_filter = f'timestamp>="{SINCE}" AND timestamp<="{UNTIL}" AND (protoPayload.methodName="google.sts.v1.SecurityTokenService.ExchangeToken" OR protoPayload.methodName:"GenerateAccessToken" OR protoPayload.methodName:"CreateServiceAccountKey" OR protoPayload.methodName:"SetIamPolicy")'
    gcp_res = subprocess.run([
        "gcloud", "logging", "read", gcp_filter,
        "--format", "json"
    ], capture_output=True, text=True)
    
    if gcp_res.returncode == 0:
        (OUT / "gcp-token-and-iam-activity.json").write_text(gcp_res.stdout)
    
    print(f"[+] Wrote cloud audit artifacts under {OUT}")

    Script: registry metadata and artifact audit

    #!/usr/bin/env python3
    import os
    import json
    import subprocess
    from pathlib import Path
    
    SINCE = "2026-05-11T19:20:00Z"
    OUT = Path(os.environ.get("OUT", "hp-tanstack-pipeline-poisoning-registry-audit"))
    PACKAGES = [
      "@tanstack/zod-adapter",
      "@tanstack/router",
      "@tanstack/react-router",
      "@tanstack/react-query",
      "@tanstack/table-core",
    ]
    VERSIONS = [
      "1.166.12",
      "1.166.15",
      "@tanstack/[email protected]",
      "@tanstack/[email protected]",
    ]
    
    # Positive signal: registry metadata, package tarballs, or cached artifacts contain the exact affected package/version values.
    # Remediation trigger: any internal package cache, build artifact, or deployment using these package/version values requires exposure scoping.
    
    OUT.mkdir(parents=True, exist_ok=True)
    
    with open(OUT / "affected-versions.txt", "w") as av:
        for version in VERSIONS:
            if version:
                av.write(version + "\n")
    
        # 1. Audit npm dependencies in lockfiles/package.json
        print("[+] Scanning lockfiles for npm selectors...")
        for file in ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "package.json"]:
            if Path(file).exists():
                subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(OUT / "affected-versions.txt"), file])
    
        # 2. Query registry metadata and fetch tarballs for local analysis
        metadata_dir = OUT / "metadata"
        tarballs_dir = OUT / "tarballs"
        metadata_dir.mkdir(exist_ok=True)
        tarballs_dir.mkdir(exist_ok=True)
        for package in PACKAGES:
            if not package: continue
            safe_name = package.replace("/", "__")
            print(f"[+] Querying npm view for {package}...")
            res = subprocess.run(["npm", "view", package, "time", "versions", "dist-tags", "maintainers", "dist.tarball", "dist.integrity", "scripts", "--json"], capture_output=True, text=True)
            if res.returncode == 0:
                (metadata_dir / f"npm-{safe_name}.json").write_text(res.stdout)
            subprocess.run(["npm", "pack", package, "--pack-destination", str(tarballs_dir)], capture_output=True)
    
        # 3. HOW TO REVOKE AND ROTATE EXPOSED NPM PUBLISHING TOKENS:
        # Revoke all compromised tokens via npm CLI:
        # subprocess.run(["npm", "token", "list"])
        # subprocess.run(["npm", "token", "revoke", "123456"])
        # Or logout to revoke the current session:
        # subprocess.run(["npm", "logout"])
        # Generate a new publishing token with MFA protection:
        # subprocess.run(["npm", "token", "create", "--read-only=false", "--cidr=0.0.0.0/0"])
    
    print(f"[+] Wrote registry audit artifacts under {OUT}")

    Sources

    1. TanStack Official Postmortem. Role: DIRECT_SOURCE Impact: Detailed timeline, affected versions, and root cause analysis of the pipeline hijacking.
    2. GHSA-g7cv-rxg3-hmpx Advisory Record. Role: DIRECT_SOURCE Impact: Official vulnerability tracking and affected version mapping.
    3. Snyk Security Analysis of TanStack Incident. Role: PRIMARY_RESEARCH Impact: In-depth breakdown of the “Pwn Request” pattern and OIDC token hijacking vector.
    4. StepSecurity Incident Investigation Report. Role: PRIMARY_RESEARCH Impact: Detailed technical analysis of the runner cache poisoning and loader persistence mechanics.

    IOC Clipboard

    7 IOCs
    Defang IOCs
    domain git-tanstack.com git-tanstack[.]com
    url https://git-tanstack.com hxxps://git-tanstack[.]com
    url https://tanstack.com hxxps://tanstack[.]com
    url https://snyk.io hxxps://snyk[.]io
    hash ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c
    file router_init.js router_init.js
    file tanstack_runner.js tanstack_runner.js