Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ concurrency:
jobs:
# What changed, so the expensive job can be skipped when nothing it covers did.
changes:
runs-on: ubuntu-latest
# Pinned rather than `ubuntu-latest`: that label moves to Ubuntu 26 in October 2026, and every run has
# been carrying an annotation saying so. A named image means the move happens when we make it, with a
# green run to prove it, rather than on a date somebody else chose. macOS is already pinned the same way.
runs-on: ubuntu-24.04
outputs:
app: ${{ steps.what.outputs.app }}
steps:
Expand Down Expand Up @@ -64,7 +67,10 @@ jobs:
# Seconds of Python, so it runs on everything: the site is generated from Swift sources and project.yml as well
# as from site/, and "docs/ is out of date" should be caught whichever of those moved.
site:
runs-on: ubuntu-latest
# Pinned rather than `ubuntu-latest`: that label moves to Ubuntu 26 in October 2026, and every run has
# been carrying an annotation saying so. A named image means the move happens when we make it, with a
# green run to prove it, rather than on a date somebody else chose. macOS is already pinned the same way.
runs-on: ubuntu-24.04
steps:
# Full history: the sitemap dates each page from the commit that last touched its source, and a shallow
# clone can only see one commit, so every page would look as though it changed today.
Expand All @@ -80,6 +86,13 @@ jobs:
exit 1
}

# The build is a template renderer: it cannot fail on a placeholder that did not render, a link to a
# section that exists in English only, a font that came back from Google, or a roadmap still promising
# what you can already download. Each of those shipped once. check_site.py is where they go to stay
# fixed, and it runs on every branch — these are mistakes to catch before the release, not at the merge.
- name: Check the built site
run: python3 scripts/check_site.py --ci

# The site is served from docs/ on main, and its Download button points at
# releases/latest/download/Flowlight.dmg. So a main that announces a version before the release exists
# sends people to the previous DMG while telling them a newer one is out. Version work belongs on a
Expand All @@ -100,6 +113,20 @@ jobs:
exit 1
fi

# The same claim lives in README's "Shipped:" list, where it is a ticked checkbox rather than a
# download button — so it went unnoticed that it credited a version nobody could install. Anything
# ticked has to exist.
bad=0
while read -r version; do
[ -z "$version" ] && continue
if [ "$version" != "$released" ] && \
[ "$(printf '%s\n%s\n' "$version" "$released" | sort -V | tail -1)" = "$version" ]; then
echo "::error file=README.md::README ticks ($version) as shipped, but the newest release is $released."
bad=1
fi
done <<< "$(sed -n '/^Shipped:/,/^Planned/p' README.md | grep -o '([0-9]\+\.[0-9]\+\.[0-9]\+)' | tr -d '()')"
[ "$bad" -eq 0 ] || exit 1

test:
needs: changes
if: needs.changes.outputs.app == 'true'
Expand Down
94 changes: 94 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# CodeQL code scanning.
#
# This exists as a workflow rather than as GitHub's default setup for one reason: the Xcode project is not
# committed. XcodeGen writes it from project.yml, so `autobuild` looked for a project or workspace, found
# neither, and gave up with `no-project-found`. The reaction to that failure was to drop Swift from the
# languages being scanned, which left the one language worth scanning in a tool that reads other people's
# network traffic unscanned. Swift is built here explicitly instead.
#
# Requires GitHub's default setup to be turned OFF for this repository (Settings › Code security › Code
# scanning), because the two configurations cannot both be active.
name: CodeQL

on:
push:
branches: [main]
pull_request:
schedule:
# Weekly, so a new query release finds old code. Wednesday early UTC: no contention with release runs.
- cron: "17 4 * * 3"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
analyze:
name: analyze (${{ matrix.language }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
security-events: write # the only reason this workflow needs more than read
contents: read
strategy:
fail-fast: false
matrix:
include:
# Swift has to be built, and only a macOS runner can. See the header.
- language: swift
runner: macos-15
build-mode: manual
# The site generator, the packaging scripts and the workflows themselves. Nothing to build.
- language: python
runner: ubuntu-24.04
build-mode: none
- language: actions
runner: ubuntu-24.04
build-mode: none
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0

# Everything that is not the compilation happens before init, and deliberately so. Once init has run,
# CodeQL traces the job by preloading an x86_64 libtrace.dylib through DYLD_INSERT_LIBRARIES, which puts
# every child process under Rosetta 2 — and Homebrew refuses to install an x86_64 build into the ARM
# prefix: "Cannot install under Rosetta 2 in ARM default prefix". Generating the project needs no tracing
# anyway; only the compiler does.
- name: Prepare the project
if: matrix.build-mode == 'manual'
run: |
set -euo pipefail
# Newest Xcode only. CI's test job covers the oldest-supported compiler; this build exists to give
# CodeQL something to trace, and tracing it twice would double the cost for the same findings.
sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)"
xcodebuild -version
brew install xcodegen
xcodegen generate

- uses: github/codeql-action/init@7999b86c43a865dc79d8923397f35af22de63401 # v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# security-extended over the default suite: this app holds decrypted request bodies and a CA key, so
# the extra precision-medium queries are worth the false positives they bring.
queries: security-extended

- name: Build for analysis
if: matrix.build-mode == 'manual'
run: |
set -euo pipefail
# Signing off, as in the test job: analysis needs the compilation, not a runnable app. `clean build`
# rather than `build`, because CodeQL only sees the files the compiler actually recompiles and an
# incremental build would hand it a fraction of the sources.
xcodebuild clean build \
-project Flowlight.xcodeproj \
-scheme Flowlight \
-destination 'platform=macOS' \
CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= CODE_SIGN_ENTITLEMENTS=

- uses: github/codeql-action/analyze@7999b86c43a865dc79d8923397f35af22de63401 # v4
with:
category: /language:${{ matrix.language }}
11 changes: 10 additions & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ concurrency:

jobs:
deploy:
runs-on: ubuntu-latest
# Pinned rather than `ubuntu-latest`: that label moves to Ubuntu 26 in October 2026, and every run has
# been carrying an annotation saying so. A named image means the move happens when we make it, with a
# green run to prove it, rather than on a date somebody else chose. macOS is already pinned the same way.
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
Expand All @@ -34,6 +37,12 @@ jobs:

- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0

# CI checks this on every pull request, but this workflow is the one that actually puts the site in front
# of people, and a deploy is not something you can take back from a cache. Seconds of Python for the
# guarantee that nothing broken is ever served.
- name: Check the site before serving it
run: python3 scripts/check_site.py --ci

# docs/ is committed and CI already checks it matches a fresh build, so there is nothing to build here.
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
Expand Down
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ Internals are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
- **Translate the website.** Copy `site/i18n/en.json` to `site/i18n/<code>.json` and translate the values — that
is the header, the footer and the 404 page. Then copy the pages you want from `site/pages/` into
`site/pages/<code>/`, keeping the file names and the markup, and translate the text. `scripts/build-site.sh`
publishes them under `/<code>/`, adds the `hreflang` links and the entry in the language menu, and leaves
anything you haven't translated pointing at the English page. Reuse the words the app already uses, from
publishes them under `/<code>/`, adds the `hreflang` links and the entry in the language menu, leaves
anything you haven't translated pointing at the English page, and then checks the result — an unrendered
placeholder, a dead link, a heading anchor that only exists in English. Reuse the words the app already uses, from
`Flowlight/Localization/<code>.lproj/Localizable.strings`.
- **Name another protocol.** Add the port to `ProtocolCatalog`, give it a category, and, if its first bytes are
distinctive, add a detector in `ProtocolClassifier.swift`. `testExtendedProtocolCoverage` checks that every named
Expand Down
36 changes: 14 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,14 @@ in exchange, traffic from before you enabled it isn't there and some system traf
Storage rolls per-second rows into minute, hour and day tables. Week, month and year views read the daily table,
so a year of history stays fast.

## Monitoring, not prevention

Flowlight tells you what software on your Mac is sending and where. It is not a defence against an attacker who already
controls the machine: anything running as root can unload its extension or stop the app. Blocking is real — the Network
Extension refuses the connection before it is made — but it is control over software you already trust rather than a
barrier against software you don't, and an agent that can run a shell can do by hand what a refused tool would have done.
The [threat model](https://flowlight.xinbetween.com/threat-model/) says what is defended, what is not, and why.

## Honest limitations

- **TCP and UDP only.** That covers essentially all app traffic, but ICMP (ping) and other raw-IP protocols aren't attributed.
Expand Down Expand Up @@ -413,31 +421,15 @@ Shipped:
- [x] Mock responses in HTTPS inspection (from 0.3.1)
- [x] Export to OpenTelemetry / SIEM (from 0.3.2)
- [x] Runs quietly in the background: menu-bar-only mode, window state, a calmer first run (0.2.7 and 0.3.3)
- [x] Coverage: what Flowlight can't account for, per app (0.9.0)
- [x] Inspect an app or destination from the row you found it on (0.9.1)
- [x] A threat model, a security contact, and fonts served from our own domain (website and documentation;
no app release of its own, since the app itself did not change)
- [x] Build, sign, notarize and publish from CI ([release workflow](.github/workflows/release.yml); see
[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#releasing-from-ci) for the secrets it needs and what they cost)

Planned, in order:

- **0.9.0 — Coverage, stated rather than assumed.** Flowlight says it sees every connection, and then the honest
paragraphs elsewhere explain the exceptions: traffic from before the filter started, system services exempt
from content filters, a Mac where another content filter owns the slot, apps that pin their certificates, QUIC
the proxy never sees, and an agent's local MCP server talking over a pipe. Today you have to know all of that
to read the screens correctly. A coverage view would say it per app: watched by the extension, decrypted by
the proxy, named only by DNS or SNI, or not covered at all — and *why*, with the fix where there is one. It is
the difference between a tool that claims completeness and one that can be trusted about its own limits.
- **0.9.1 — Inspect it from where you found it.** Seeing in Live that an agent talked to a host and wanting to
know what it said is the commonest move there is, and it meant changing screen, remembering the name and
typing it into a search field. Right-click any row in Live, Reports or AI Agents and Inspect opens already
looking at that app or destination. The subject lands in the search field rather than a hidden filter, so it
stays visible and can be widened or dropped without going back.
- **0.9.2 — A threat model, written down.** What Flowlight defends against, and what it does not, on one page:
software on this Mac behaving badly in the open; not software with root, which can switch the filter off.
A signed update from someone who is not us — which 0.8.2 closed. A VPN or security agent holding the content
filter slot. Certificate pinning, QUIC, and anything routed around the proxy. The product language should say
**monitoring, not prevention** beside every mention of blocking, because "block" invites a guarantee the
design cannot make. Alongside it, a security contact address and key for people who can't or won't use
GitHub's private reporting, and the web fonts served from this domain rather than Google's — a local-first
tool shouldn't make a third-party request to render its own privacy page.
- **0.9.3 — A budget for inspection.** HTTPS inspection records request and response bodies for three days, and
that is the most sensitive thing Flowlight ever holds. The redaction that protects it is a guess at which
headers carry credentials — improved in 0.8.2, still a guess. This turns it into something with limits you
Expand All @@ -453,7 +445,7 @@ Planned, in order:
is encrypted, exactly which fields travel and whether any of them are headers — the same disclosure Flowlight
demands of everything else it watches, applied to itself.

- **1.0 — What an agent did besides the network.** Flowlight can say that Claude Code uploaded 40 MB to an
- **1.0 — What an agent did off the network.** Flowlight can say that Claude Code uploaded 40 MB to an
unfamiliar host. It cannot say that it read `~/.ssh/id_rsa` first, and that second half is the exfiltration
story: a file leaving is a read followed by a send, and only the send is visible today. This would record
what an agent's tools opened, wrote and spawned, and line it up against the connections that followed —
Expand All @@ -466,7 +458,7 @@ Planned, in order:
**Two things gate it, both stated here rather than discovered later.** Per-process file attribution on macOS
needs the EndpointSecurity framework, whose entitlement Apple grants on request and review — not a
checkbox — and which only works from a system extension. And it would put far more sensitive material in
Flowlight's hands than network metadata ever did, so the inspection budget in 0.9.2 stops being a nicety and
Flowlight's hands than network metadata ever did, so the inspection budget in 0.9.3 stops being a nicety and
becomes the thing that has to exist first. A tool that asks you not to monitor it has to deserve that twice
over when it can see every file you open.

Expand Down
49 changes: 49 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Security

Flowlight watches what other software on a Mac sends, and with HTTPS inspection on it can read request and
response bodies. That makes a bug in it worth more than a bug in most apps, so reports are welcome and taken
seriously.

## Reporting

- **[Private vulnerability reporting](https://github.com/xinbetween/flowlight/security/advisories/new)** on
this repository. Preferred: it keeps the report, the fix and the advisory in one place.
- **security@xinbetween.com**, if you would rather not use GitHub, or cannot.

Please don't open a public issue for something exploitable. Anything else — a crash, a wrong number, a
misleading screen — belongs in the normal issue tracker.

## What to expect

A first reply within three working days, and an honest assessment rather than a holding message: whether it
reproduces, whether it is a vulnerability or a bug, and what the fix looks like. If a report is not a
vulnerability, we will say so and why rather than leaving it in a queue.

Fixes ship in the next release, and sooner where one is warranted. You will be credited in the release notes
unless you would rather not be.

## Scope

In scope: anything that lets software on the Mac read data Flowlight holds, escape the limits stated in the
documentation, tamper with an update, or make Flowlight report something false — a connection it did not show,
an agent it attributed to the wrong process, a rule it said was enforcing when it was not.

Out of scope, because they are already true by design and documented:

- **Software running as root can switch Flowlight off.** It is a monitor, not a defence against an attacker
who already owns the machine.
- **A content filter does not see everything.** Another filter can hold the one slot macOS offers, system
traffic is exempt, and traffic from before capture started never existed as far as Flowlight is concerned.
- **HTTPS inspection only reads what is routed through the proxy.** An app that ignores proxy settings or pins
its certificates is passed through untouched, by design.
- **Blocking is not prevention.** A refused connection is refused at the network layer; an agent that can run
a shell can do by hand what the refused tool would have done.

The [threat model](https://flowlight.xinbetween.com/threat-model/) says all of this at more length, including
what Flowlight does defend against.

## Signed releases

Releases are signed with a Developer ID and notarized by Apple, and every release publishes `SHA256SUMS.txt`.
The in-app updater refuses a download whose checksum it cannot verify, and refuses to install an app that is
not signed by the same team as the copy asking for the update.
Loading