Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-12 19:12:29 +02:00
commit e8e64eff12
101 changed files with 10081 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Contributing
Everybody is invited and welcome to contribute. There is a lot to do... Check the issues!
The process is straight-forward.
- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1)
- Fork this git repository
- Write your changes (bug fixes, new features, ...).
- Create a Pull Request against the main branch.
+1
View File
@@ -0,0 +1 @@
ko_fi: anotherhadi
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+14
View File
@@ -0,0 +1,14 @@
## CA Certificate Installation
1. Copy your **CA Certificate** located in `{{.Cfg.App.CertDir}}`
2. Install your certificate:
- On Chrome:
- Open your Chrome settings, search for "Certificates" and click on "Security".
- In the security settings page, scroll down and click on "Manage certificates".
- Select the "Authorities" tab and click on "Import tab and click on "Import".
- Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`.
- On Firefox:
- Open your Firefox settings, search for "Certificates" and click on "View Certificates".
- Select the "Authorities" tab and click on "Import".
- Select the `mitmproxy-ca-cert.pem` file in `{{.Cfg.App.CertDir}}`.
- When prompted, click the "Trust this CA to identify websites" checkbox, then click on "OK".
+25
View File
@@ -0,0 +1,25 @@
## History Search
The History page has a built-in search bar with two modes:
**Fulltext search**: press `/` to open it. Results filter in real time as you type across all fields: method, host, path, and the raw request/response bodies.
**SQL mode**: press `:` to open it, then `Enter` to run. You can write either a WHERE expression or a full SELECT query against the `entries` table.
WHERE expression (the `SELECT` is added automatically):
```sql
status_code = 404
```
```sql
host LIKE '%.api.%' AND method = 'POST'
```
Full SELECT query:
```sql
SELECT * FROM entries WHERE response_raw LIKE '%password%' ORDER BY timestamp DESC LIMIT 20
```
The `entries` table has the following columns: `id`, `timestamp`, `method`, `host`, `path`, `status_code`, `request_raw`, `response_raw`.
+15
View File
@@ -0,0 +1,15 @@
```txt
)
(
)
.-.,--^--. _
\\| `---' |//
\| /
_\_______/_
```
# Spilltea Documentation
- **Version**: `{{.Cfg.Version}}`
- **Repository**: `https://github.com/anotherhadi/spilltea`
- **Sponsor this project**: `https://ko-fi.com/anotherhadi`
+127
View File
@@ -0,0 +1,127 @@
# Plugins
Spilltea supports Lua plugins that can intercept, modify, and analyze HTTP traffic.
## Where to place plugins
Put `.lua` files in the directory configured by `plugins_dir` in your config file (default: `~/.config/spilltea/plugins`).
Each file is loaded as a separate plugin at startup. The plugin list is shown on the **Plugins** page.
## Plugin structure
Every plugin must declare a `Plugin` table and implement the hooks it wants to use.
```lua
Plugin = {
name = "My Plugin",
-- Declare which hooks you use and whether they are synchronous.
on_start = { sync = true },
on_request = { sync = true },
on_response = { sync = false },
on_history_entry = {},
on_quit = {},
}
```
### Hook reference
| Hook | When called | Sync/async | Return value |
| ------------------------- | --------------------------- | ------------ | ------------------- |
| `on_start(config_text)` | Once at startup | always sync | ignored |
| `on_quit()` | When the app exits | always sync | ignored |
| `on_request(req)` | Every request | declared | `"drop"`, `"forward"`, or `nil` (sync only) |
| `on_response(req, res)` | Every response | declared | `"drop"`, `"forward"`, or `nil` (sync only) |
| `on_history_entry(entry)` | After a flow is saved to DB | always async | ignored |
## Request and response objects
### `req` (request)
| Field / method | Type | Description |
| ----------------------------- | ------ | ----------------------------------- |
| `req.method` | string | HTTP method |
| `req.url` | string | Full URL |
| `req.host` | string | Host |
| `req.path` | string | Path |
| `req.headers["Name"]` | string | Request header value |
| `req:get_body()` | string | Raw request body (loaded on demand) |
| `req:set_header(name, value)` | - | Set a request header |
| `req:set_body(body)` | - | Replace the request body |
### `res` (response)
| Field / method | Type | Description |
| ----------------------------- | ------ | ------------------------- |
| `res.status_code` | number | HTTP status code |
| `res.headers["Name"]` | string | Response header value |
| `res:get_body()` | string | Raw response body |
| `res:set_header(name, value)` | - | Set a response header |
| `res:set_body(body)` | - | Replace the response body |
### `entry` (history entry)
| Field | Type |
| -------------------- | ---------------------------- |
| `entry.id` | number |
| `entry.method` | string |
| `entry.host` | string |
| `entry.path` | string |
| `entry.status_code` | number |
| `entry.timestamp` | string (YYYY-MM-DD HH:MM:SS) |
| `entry.request_raw` | string |
| `entry.response_raw` | string |
## Utility functions
```lua
-- Log a message to logs.log (prefixed with the plugin name)
log("message")
-- Send a notification bubble in the TUI
notif("Title", "Body text")
-- Create a finding (shown on the Findings page, persisted in DB)
create_finding({
title = "API Key Found",
description = "Markdown description of the finding...",
key = "stable-unique-id", -- used for deduplication; defaults to title
severity = "high", -- info | low | medium | high | critical
})
-- Check if a URL matches the current scope (whitelist/blacklist)
local ok = is_in_scope("https://example.com/api/v1")
-- Quit the app (useful for startup checks that fail)
quit("reason message")
```
### Finding deduplication
A finding is identified by `(plugin_name, key)`. If a finding with that pair already exists in the database it will **not** be re-created, even across restarts. If the user **dismisses** a finding it is permanently hidden and will never reappear, even if the plugin generates it again.
## Configuration
Each plugin gets a **config textarea** on the Plugins page. The raw text is passed as-is to `on_start(config_text)`. Parse it however you like (line by line, key=value, JSON, etc.).
## Sync vs async
- **`sync = true`**: spilltea waits for the hook to return before continuing. For `on_request`/`on_response` this blocks the proxy goroutine; the hook can return one of the values below.
- **`sync = false`** (or omitted for supported hooks): the hook runs in a background goroutine. Return values are ignored. Use this for analysis and findings.
### Return values for `on_request` and `on_response` (sync only)
| Return value | Effect |
| ------------ | ------ |
| `"drop"` | The flow is dropped immediately and never shown in the intercept panel. |
| `"forward"` | The flow is forwarded immediately without going through the intercept panel. |
| `nil` | Normal behaviour: the flow appears in the intercept panel for the user to decide. |
The `sync` declaration is only meaningful for `on_request` and `on_response`. The other hooks have fixed behaviour:
- `on_start` is **always synchronous**: plugins are initialised one by one before the first request is accepted.
- `on_quit` is **always synchronous**: the app waits for all `on_quit` hooks before exiting.
- `on_history_entry` is **always asynchronous**.
> A sync `on_request` or `on_response` hook that hangs will block traffic for that flow. There is no automatic timeout.
+9
View File
@@ -0,0 +1,9 @@
## Configuring your browser's proxy settings
We recommend installing **FoxyProxy** to manage your browser's proxies.
You can install it from the [Google Chrome extension store](https://chromewebstore.google.com/) or from the [Firefox extension store](https://addons.mozilla.org/en-US/firefox/extensions)
1. Open FoxyProxy's options, then click on `Add New Proxy` button.
2. Click the "Manual Proxy Configuration" radio button. Set the "HTTP Proxy" field to `{{.Cfg.App.Host}}` and the "Port" field to `{{.Cfg.App.Port}}`. Click "Save".
3. Forward traffic to Spilltea by selecting the new proxy in FoxyProxy's extension button.
4. You're all set! You can now use Spilltea.
+19
View File
@@ -0,0 +1,19 @@
## Scopes
Scopes let you control which requests Spilltea intercepts. Patterns are Go regular expressions matched against `host/path` (e.g. `api.example.com/v1/users`).
- **Whitelist**: if non-empty, only matching requests are intercepted.
- **Blacklist**: matching requests are always ignored, even if whitelisted.
When both lists are set, a request must pass the whitelist _and_ not be in the blacklist.
### Examples
| Pattern | Matches |
| -------------------------- | ----------------------------------- |
| `example\.com` | any request to `example.com` |
| `^api\.example\.com` | only the `api` subdomain |
| `example\.com/api/v2` | a specific path prefix |
| `\.(js\|css\|png\|woff2?)` | static assets (useful in blacklist) |
| `googleapis\.com` | all Google API traffic |
| `/graphql$` | any host with a `/graphql` endpoint |
+26
View File
@@ -0,0 +1,26 @@
-- Inject a custom header into every request.
-- Config format (one per line): Header-Name: value
Plugin = {
name = "Inject Header",
on_request = { sync = true },
}
local headers = {}
function on_start(config_text)
for line in config_text:gmatch("[^\n]+") do
local name, value = line:match("^([^:]+):%s*(.+)$")
if name and value then
table.insert(headers, { name = name, value = value })
end
end
log("loaded " .. #headers .. " header(s)")
end
function on_request(req)
for _, h in ipairs(headers) do
req:set_header(h.name, h.value)
end
return "forward"
end
+48
View File
@@ -0,0 +1,48 @@
-- Check that the proxy's outbound IP is in the whitelist before starting.
-- Config: one allowed IP per line. Leave empty to disable the check.
Plugin = {
name = "IP Whitelist",
on_start = {},
}
function on_start(config_text)
local allowed = {}
for line in config_text:gmatch("[^\n]+") do
local ip = line:match("^%s*(.-)%s*$")
if ip ~= "" then
table.insert(allowed, ip)
end
end
if #allowed == 0 then
log("no IPs configured, skipping check")
return
end
-- Fetch the current outbound IP via a public API.
local ok, result = pcall(function()
local handle = io.popen("curl -sf https://api.ipify.org 2>/dev/null")
if not handle then return nil end
local ip = handle:read("*a")
handle:close()
return ip and ip:match("^%s*(.-)%s*$") or nil
end)
if not ok or not result or result == "" then
log("could not determine outbound IP, skipping check")
return
end
log("outbound IP: " .. result)
for _, ip in ipairs(allowed) do
if result == ip then
log("IP " .. result .. " is whitelisted")
return
end
end
notif("IP Whitelist", "Outbound IP " .. result .. " is NOT in the whitelist!")
quit("outbound IP " .. result .. " not whitelisted")
end
+35
View File
@@ -0,0 +1,35 @@
-- Scan response bodies for common API key / secret patterns.
-- Runs asynchronously so it never delays traffic.
Plugin = {
name = "Secret Finder",
on_response = { sync = false },
}
local PATTERNS = {
{ pattern = "AIza[0-9A-Za-z%-_]{35}", label = "Google API Key" },
{ pattern = "AKIA[0-9A-Z]{16}", label = "AWS Access Key" },
{ pattern = "sk%-[a-zA-Z0-9]{20,}", label = "OpenAI API Key" },
{ pattern = "ghp_[a-zA-Z0-9]{36}", label = "GitHub Personal Token" },
{ pattern = "Bearer%s+[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+%.[a-zA-Z0-9%-_%.]+",
label = "JWT Bearer Token" },
}
function on_response(req, res)
local body = res:get_body()
if body == "" then return end
for _, p in ipairs(PATTERNS) do
if body:find(p.pattern) then
local key = p.label .. ":" .. req.host
create_finding({
title = p.label .. " in response",
description = "**Host:** `" .. req.host .. "`\n\n" ..
"**Path:** `" .. req.path .. "`\n\n" ..
"Pattern `" .. p.pattern .. "` matched in the response body.",
key = key,
severity = "high",
})
end
end
end
+28
View File
@@ -0,0 +1,28 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}