medium Threat analysis

GemStuffer RubyGems Exfiltration Channel

GemStuffer used RubyGems package publishing as a data-staging channel, wrapping scraped UK council ModernGov portal responses into junk gem artifacts published with embedded RubyGems API keys.

#supply-chain#rubygems#ruby#exfiltration#public-sector
On this page 0% read

    Executive Summary

    GemStuffer should become a new standalone post. It is not a classic dependency compromise: public evidence does not show existing legitimate gems being hijacked, mass developer installs, or a self-propagating installer. It is still supply-chain abuse because the actor used RubyGems’ package release path as an exfiltration and storage primitive, turning ordinary gem artifacts into retrievable containers for scraped public-sector portal data Socket.

    Socket reports more than 100 gems in the campaign and says its tracker contains 155 affected package artifacts Socket. Representative tracker rows include agenda-sample-yard 0.1.1, bot9evil 0.1.0, fetchrootx2 0.0.1, soufetchabc 0.0.3, wandcabfetchfix21736 0.0.1, wandscrawlr 0.0.1, slnleaker5 0.0.1, fetchrootx1 0.0.1, and lambeth71b 0.0.1 Socket tracker. RubyGems public timeframe metadata independently confirms yanked representative versions and exposes SHA-256 values for those versions through the registry API fields documented by RubyGems RubyGems API.

    The downstream risk is registry-abuse blind spots. A POST to rubygems[.]org/api/v1/gems can look like a normal release from a developer workstation or CI runner, but in this case the uploaded binary gem carried scraped HTML responses from UK council ModernGov portals. Defenders should monitor who is allowed to publish packages, not only which packages are installed.

    Key Facts

    threat_type: "RubyGems registry abuse as exfiltration channel"
    ecosystem: "RubyGems, Ruby"
    registry: "RubyGems.org"
    campaign_name: "GemStuffer"
    affected_package_artifacts: "155 reported by Socket tracker; representative rows verified"
    affected_packages_representative:
      - "agenda-sample-yard"
      - "bot9evil"
      - "fetchrootx2"
      - "soufetchabc"
      - "lambeth71b"
      - "wandscrawlr"
      - "slnleaker5"
      - "lambethx33zzz"
      - "southfetchprobe42"
    malicious_versions_representative:
      - "agenda-sample-yard 0.1.1"
      - "bot9evil 0.1.0"
      - "fetchrootx2 0.0.1"
      - "soufetchabc 0.0.3"
      - "lambeth71b 0.0.1"
    known_good_versions: []
    fixed_or_safe_versions:
      - "not applicable; junk packages were yanked or should be treated as malicious staging artifacts"
    execution_trigger: "unknown delivery vector; observed Ruby payload execution with embedded RubyGems publishing credentials"
    primary_impact: "scraped council portal data staged into public RubyGems package artifacts"
    public_sector_scope:
      - "Lambeth ModernGov portal"
      - "Wandsworth democratic services portal"
      - "Southwark ModernGov portal"
    confidence: "medium"
    canonical_source: "https://socket.dev/blog/gemstuffer"
    last_verified: "2026-05-28"

    Source Confidence And Evidence Mapping

    • confirmed: Socket published primary research with representative Ruby code for ModernGov scraping, gem archive construction, /tmp/gemhome credential injection, gem push, and direct HTTP upload variants Socket.
    • confirmed: Socket’s public campaign tracker reports 155 affected package artifacts and shows representative package/version rows with May 12, 2026 UTC publish and detection timestamps Socket tracker.
    • confirmed: RubyGems documents POST /api/v1/gems, version metadata, SHA fields, yanking, and timeframe_versions, matching the registry mechanics observed in this campaign RubyGems API.
    • confirmed: RubyGems status history records temporary disabling of new user registrations from May 12, 2026 08:54 UTC to May 16, 2026 05:12 UTC during the broader spam-publishing response RubyGems status.
    • unknown: Publisher account handles, complete account-to-package mapping, initial delivery vector, and whether every tracked artifact contained scraped data versus probe or spam content.

    Impact Determination

    ClassificationCriteriaRequired evidenceRequired actionClosure condition
    Confirmed staging hostA host ran the Ruby payload, wrote /tmp/gemhome/.gem/credentials, built a gem, or posted a gem body to RubyGems.EDR process/file telemetry, shell history, CI logs, preserved /tmp directories, RubyGems API key audit records.Isolate long enough to preserve evidence, revoke RubyGems keys, review other reachable secrets, and block further gem publishing from that host.No remaining payload files, no unauthorized gem pushes, credentials rotated, and egress controls enforce approved release runners only.
    Presumed staging hostThe host made an unauthorized POST to rubygems[.]org/api/v1/gems in the campaign window and also accessed one of the ModernGov calendar URLs.Proxy logs with method/path, TLS inspection metadata if available, process attribution, Ruby runtime telemetry.Treat as exfiltration-capable until proven otherwise; preserve logs and map the executing user.Network event is explained by an approved release workflow or incident handling is complete.
    Registry exposure onlyYour organization did not execute the payload but mirrors, caches, SBOMs, or dependency tools recorded GemStuffer package artifacts.Gem cache listings, Artifactory/Nexus logs, SBOMs, lockfiles, RubyGems mirror data.Remove cached gems, block known names, and ensure no internal automation promoted them.Internal package mirrors and dependency indexes no longer serve the artifacts.
    Public-sector data exposureCouncil portal pages or agenda responses appear in gem archive content or registry metadata.Extracted data.tar.gz, lib/result.txt, README payloads, metadata descriptions, council URLs.Notify data owners if non-public or sensitive content is observed; otherwise record bulk public-data scraping exposure.Each recovered artifact is classified as public, sensitive, or unknown and handled accordingly.
    Not exposedNo GemStuffer package names, payload hashes, /tmp artifacts, ModernGov scrape requests, or unauthorized RubyGems publish requests are present.Source/gem-cache scans, proxy logs, EDR telemetry, CI job logs.Keep registry publish monitoring in place.Search coverage includes developer endpoints, CI, package mirrors, and logs for the campaign window.

    Timeline

    • 2026-05-12 02:20-03:30 UTC: RubyGems timeframe metadata shows yanked representative versions and many suspicious May 12 package rows containing ModernGov or related selectors. Local verification used the public timeframe_versions API documented by RubyGems RubyGems API.
    • 2026-05-12 08:54 UTC: RubyGems status history marks the start of temporary new-user registration disabling during the broader abuse response RubyGems status.
    • 2026-05-13: Socket publishes the GemStuffer research and links it to the RubyGems spam-publishing context Socket.
    • 2026-05-16 05:12 UTC: RubyGems status history marks the registration-disabling incident resolved RubyGems status.
    • 2026-05-28: Representative RubyGems API checks return yanked: true for sampled GemStuffer package versions.

    Technical Analysis

    Registry Abuse, Not Normal Package Consumption

    The campaign’s most important distinction is directionality. The package registry was not merely a place where victims downloaded code. The payload published new packages back to RubyGems so the actor could retrieve scraped data later with standard gem tooling Socket.

    Socket’s representative chain collects execution context, fetches ModernGov calendar and agenda pages, writes the HTTP responses into a valid gem directory, builds a .gem archive, and pushes it to RubyGems. In one variant, the scraped content lands in lib/result.txt; in another, it is placed in a README inside a gem built through Ruby APIs Socket.

    Scraping Targets

    Socket identifies three public-facing UK council portals: moderngov[.]lambeth[.]gov[.]uk, democracy[.]wandsworth[.]gov[.]uk, and moderngov[.]southwark[.]gov[.]uk. The code follows mgCalendarMonthView.aspx pages, extracts ieList and mgCommittee links, and fetches follow-on agenda pages Socket.

    The public evidence supports bulk scraping of public ModernGov content. It does not prove theft of private council systems, authenticated portals, or internal networks. That uncertainty matters: responders should classify recovered lib/result.txt or README content rather than assuming either harmless public data or confirmed sensitive data.

    RubyGems Credential Handling

    Some samples created /tmp/gemhome/.gem/credentials, wrote a hardcoded RubyGems API key, set permissions to 0600, and overrode HOME so gem push would read that fabricated credential store. Other variants avoided the gem CLI and used Net::HTTP::Post directly with an Authorization header and application/octet-stream body Socket. RubyGems’ API documentation confirms that gem creation is a binary POST to /api/v1/gems authenticated with an Authorization header RubyGems API.

    This makes credential rotation narrower than a typical credential stealer but still urgent. Rotate observed RubyGems API keys first, then evaluate secrets reachable from the host that ran the unknown delivery vector.

    Registry Metadata Verification

    Direct package pages for many sampled names now return not found or yanked states, but timeframe_versions remains useful for retrospective scoping. Representative API rows verified on May 28, 2026 included:

    representative_verified_rows:
      - package: "agenda-sample-yard"
        version: "0.1.1"
        created_at: "2026-05-12T03:25:33.962Z"
        sha256: "2e4e099275efb8f886824a8eccdc595e624cd08ebb1772bd427710e08ff3ab24"
        yanked: true
      - package: "bot9evil"
        version: "0.1.0"
        created_at: "2026-05-12T03:23:17.445Z"
        sha256: "94d6c0b589704c8cc75e19f7250d6bfda473266dd7dd7e23fd14bd1bb972a717"
        yanked: true
      - package: "fetchrootx2"
        version: "0.0.1"
        created_at: "2026-05-12T03:21:50.962Z"
        sha256: "986342f884d531d686eeda19eb2cdc32eecea3f9d49ad6be6d493b5e680fc38b"
        yanked: true
      - package: "soufetchabc"
        version: "0.0.3"
        created_at: "2026-05-12T03:18:31.634Z"
        sha256: "75608fbc0307555c0f8eafe03f323c556dd4b2a7a05fa17ab4a13b7ef1d86eb7"
        yanked: true
      - package: "lambeth71b"
        version: "0.0.1"
        created_at: "2026-05-12T03:13:47.068Z"
        sha256: "34212b88108cab6ded037257d6fbc79a61b4c2ea8ecddc6c513b5aad1f308638"
        yanked: true

    RubyGems also publishes weekly sanitized PostgreSQL dumps, which are the right next source for a complete registry-scale ledger without scraping live package pages RubyGems data.

    Affected Assets And Blast Radius

    affected_assets:
      ecosystems:
        - "RubyGems"
        - "Ruby"
      packages:
        - "agenda-sample-yard"
        - "bot9evil"
        - "fetchrootx2"
        - "soufetchabc"
        - "wandcabfetchfix21736"
        - "wandscrawlr"
        - "slnleaker5"
        - "fetchrootx1"
        - "lambeth71b"
        - "lambethx33zzz"
        - "southfetchprobe42"
      versions:
        - "0.0.1"
        - "0.0.2"
        - "0.0.3"
        - "0.0.5"
        - "0.1.0"
        - "0.1.1"
        - "1.0.0"
        - "1.2.3"
        - "9.8.0"
        - "9.9.0"
      public_sector_portals:
        - "moderngov[.]lambeth[.]gov[.]uk"
        - "democracy[.]wandsworth[.]gov[.]uk"
        - "moderngov[.]southwark[.]gov[.]uk"
      ci_cd_systems:
        - "unknown; any Ruby-capable release runner with outbound RubyGems publish access should be audited"
      developer_tools:
        - "Ruby"
        - "RubyGems gem CLI"
    credentials_at_risk:
      - "RubyGems API keys embedded in payloads"
      - "secrets reachable from hosts where the unknown delivery vector executed"
    not_currently_known_to_affect:
      - "Existing legitimate RubyGems packages, based on public reporting available for this post."
      - "Developers who only consumed normal Ruby dependencies and never executed the GemStuffer payload."

    Indicators of Compromise

    package_versions:
      - "agenda-sample-yard 0.1.1"
      - "bot9evil 0.1.0"
      - "fetchrootx2 0.0.1"
      - "soufetchabc 0.0.3"
      - "lambeth71b 0.0.1"
    files:
      - "payload.rb"
      - "script.rb"
      - "evil.rb"
      - "yardload.rb"
      - "yard_plugin.rb"
      - "exploit.rb"
      - "extconf.rb"
      - "fetcher.rb"
      - "/tmp/gemhome/.gem/credentials"
      - "/tmp/rubydocran_*"
      - "lib/result.txt"
      - "x.gemspec"
    hashes:
      - "239440c830e17530dda0a8a06ed2708860998750a1e3ed2239e919465dc59420"
      - "c2d6bcacc88177e0f2c8c262726f86f37e671b1692c8bc135bac4b610ddcf31a"
      - "34212b88108cab6ded037257d6fbc79a61b4c2ea8ecddc6c513b5aad1f308638"
      - "2e4e099275efb8f886824a8eccdc595e624cd08ebb1772bd427710e08ff3ab24"
      - "94d6c0b589704c8cc75e19f7250d6bfda473266dd7dd7e23fd14bd1bb972a717"
    domains:
      - "rubygems[.]org"
      - "moderngov[.]lambeth[.]gov[.]uk"
      - "democracy[.]wandsworth[.]gov[.]uk"
      - "moderngov[.]southwark[.]gov[.]uk"
    urls:
      - "hxxps://rubygems[.]org/api/v1/gems"
      - "hxxps://moderngov[.]lambeth[.]gov[.]uk/mgCalendarMonthView[.]aspx?M=1&Y=2026&GL=1&bcr=1"
      - "hxxps://democracy[.]wandsworth[.]gov[.]uk/mgCalendarMonthView[.]aspx?M=1&Y=2026&GL=1&bcr=1"
      - "hxxps://moderngov[.]southwark[.]gov[.]uk/mgCalendarMonthView[.]aspx?M=1&Y=2026&GL=1&bcr=1"
    ips: []
    process_patterns:
      - "ruby writing /tmp/gemhome/.gem/credentials"
      - "ruby running gem build"
      - "ruby running gem push"
      - "ruby Net::HTTP::Post to RubyGems"
    network_patterns:
      - "POST hxxps://rubygems[.]org/api/v1/gems"
      - "GET ModernGov mgCalendarMonthView.aspx with User-Agent Mozilla/5.0"

    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-gemstuffer-rubygems-exfiltration-channel-scope"))
    SINCE = "2026-05-28T00:00:00Z"
    UNTIL = "2026-05-28T23:59:59Z"
    
    PACKAGES = [
    ]
    VERSIONS = [
      "agenda-sample-yard 0.1.1",
      "bot9evil 0.1.0",
      "fetchrootx2 0.0.1",
      "soufetchabc 0.0.3",
      "lambeth71b 0.0.1",
    ]
    FILES = [
      "payload.rb",
      "script.rb",
      "evil.rb",
      "yardload.rb",
      "yard_plugin.rb",
      "exploit.rb",
      "extconf.rb",
      "fetcher.rb",
      "/tmp/gemhome/.gem/credentials",
      "/tmp/rubydocran_*",
      "lib/result.txt",
      "x.gemspec",
    ]
    DOMAINS = [
      "rubygems.org",
      "moderngov.lambeth.gov.uk",
      "democracy.wandsworth.gov.uk",
      "moderngov.southwark.gov.uk",
    ]
    URLS = [
      "https://rubygems.org/api/v1/gems",
      "https://moderngov.lambeth.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1",
      "https://democracy.wandsworth.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1",
      "https://moderngov.southwark.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1",
    ]
    IPS = [
    ]
    HASHES = [
      "239440c830e17530dda0a8a06ed2708860998750a1e3ed2239e919465dc59420",
      "c2d6bcacc88177e0f2c8c262726f86f37e671b1692c8bc135bac4b610ddcf31a",
      "34212b88108cab6ded037257d6fbc79a61b4c2ea8ecddc6c513b5aad1f308638",
      "2e4e099275efb8f886824a8eccdc595e624cd08ebb1772bd427710e08ff3ab24",
      "94d6c0b589704c8cc75e19f7250d6bfda473266dd7dd7e23fd14bd1bb972a717",
    ]
    PROCESS_PATTERNS = [
      "ruby writing /tmp/gemhome/.gem/credentials",
      "ruby running gem build",
      "ruby running gem push",
      "ruby Net::HTTP::Post to RubyGems",
    ]
    NETWORK_PATTERNS = [
      "POST hxxps://rubygems.org/api/v1/gems",
      "GET ModernGov mgCalendarMonthView.aspx with User-Agent Mozilla/5.0",
    ]
    
    # 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)
    
    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-28T00:00:00Z"
    UNTIL = "2026-05-28T23:59:59Z"
    OUT = Path(os.environ.get("OUT", "hp-gemstuffer-rubygems-exfiltration-channel-github-audit"))
    
    SELECTORS = [
      "agenda-sample-yard 0.1.1",
      "bot9evil 0.1.0",
      "fetchrootx2 0.0.1",
      "soufetchabc 0.0.3",
      "lambeth71b 0.0.1",
      "payload.rb",
      "script.rb",
      "evil.rb",
      "yardload.rb",
      "yard_plugin.rb",
      "exploit.rb",
      "extconf.rb",
      "fetcher.rb",
      "/tmp/gemhome/.gem/credentials",
      "/tmp/rubydocran_*",
      "lib/result.txt",
      "x.gemspec",
      "rubygems.org",
      "moderngov.lambeth.gov.uk",
      "democracy.wandsworth.gov.uk",
      "moderngov.southwark.gov.uk",
      "https://rubygems.org/api/v1/gems",
      "https://moderngov.lambeth.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1",
      "https://democracy.wandsworth.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1",
      "https://moderngov.southwark.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1",
      "239440c830e17530dda0a8a06ed2708860998750a1e3ed2239e919465dc59420",
      "c2d6bcacc88177e0f2c8c262726f86f37e671b1692c8bc135bac4b610ddcf31a",
      "34212b88108cab6ded037257d6fbc79a61b4c2ea8ecddc6c513b5aad1f308638",
      "2e4e099275efb8f886824a8eccdc595e624cd08ebb1772bd427710e08ff3ab24",
      "94d6c0b589704c8cc75e19f7250d6bfda473266dd7dd7e23fd14bd1bb972a717",
    ]
    
    # 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-28T00:00:00Z"
    UNTIL = "2026-05-28T23:59:59Z"
    OUT = Path(os.environ.get("OUT", "hp-gemstuffer-rubygems-exfiltration-channel-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-28T00:00:00Z"
    OUT = Path(os.environ.get("OUT", "hp-gemstuffer-rubygems-exfiltration-channel-registry-audit"))
    PACKAGES = [
    ]
    VERSIONS = [
      "agenda-sample-yard 0.1.1",
      "bot9evil 0.1.0",
      "fetchrootx2 0.0.1",
      "soufetchabc 0.0.3",
      "lambeth71b 0.0.1",
    ]
    
    # Positive signal: workflow files or extensions reference the affected action/extension names or versions.
    # Remediation trigger: exposed secrets or OIDC federation policies must be immediately rotated.
    
    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. Search local workspace files for the affected actions/extensions
        print("[+] Scanning workspace workflows for selectors...")
        for file in Path(".").glob(".github/workflows/**/*.yml"):
            subprocess.run(["rg", "-n", "--hidden", "--fixed-strings", "-f", str(OUT / "affected-versions.txt"), str(file)])
    
        # 2. HOW TO ROTATE EXPOSED GITHUB ACTIONS SECRETS:
        # Overwrite compromised secrets with newly generated credentials:
        # subprocess.run(["gh", "secret", "set", "COMPROMISED_SECRET_NAME", "--body", "my-new-secret-value", "--repo", "my-org/my-repo"])
        # For organization-level secrets:
        # subprocess.run(["gh", "secret", "set", "COMPROMISED_SECRET_NAME", "--org", "my-org", "--visibility", "private"])
        # Revoke compromised OIDC federated trust credentials in AWS/GCP and redeploy the IAM trust policy:
        # subprocess.run(["aws", "iam", "update-assume-role-policy", "--role-name", "my-role-name", "--policy-document", "file://new-clean-trust-policy.json"])
    
    print(f"[+] Wrote registry audit artifacts under {OUT}")

    Remediation

    1. Contain systems that show GemStuffer execution or unauthorized RubyGems publishing.
    2. Preserve /tmp/gemhome, /tmp/rubydocran_*, package staging directories, .gem archives, shell histories, CI logs, and proxy logs.
    3. Revoke all RubyGems API keys observed in payloads or logs. Review key creation and usage around May 12, 2026.
    4. Block RubyGems gem-create POSTs from networks and runners that should never publish gems.
    5. For legitimate release automation, restrict RubyGems publishing to named runners and expected package names.
    6. Remove cached GemStuffer gems from internal mirrors and dependency caches.
    7. Classify any recovered council data and notify affected data owners if non-public or sensitive content appears.
    8. Close the incident only after registry, endpoint, CI, proxy, and mirror checks are complete and documented.

    Machine-Readable Event Profile

    {
      "schema_version": "2.0",
      "event_id": "gemstuffer-rubygems-exfiltration-channel-2026-05-13",
      "event_name": "GemStuffer RubyGems Exfiltration Channel",
      "parent_campaign_id": "none",
      "is_campaign_level": true,
      "publication_state": "publish_ready",
      "confidence": "medium",
      "confidence_reason": "Primary research and RubyGems registry metadata confirm the technique and representative yanked package artifacts; publisher identities, full package set, and original delivery vector remain incomplete publicly.",
      "attack_types": ["registry abuse", "package artifact data staging", "hardcoded registry credential use", "public-sector scraping"],
      "sources": {
        "direct": ["https://guides.rubygems.org/rubygems-org-api/", "https://status.rubygems.org/history", "https://rubygems.org/pages/data"],
        "primary_research": ["https://socket.dev/blog/gemstuffer", "https://socket.dev/supply-chain-attacks/gemstuffer"],
        "correlated": []
      },
      "affected_assets": {
        "ecosystems": ["RubyGems", "Ruby"],
        "registries": ["RubyGems.org"],
        "packages": ["agenda-sample-yard", "bot9evil", "fetchrootx2", "soufetchabc", "wandcabfetchfix21736", "wandscrawlr", "slnleaker5", "fetchrootx1", "lambeth71b", "probeextwand", "designfetchdemo", "lambethx33zzz", "wandocal1", "wandcabm10266dsgn4", "sl-yard-probe2", "lambexploitabc1", "wandscrawlq", "lambcrawlxyz", "swmeetfetcha", "lbdeepgeta", "slfetchrootabc", "zzsouthrunnerb", "slnleakerext", "runnerhack1778553910", "southfetchprobe42"],
        "versions": ["0.0.1", "0.0.2", "0.0.3", "0.0.5", "0.1.0", "0.1.1", "1.0.0", "1.2.3", "9.8.0", "9.9.0"],
        "repositories": [],
        "vendors": ["Lambeth Council", "Wandsworth Council", "Southwark Council"],
        "ci_cd_systems": ["unknown"],
        "developer_tools": ["Ruby", "RubyGems gem CLI"],
        "credentials_at_risk": ["RubyGems API keys embedded in payloads", "credentials reachable from any host where the unknown delivery vector executed"]
      },
      "timeline": {
        "first_seen": "2026-05-12T02:20:00Z",
        "malicious_publish_time": "2026-05-12T02:20:00Z/2026-05-12T03:30:00Z",
        "discovery_time": "2026-05-13",
        "removal_time": "mixed; representative RubyGems API rows show yanked=true as of 2026-05-28",
        "disclosure_time": "2026-05-13",
        "patch_or_fix_time": "unknown"
      },
      "artifact_analysis": {
        "malicious_artifacts": ["payload.rb", "script.rb", "evil.rb", "yardload.rb", "yard_plugin.rb", "exploit.rb", "extconf.rb", "fetcher.rb", ".gem archives containing scraped responses"],
        "execution_trigger": "unknown",
        "payload_behavior": ["scrape ModernGov pages", "build RubyGems packages", "push packages with embedded RubyGems API keys"],
        "provenance": {}
      },
      "iocs": {
        "package_versions": ["agenda-sample-yard 0.1.1", "bot9evil 0.1.0", "fetchrootx2 0.0.1", "soufetchabc 0.0.3", "lambeth71b 0.0.1"],
        "files": ["payload.rb", "script.rb", "evil.rb", "yardload.rb", "yard_plugin.rb", "exploit.rb", "extconf.rb", "fetcher.rb", "/tmp/gemhome/.gem/credentials", "/tmp/rubydocran_*", "lib/result.txt", "x.gemspec"],
        "hashes": ["239440c830e17530dda0a8a06ed2708860998750a1e3ed2239e919465dc59420", "c2d6bcacc88177e0f2c8c262726f86f37e671b1692c8bc135bac4b610ddcf31a", "34212b88108cab6ded037257d6fbc79a61b4c2ea8ecddc6c513b5aad1f308638", "2e4e099275efb8f886824a8eccdc595e624cd08ebb1772bd427710e08ff3ab24", "94d6c0b589704c8cc75e19f7250d6bfda473266dd7dd7e23fd14bd1bb972a717"],
        "domains": ["rubygems.org", "moderngov.lambeth.gov.uk", "democracy.wandsworth.gov.uk", "moderngov.southwark.gov.uk"],
        "urls": ["https://rubygems.org/api/v1/gems", "https://moderngov.lambeth.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1", "https://democracy.wandsworth.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1", "https://moderngov.southwark.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1"],
        "ips": [],
        "process_patterns": ["ruby writing /tmp/gemhome/.gem/credentials", "ruby running gem build", "ruby running gem push", "ruby Net::HTTP::Post to RubyGems"],
        "network_patterns": ["POST https://rubygems.org/api/v1/gems", "GET ModernGov mgCalendarMonthView.aspx with User-Agent Mozilla/5.0"]
      },
      "detection": {
        "registry_hunts": ["Query RubyGems timeframe_versions for May 12, 2026 versions containing ModernGov domains or known package/version pairs."],
        "filesystem_hunts": ["Search /tmp, gem caches, CI workspaces, and shell histories for GemStuffer filenames, /tmp/gemhome, and known package names."],
        "process_hunts": ["Find ruby processes invoking gem build, gem push, or Net::HTTP::Post to RubyGems from non-release hosts."],
        "network_hunts": ["Find RubyGems gem-create POSTs and ModernGov calendar scraping from developer, CI, or production hosts."],
        "ci_cd_hunts": ["Identify CI jobs with Ruby installed that made outbound RubyGems publish requests outside approved release workflows."]
      },
      "open_questions": ["complete 155 artifact list", "publisher account mapping", "initial delivery vector", "private-data exposure status"],
      "defender_takeaways": {
        "detection": "Treat unexpected RubyGems publishing traffic as exfiltration-capable egress, not merely release activity.",
        "hunting": "Correlate ModernGov scraping, /tmp gem staging, and RubyGems gem-create POSTs in the same host or CI job window.",
        "remediation": "Yank confirmed gems, revoke embedded RubyGems keys, preserve staging artifacts, and restrict gem publishing to approved release runners.",
        "prevention": "Block registry publish endpoints from systems that consume packages but never publish them."
      }
    }

    Sources

    1. Socket: GemStuffer Campaign Abuses RubyGems as Exfiltration Channel Targeting UK Local Government
    2. Socket GemStuffer campaign tracker
    3. RubyGems.org API guide
    4. RubyGems.org status history
    5. RubyGems.org data dumps

    IOC Clipboard

    25 IOCs
    Defang IOCs
    domain rubygems.org rubygems[.]org
    domain moderngov.lambeth.gov.uk moderngov[.]lambeth[.]gov[.]uk
    domain democracy.wandsworth.gov.uk democracy[.]wandsworth[.]gov[.]uk
    domain moderngov.southwark.gov.uk moderngov[.]southwark[.]gov[.]uk
    url https://rubygems.org/api/v1/gems hxxps://rubygems[.]org/api/v1/gems
    url https://moderngov.lambeth.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1 hxxps://moderngov[.]lambeth[.]gov[.]uk/mgCalendarMonthView[.]aspx?M=1&Y=2026&GL=1&bcr=1
    url https://democracy.wandsworth.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1 hxxps://democracy[.]wandsworth[.]gov[.]uk/mgCalendarMonthView[.]aspx?M=1&Y=2026&GL=1&bcr=1
    url https://moderngov.southwark.gov.uk/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1 hxxps://moderngov[.]southwark[.]gov[.]uk/mgCalendarMonthView[.]aspx?M=1&Y=2026&GL=1&bcr=1
    hash 239440c830e17530dda0a8a06ed2708860998750a1e3ed2239e919465dc59420 239440c830e17530dda0a8a06ed2708860998750a1e3ed2239e919465dc59420
    hash c2d6bcacc88177e0f2c8c262726f86f37e671b1692c8bc135bac4b610ddcf31a c2d6bcacc88177e0f2c8c262726f86f37e671b1692c8bc135bac4b610ddcf31a
    hash 34212b88108cab6ded037257d6fbc79a61b4c2ea8ecddc6c513b5aad1f308638 34212b88108cab6ded037257d6fbc79a61b4c2ea8ecddc6c513b5aad1f308638
    hash 2e4e099275efb8f886824a8eccdc595e624cd08ebb1772bd427710e08ff3ab24 2e4e099275efb8f886824a8eccdc595e624cd08ebb1772bd427710e08ff3ab24
    hash 94d6c0b589704c8cc75e19f7250d6bfda473266dd7dd7e23fd14bd1bb972a717 94d6c0b589704c8cc75e19f7250d6bfda473266dd7dd7e23fd14bd1bb972a717
    file payload.rb payload.rb
    file script.rb script.rb
    file evil.rb evil.rb
    file yardload.rb yardload.rb
    file yard_plugin.rb yard_plugin.rb
    file exploit.rb exploit.rb
    file extconf.rb extconf.rb
    file fetcher.rb fetcher.rb
    file /tmp/gemhome/.gem/credentials /tmp/gemhome/.gem/credentials
    file /tmp/rubydocran_* /tmp/rubydocran_*
    file lib/result.txt lib/result.txt
    file x.gemspec x.gemspec