Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d62da8adb4 |
@@ -23,6 +23,35 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once
|
|||||||
that is settled. The newest entries may not be published yet; the manifest
|
that is settled. The newest entries may not be published yet; the manifest
|
||||||
says which version is. Older entries are not marked.
|
says which version is. Older entries are not marked.
|
||||||
|
|
||||||
|
## 0.54.0
|
||||||
|
|
||||||
|
- **Every audit row also goes to the system log.** The rows written to
|
||||||
|
`mgmt_audit.jsonl`, `pairing_audit.jsonl` and each pairing's `audit.jsonl`
|
||||||
|
(terminals, root commands, store ops, op runs) are also written to the
|
||||||
|
machine's system log, so a log collector can keep them where no host can
|
||||||
|
write: syslog facility `auth`, tag `privatae-bastion-audit` (the journal on
|
||||||
|
systemd) on Linux and macOS, and the Application event log, source
|
||||||
|
`PrivataeBastionAudit`, on Windows, registered when the system service is
|
||||||
|
installed and removed with it. Each line is the file's row with `audit`
|
||||||
|
(the file) and `pairing` added, and a root command's text left out (its
|
||||||
|
digest and length stay). The lines go through a bounded queue, so a slow or
|
||||||
|
stuck system log never holds up the audited op; a full queue drops lines and
|
||||||
|
the next line carries `system_log_dropped`. The file stays the record.
|
||||||
|
- **A server is networked and anchored by default.** `install.sh` run as root
|
||||||
|
now installs the networked bastion (systemd on Linux, launchd on macOS);
|
||||||
|
`--local` keeps a root install local, a reinstall over a machine already
|
||||||
|
paired in local mode keeps it local unless `--networked` is passed, and run
|
||||||
|
as your own user it is local as before. On a networked machine with no anchor, the first `pair` run with
|
||||||
|
machine authority anchors it to that host. Before, such a machine refused
|
||||||
|
every op until `claim-anchor` was run by hand; now it is anchored at once:
|
||||||
|
every terminal and every write waits for the person's approval, and any
|
||||||
|
later host for the anchor's. `pair` then says to
|
||||||
|
enrol the second factor. `claim-anchor` with no token on a machine already
|
||||||
|
anchored to that host now succeeds and changes nothing (an installer that
|
||||||
|
anchors after pairing finds the work done); naming another host is still
|
||||||
|
refused without `--rotate`. The Windows installer is unchanged: `-Networked`
|
||||||
|
as before.
|
||||||
|
|
||||||
## 0.53.2
|
## 0.53.2
|
||||||
|
|
||||||
- **No change to what the bastion does.** Two code comments named source files
|
- **No change to what the bastion does.** Two code comments named source files
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
[](#supported-platforms)
|
[](#supported-platforms)
|
||||||
[](https://git.privatae.ai/privatae/privatae-bastion/actions)
|
[](https://git.privatae.ai/privatae/privatae-bastion/actions)
|
||||||
|
|
||||||
**One static Go binary that turns your own machine into a bastion in your Privatae network.**
|
**One static Go binary that gives your AI Host access to a machine without SSH keys: it dials out, checks every signed grant on the machine itself, and keeps its own audit log.**
|
||||||
|
|
||||||
No Docker, no Python, no database, no separate WireGuard install. You pair the
|
No Docker, no Python, no database, no separate WireGuard install. You pair the
|
||||||
binary with a short token from your host, and from then on your host can reach
|
binary with a short token from your host, and from then on your host can reach
|
||||||
@@ -20,23 +20,26 @@ binary can be rebuilt from this source and compared
|
|||||||
([Verify a release](#verify-a-release)). See [CHANGELOG.md](CHANGELOG.md).
|
([Verify a release](#verify-a-release)). See [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# macOS / Linux (a home machine, running as you)
|
# A server (macOS / Linux, as root): networked, anchored to the first host you pair
|
||||||
curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
|
curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
|
||||||
privatae-bastion pair <token> # token from your host's "Set up a bastion"
|
sudo -H privatae-bastion pair <token> # token from your host's "Set up a bastion"
|
||||||
privatae-bastion run # tunnel + lanes up
|
sudo -H privatae-bastion stepup-enroll # your second factor, so you can approve here
|
||||||
privatae-bastion add-folder ~/docs # hand a folder to your network; you approve on your host
|
|
||||||
|
# Your own computer (macOS / Linux, as you)
|
||||||
|
curl -fsSL https://privatae.ai/bastion/install.sh | sh
|
||||||
|
privatae-bastion pair <token>
|
||||||
|
privatae-bastion run # tunnel + lanes up
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Windows
|
# Windows, your own computer
|
||||||
irm https://privatae.ai/bastion/install.ps1 | iex
|
irm https://privatae.ai/bastion/install.ps1 | iex
|
||||||
privatae-bastion pair <token>
|
privatae-bastion pair <token>
|
||||||
privatae-bastion run
|
privatae-bastion run
|
||||||
```
|
```
|
||||||
|
|
||||||
On a Linux server installed as root, the installer also enables a service that
|
A Windows server installs from an Administrator PowerShell with `-Networked`.
|
||||||
runs the daemon for you, so it is `sudo -H privatae-bastion pair <token>` and
|
See [Install](#install).
|
||||||
nothing else. See [Install](#install).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ nothing else. See [Install](#install).
|
|||||||
- [Install](#install)
|
- [Install](#install)
|
||||||
- [Quick start](#quick-start)
|
- [Quick start](#quick-start)
|
||||||
- [Local and networked modes](#local-and-networked-modes)
|
- [Local and networked modes](#local-and-networked-modes)
|
||||||
|
- [Replacing an agent's SSH keys](#replacing-an-agents-ssh-keys)
|
||||||
- [A Host on this machine](#a-host-on-this-machine)
|
- [A Host on this machine](#a-host-on-this-machine)
|
||||||
- [What the bastion does](#what-the-bastion-does)
|
- [What the bastion does](#what-the-bastion-does)
|
||||||
- [Command reference](#command-reference)
|
- [Command reference](#command-reference)
|
||||||
@@ -80,41 +84,59 @@ trust, and why.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
The installer downloads the binary for your OS and architecture, verifies its
|
There are two ways to run the bastion, and the installer picks by who runs it.
|
||||||
SHA-256 against the release manifest, and only then moves it into place. A
|
|
||||||
failed check installs nothing.
|
|
||||||
|
|
||||||
**macOS / Linux**
|
**On a server** (run the installer as root). The bastion is installed as the
|
||||||
|
machine's system service in **networked** mode (Linux with systemd, or macOS;
|
||||||
|
on Linux without systemd a root install stays local, with no service), and
|
||||||
|
the first host you pair becomes its **anchor**. From then on, every terminal and every write on the
|
||||||
|
machine waits for your approval, and any other host that pairs waits for your
|
||||||
|
host to approve it.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
|
curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
|
||||||
|
sudo -H privatae-bastion pair <token> # token: your host's Network app → Set up a bastion
|
||||||
|
sudo -H privatae-bastion stepup-enroll # your second factor, so you can approve here
|
||||||
```
|
```
|
||||||
|
|
||||||
On Linux, running the installer as root also installs an always-on `systemd`
|
On Windows, from an Administrator PowerShell:
|
||||||
service (`privatae-bastion.service`), so a server is onboarded in two commands:
|
|
||||||
install, then `sudo -H privatae-bastion pair <token>`.
|
|
||||||
|
|
||||||
**Windows (PowerShell)**
|
```powershell
|
||||||
|
& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Networked
|
||||||
|
privatae-bastion pair <token>
|
||||||
|
privatae-bastion stepup-enroll
|
||||||
|
```
|
||||||
|
|
||||||
|
**On your own computer** (run the installer as yourself). The bastion runs
|
||||||
|
as your user in **local** mode and serves one host. The pairing's
|
||||||
|
capabilities are the authorization, and approvals work once you enrol a
|
||||||
|
second factor.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsSL https://privatae.ai/bastion/install.sh | sh
|
||||||
|
privatae-bastion pair <token>
|
||||||
|
privatae-bastion run
|
||||||
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
irm https://privatae.ai/bastion/install.ps1 | iex
|
irm https://privatae.ai/bastion/install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
**Networked bastion** (a shared machine serving several hosts, see
|
A root install that should stay local passes `--local`
|
||||||
[modes](#local-and-networked-modes)):
|
(`… | sudo sh -s -- --local`). Reinstalling over a machine already paired in
|
||||||
|
local mode keeps it local unless you pass `--networked`. [Local and networked modes](#local-and-networked-modes)
|
||||||
|
sets out the difference, and [Replacing an agent's SSH keys](#replacing-an-agents-ssh-keys)
|
||||||
|
how to run a fleet of servers this way.
|
||||||
|
|
||||||
```sh
|
The installer downloads the binary for your OS and architecture, checks its
|
||||||
curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh -s -- --networked
|
SHA-256 against the release manifest, and only then moves it into place; a
|
||||||
```
|
failed check installs nothing. That manifest comes from the same place as the
|
||||||
|
binary. To check its signature too, see [Verify a release](#verify-a-release).
|
||||||
|
|
||||||
```powershell
|
A networked install runs as the operating system's own service, whoever is
|
||||||
& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Networked
|
signed in, and only the machine's administrator can stop it:
|
||||||
```
|
|
||||||
|
|
||||||
A networked install runs as the operating system's own service, so it runs
|
- **Linux:** the `systemd` unit `privatae-bastion.service`, as root.
|
||||||
whoever is signed in, and only the machine's administrator can stop it:
|
|
||||||
|
|
||||||
- **Linux:** the `systemd` unit above, as root.
|
|
||||||
- **macOS:** a `launchd` daemon (`ai.privatae.bastion`) running as root. The
|
- **macOS:** a `launchd` daemon (`ai.privatae.bastion`) running as root. The
|
||||||
program goes in `/Library/Application Support/privatae/bin`, which only root
|
program goes in `/Library/Application Support/privatae/bin`, which only root
|
||||||
can change, with a link on `PATH`.
|
can change, with a link on `PATH`.
|
||||||
@@ -143,6 +165,9 @@ manifest's sha256, made on the key holder's own computer with
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
On your own computer. For a server, see [Install](#install): the installer
|
||||||
|
run as root sets it up networked, and `run` is the service's job.
|
||||||
|
|
||||||
1. In your Privatae host, choose **Set up a bastion**. It gives you a one-time
|
1. In your Privatae host, choose **Set up a bastion**. It gives you a one-time
|
||||||
pairing token.
|
pairing token.
|
||||||
2. Pair this machine. The binary generates its own keys locally and pulls only
|
2. Pair this machine. The binary generates its own keys locally and pulls only
|
||||||
@@ -152,7 +177,16 @@ manifest's sha256, made on the key holder's own computer with
|
|||||||
privatae-bastion pair <token>
|
privatae-bastion pair <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Bring the tunnel and lanes up.
|
3. Enrol a second factor on this machine, straight away. Until you do, folder grants and
|
||||||
|
file moves are gated by the signed grant alone (the host verifies its own
|
||||||
|
factor), and model downloads and root shells are refused outright. After
|
||||||
|
you do, every one of those waits for your approval here.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
privatae-bastion stepup-enroll
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Bring the tunnel and lanes up.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
privatae-bastion run
|
privatae-bastion run
|
||||||
@@ -161,22 +195,13 @@ manifest's sha256, made on the key holder's own computer with
|
|||||||
Each paired host gets its own loopback control port, starting at
|
Each paired host gets its own loopback control port, starting at
|
||||||
`127.0.0.1:7878`. One daemon serves every host you pair.
|
`127.0.0.1:7878`. One daemon serves every host you pair.
|
||||||
|
|
||||||
4. Check on it, or open the local console.
|
5. Check on it, or open the local console.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
privatae-bastion status
|
privatae-bastion status
|
||||||
privatae-bastion ui
|
privatae-bastion ui
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Enrol a second factor on this machine. Until you do, folder grants and
|
|
||||||
file moves are gated by the signed grant alone (the host verifies its own
|
|
||||||
factor), and model downloads and root shells are refused outright. After
|
|
||||||
you do, every one of those waits for your approval here.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
privatae-bastion stepup-enroll
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Share a folder. Your files stay on this machine. The host keeps only an
|
6. Share a folder. Your files stay on this machine. The host keeps only an
|
||||||
index, and you confirm the share on the host with your code.
|
index, and you confirm the share on the host with your code.
|
||||||
|
|
||||||
@@ -186,18 +211,23 @@ manifest's sha256, made on the key holder's own computer with
|
|||||||
|
|
||||||
## Local and networked modes
|
## Local and networked modes
|
||||||
|
|
||||||
| | **local** (default) | **networked** |
|
| | **local** | **networked** |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Who it serves | one host | several hosts on one shared machine |
|
| Where | your own computer | a server, or a machine several people share |
|
||||||
|
| Installed by | the installer run as yourself | the installer run as root (Windows: `-Networked`), or `sudo -H privatae-bastion set-mode networked` |
|
||||||
| Runs as | your user is fine | the system's service: root under systemd or launchd, LocalSystem on Windows |
|
| Runs as | your user is fine | the system's service: root under systemd or launchd, LocalSystem on Windows |
|
||||||
|
| A terminal the host opens | opens on the pairing's capabilities | waits for your approval, for that one session |
|
||||||
|
| A write (an op, a ship step) | runs on the pairing's capabilities | waits for your approval, bound to that op and its arguments |
|
||||||
| Second pairing | refused | waits for the **anchor** host's approval |
|
| Second pairing | refused | waits for the **anchor** host's approval |
|
||||||
| Set with | nothing to do | `install.sh --networked` or `sudo -H privatae-bastion set-mode networked` |
|
| The anchor | none | the first host paired, or `claim-anchor` |
|
||||||
|
|
||||||
In networked mode one host is the **anchor**: the one host that approves every
|
In networked mode one host is the **anchor**: the one host that approves every
|
||||||
later pairing on this machine. Claiming the anchor requires root, because there
|
later pairing on this machine. Claiming the anchor requires root, because there
|
||||||
is no authority above the box to ask. The ordinary flow is `pair`, then
|
is no authority above the box to ask. On a networked machine with no anchor,
|
||||||
`set-mode networked`, then `claim-anchor` with no token: it is a choice among
|
the first `pair` run as root anchors it to that host by itself. A machine
|
||||||
pairings the box already holds. A token is needed only to name a host this box
|
paired first and switched to networked afterwards is anchored with
|
||||||
|
`claim-anchor` and no token: it is a choice among pairings the box already
|
||||||
|
holds, and running it again for the same host changes nothing. A token is needed only to name a host this box
|
||||||
does not serve yet (a fresh machine, or `--rotate`). The anchor can be moved to
|
does not serve yet (a fresh machine, or `--rotate`). The anchor can be moved to
|
||||||
another host only with the current anchor's signed consent, or after 14 days of
|
another host only with the current anchor's signed consent, or after 14 days of
|
||||||
silence from it.
|
silence from it.
|
||||||
@@ -213,6 +243,38 @@ sudo -H privatae-bastion claim-owner <name> # state
|
|||||||
`claim-owner` is optional. The name shows on this machine's card in every host
|
`claim-owner` is optional. The name shows on this machine's card in every host
|
||||||
it serves, and it grants nothing anywhere.
|
it serves, and it grants nothing anywhere.
|
||||||
|
|
||||||
|
## Replacing an agent's SSH keys
|
||||||
|
|
||||||
|
An agent with an SSH key has a shell on every server the key opens, and the
|
||||||
|
server's log says only that the key was used. Paired through the bastion, the
|
||||||
|
agent holds no key that opens anything: each server dials out to its host,
|
||||||
|
decides what the host may do, and records every decision.
|
||||||
|
|
||||||
|
For a fleet of servers:
|
||||||
|
|
||||||
|
1. **Install each server as networked** (the installer as root) and pair it
|
||||||
|
to your host, which anchors it there. Enrol a second factor on each one.
|
||||||
|
2. **Make one approval device** that is not the host's machine
|
||||||
|
(`device-init` on your own computer, `pin-device <key>` on each server).
|
||||||
|
Approvals then come from it, in each server's own words.
|
||||||
|
3. **Start with reads.** Observe ops (`observe:*`) are read-only and give the
|
||||||
|
host the view of each server it needs.
|
||||||
|
4. **Turn recurring jobs into ops**, not shell sessions. An op is a program
|
||||||
|
your host wrote, run in a sandbox first; you approve its terms once, with
|
||||||
|
bounds, an expiry and a use count (see [Ops](#ops-programs-your-host-wrote-run-in-a-sandbox-first)).
|
||||||
|
A change across several servers is one [ship plan](#ship-plans-one-approval-for-a-whole-ship):
|
||||||
|
one approval, and each server runs only its own lines.
|
||||||
|
5. **Keep terminals off** where nobody needs them:
|
||||||
|
`sudo -H privatae-bastion switch <host> session off`. That also stops
|
||||||
|
root commands. Do not grant `root-exec` on a server you would not hand a
|
||||||
|
standing root shell.
|
||||||
|
6. **Send the audit somewhere the host cannot write.** Every audit row also
|
||||||
|
goes to the system log (see [Audit](#audit-on-this-machine-and-in-its-system-log));
|
||||||
|
point your log collector at it.
|
||||||
|
|
||||||
|
When the agent cannot do something without a shell, that is the op to write
|
||||||
|
next, not the shell to open.
|
||||||
|
|
||||||
## A Host on this machine
|
## A Host on this machine
|
||||||
|
|
||||||
A Host with no platform (a customer's own server with the Host on it) pairs
|
A Host with no platform (a customer's own server with the Host on it) pairs
|
||||||
@@ -667,7 +729,30 @@ Read-only, and reported so the host can show **why** rather than a verdict.
|
|||||||
(`interactive`, `remote`, `scheduler`, `service`, `agent`) and a typed
|
(`interactive`, `remote`, `scheduler`, `service`, `agent`) and a typed
|
||||||
evidence list with a confidence tier. Tools that drive the machine with no
|
evidence list with a confidence tier. Tools that drive the machine with no
|
||||||
model of their own are inventoried as `automation` and promoted to `agent`
|
model of their own are inventoried as `automation` and promoted to `agent`
|
||||||
only when model traffic joins them.
|
only when model traffic joins them. Each also says who owns the account it
|
||||||
|
runs as: `owner_class` (`domain_user`, `local_user`, `service`, `system`),
|
||||||
|
`owner_admin` and `owner_interactive` (each `yes`, `no` or `unknown`; a
|
||||||
|
group list or token that could not be read is `unknown`, never `no`).
|
||||||
|
- **Declared tool servers.** The files where Claude Desktop, Claude Code,
|
||||||
|
Cursor, VS Code, Windsurf, Codex and Zed declare their MCP servers, per
|
||||||
|
account and for the projects Claude Code lists, read and never written or
|
||||||
|
connected to. Per server: client, scope, path, name, transport, whether it is
|
||||||
|
a local process or a URL (and the URL's host), the launch command truncated
|
||||||
|
like an agent's, the package and whether it is pinned, the **names** of the
|
||||||
|
environment variables it gets, whether a value looked like a secret (the
|
||||||
|
value is dropped where it is read), whether a remote server has
|
||||||
|
authentication configured, and whether it is running, declared but not
|
||||||
|
running, or running with no declaration. A command line keeps a URL's
|
||||||
|
scheme and host only, and every flag, header or `NAME=value` that names a
|
||||||
|
secret is replaced. A file is opened only once it is known to be a regular
|
||||||
|
file (on Windows: a reparse point, directory or device at the path is
|
||||||
|
refused, checked on the open handle), and in an account's home or project
|
||||||
|
only when that account or root owns it (on Windows: the account whose
|
||||||
|
profile it is, SYSTEM or the Administrators group; `files_foreign_owner`
|
||||||
|
counts the rest). The files are read at most every ten minutes, and a read
|
||||||
|
that does not finish holds the beats for at most 20 seconds in total
|
||||||
|
(`timed_out` says it did not finish; until one read has finished, no server
|
||||||
|
is reported).
|
||||||
- **Scheduler inventory.** Crontabs, `systemd` timers, `launchd` jobs and
|
- **Scheduler inventory.** Crontabs, `systemd` timers, `launchd` jobs and
|
||||||
Windows scheduled tasks whose command would run an agent or automation.
|
Windows scheduled tasks whose command would run an agent or automation.
|
||||||
- **Baseline and delta.** `observe:baseline` records what is normal on this
|
- **Baseline and delta.** `observe:baseline` records what is normal on this
|
||||||
@@ -705,8 +790,9 @@ platform **withholds** the capability, so the host can tell a machine that is
|
|||||||
quiet apart from one that is blind.
|
quiet apart from one that is blind.
|
||||||
|
|
||||||
### Heartbeat and self-update
|
### Heartbeat and self-update
|
||||||
Each check-in carries the machine's capabilities, posture, agent scan and
|
Each check-in carries the machine's capabilities, posture, agent scan,
|
||||||
scheduler inventory. The host can direct endpoint rotation and a self-update.
|
scheduler inventory and declared tool servers (the last three only to a
|
||||||
|
pairing granted `observe`). The host can direct endpoint rotation and a self-update.
|
||||||
The update is fetched from the release manifest; the manifest's signature is
|
The update is fetched from the release manifest; the manifest's signature is
|
||||||
checked against the release keys compiled into this binary and the binary's
|
checked against the release keys compiled into this binary and the binary's
|
||||||
digest against the manifest before it is swapped. On an anchored machine a
|
digest against the manifest before it is swapped. On an anchored machine a
|
||||||
@@ -861,6 +947,35 @@ needs infrastructure that is not part of this program, and not built yet:
|
|||||||
services a host reaches when it starts, or replacements for them;
|
services a host reaches when it starts, or replacements for them;
|
||||||
- a place the new host can fetch the backup from, as above.
|
- a place the new host can fetch the backup from, as above.
|
||||||
|
|
||||||
|
### Audit: on this machine and in its system log
|
||||||
|
Every decision the bastion makes is written as one JSON line to an audit
|
||||||
|
file on this machine: what was asked of the management lane, allowed or
|
||||||
|
refused (`mgmt_audit.jsonl`), pairing changes (`pairing_audit.jsonl`), and
|
||||||
|
each pairing's terminals, root commands, store ops and op runs (that
|
||||||
|
pairing's `audit.jsonl`). A host can fetch these files over the tunnel but
|
||||||
|
cannot change or remove them.
|
||||||
|
|
||||||
|
Each line is also written to the machine's own system log, so whatever
|
||||||
|
already collects that log can take it to a store no host can write:
|
||||||
|
|
||||||
|
| System | Where | Name |
|
||||||
|
|---|---|---|
|
||||||
|
| Linux | syslog, facility `auth`; on systemd, the journal | `privatae-bastion-audit` (`journalctl -t privatae-bastion-audit`) |
|
||||||
|
| macOS | syslog | `privatae-bastion-audit` |
|
||||||
|
| Windows | Application event log, event id 1 | source `PrivataeBastionAudit`, registered when the system service is installed |
|
||||||
|
|
||||||
|
The system-log line is the file's line with two fields added: `audit`, which
|
||||||
|
file it went to, and `pairing`, the pairing it belongs to (empty for a
|
||||||
|
machine-wide file). One field is left out: a root command's text, which can
|
||||||
|
hold a password typed on a command line and stays in the file only. The line
|
||||||
|
keeps the command's `command_sha256` and `command_bytes`, so it can still be
|
||||||
|
matched to the file and the transcript.
|
||||||
|
|
||||||
|
The file stays the record. Lines go to the system log through a queue, so a
|
||||||
|
system log that is missing, slow or stuck never holds up the op being
|
||||||
|
audited; if the queue fills, lines are dropped, and the next line queued
|
||||||
|
carries `system_log_dropped`, how many were lost just before it.
|
||||||
|
|
||||||
### Local console
|
### Local console
|
||||||
`privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1`
|
`privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1`
|
||||||
and reachable from nowhere else: pairings, the anchor, containment state, marks
|
and reachable from nowhere else: pairings, the anchor, containment state, marks
|
||||||
@@ -1123,7 +1238,8 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| | `local_host_linux_test.go`: `TestARunSocketOpensOnlyForALiveRunOfThePairing` | No socket is made for a run with no record, another pairing's run or a run the watchdog stopped; another pairing reusing the run_id gets its own. |
|
| | `local_host_linux_test.go`: `TestARunSocketOpensOnlyForALiveRunOfThePairing` | No socket is made for a run with no record, another pairing's run or a run the watchdog stopped; another pairing reusing the run_id gets its own. |
|
||||||
| | `local_host_linux_test.go`: `TestAUnixPairingsRunStillCannotReachThisMachine` | A run a unix pairing owns gets 403 from its egress proxy for loopback, this machine's public address and a name for it. |
|
| | `local_host_linux_test.go`: `TestAUnixPairingsRunStillCannotReachThisMachine` | A run a unix pairing owns gets 403 from its egress proxy for loopback, this machine's public address and a name for it. |
|
||||||
| | `local_host_linux_test.go`: `TestTheOwnersImagePrefix`, `TestALocalImageRunsByItsIDWithoutAPull` | Run-up takes only `<prefix>harness-<engine>@sha256:<digest>` under the owner's prefix, and pull credentials only for its registry. A local image runs by its ID with no pull only when the image with that ID carries a tag of the named repository. |
|
| | `local_host_linux_test.go`: `TestTheOwnersImagePrefix`, `TestALocalImageRunsByItsIDWithoutAPull` | Run-up takes only `<prefix>harness-<engine>@sha256:<digest>` under the owner's prefix, and pull credentials only for its registry. A local image runs by its ID with no pull only when the image with that ID carries a tag of the named repository. |
|
||||||
| No MCP endpoint | `claims_static_test.go`: `TestNoRouteOpOrDependencySpeaksMCP` | Every HTTP route on every listener is in a fixed table and none names MCP; no management op, import, module or string in the code names MCP, an `/mcp` path or JSON-RPC. |
|
| No MCP endpoint | `claims_static_test.go`: `TestNoRouteOpOrDependencySpeaksMCP` | Every HTTP route on every listener is in a fixed table and none names MCP; no management op, import or module names MCP; no string in the code is a URL or route with an `/mcp` path, or names JSON-RPC. The agent clients' tool-server config files are named only in `mcpconfig.go`, which reads them, and nothing in the config reader imports or can reach a dialler, an HTTP client, a websocket or a process start. |
|
||||||
|
| | `claims_static_test.go`: `TestMCPReaderCheckCatchesADialAndAStrayBasename` | The check above reports a config reader that starts a process, imports `net/http` or reaches a dialler through another function, a Windows reader that starts a process, opens a socket, loads a DLL by name or opens a remote registry, and a second file that names a client's config file, the servers key, an `/mcp` URL or an `/mcp` route. |
|
||||||
| Grants are verified here | `anchor_test.go`: `TestPairingGrantSyncWalls` | A pairing grant signed by another key, for another machine, naming another anchor, or expired is refused, and the pairing stays pending. |
|
| Grants are verified here | `anchor_test.go`: `TestPairingGrantSyncWalls` | A pairing grant signed by another key, for another machine, naming another anchor, or expired is refused, and the pairing stays pending. |
|
||||||
| | `fuzz_grants_test.go`: `FuzzAChangedSignedByteNeverVerifies` | For every kind of signed record, changing any byte of the record or its signature makes it fail to verify. |
|
| | `fuzz_grants_test.go`: `FuzzAChangedSignedByteNeverVerifies` | For every kind of signed record, changing any byte of the record or its signature makes it fail to verify. |
|
||||||
| | `session_test.go`: `TestSessionPinsTheHostKeyWithoutAStoreGrant` | The first key offered is pinned; a different key later is refused and the pin is kept. |
|
| | `session_test.go`: `TestSessionPinsTheHostKeyWithoutAStoreGrant` | The first key offered is pinned; a different key later is refused and the pin is kept. |
|
||||||
@@ -1222,6 +1338,14 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| | `ownaddr_test.go`: `TestBackupLanesRefuseThisMachinesOwnAddresses` | The backup lanes' URL check refuses an address on this machine's interface and one a `local` route delivers here, in every spelling and through a name, and their dial wall refuses those addresses; a failed lookup refuses both; a neighbour on the subnet passes. With the kernel's answer supplied. |
|
| | `ownaddr_test.go`: `TestBackupLanesRefuseThisMachinesOwnAddresses` | The backup lanes' URL check refuses an address on this machine's interface and one a `local` route delivers here, in every spelling and through a name, and their dial wall refuses those addresses; a failed lookup refuses both; a neighbour on the subnet passes. With the kernel's answer supplied. |
|
||||||
| | `backupfetch_test.go`: `TestBackupWallJudgesTheIPv4InsideAndTheReservedRanges` | The backup lanes' classifier, address wall and URL check refuse 10.0.0.1 and 127.0.0.1 inside NAT64, 10.0.0.1 inside 6to4 and in the IPv4-compatible and IPv4-translated forms, the metadata address inside 6to4, a local-use NAT64 address, `fec0::1`, `198.18.0.0/15` (also inside an IPv4-mapped address), `240.0.0.0/4` (also inside NAT64) and `255.255.255.255`; public addresses in the NAT64, 6to4 and IPv4-mapped forms, and the addresses either side of `198.18.0.0/15`, pass the classifier. |
|
| | `backupfetch_test.go`: `TestBackupWallJudgesTheIPv4InsideAndTheReservedRanges` | The backup lanes' classifier, address wall and URL check refuse 10.0.0.1 and 127.0.0.1 inside NAT64, 10.0.0.1 inside 6to4 and in the IPv4-compatible and IPv4-translated forms, the metadata address inside 6to4, a local-use NAT64 address, `fec0::1`, `198.18.0.0/15` (also inside an IPv4-mapped address), `240.0.0.0/4` (also inside NAT64) and `255.255.255.255`; public addresses in the NAT64, 6to4 and IPv4-mapped forms, and the addresses either side of `198.18.0.0/15`, pass the classifier. |
|
||||||
| | not covered by a test | A public address that reaches this machine only through a 1:1 NAT in front of it. Nothing refuses it. |
|
| | not covered by a test | A public address that reaches this machine only through a 1:1 NAT in front of it. Nothing refuses it. |
|
||||||
|
| Audit rows also reach the system log | `auditlog_test.go`: `TestAuditRowsReachTheSystemLog` | The management lane, pairing changes, store ops, terminals and root commands each send the row they write to their file to the system log too, unchanged, with `audit` and `pairing` added. |
|
||||||
|
| | `auditlog_test.go`: `TestAuditFileRowStandsWithoutASystemLog` | A system log that takes nothing costs only that copy; the row is still written to its file. |
|
||||||
|
| | `auditlog_syslog_test.go`: `TestAuditRowsDoNotWaitOnAStuckSyslogSocket` | A real syslog writer on a socket nobody reads blocks; 5000 rows queued in front of it all return at once and the excess is counted as dropped. |
|
||||||
|
| | `auditlog_queue_test.go`: `TestAuditSinkIsTheQueue`, `TestAuditQueueNeverWaitsOnAStuckWriter` | The shipped sink is the queue, not the writer; with a writer that never returns, 1000 rows are taken at once, and every row is either written or counted in `system_log_dropped` on a later line. |
|
||||||
|
| | `auditlog_queue_test.go`: `TestAuditSystemLogOmitsTheRootCommandText` | A root command's text is not in the system-log line; its digest and length are. |
|
||||||
|
| A networked machine is anchored to its first host | `pair_anchor_test.go`: `TestPairAnchorsANetworkedMachineToItsFirstHost` | On a networked machine with no anchor, the first pairing made with machine authority anchors it and is active; a local machine, an anchored one (a new host stays pending), a second pairing, and a caller without machine authority are left as they were. `claim-anchor` afterwards succeeds and changes nothing. Called directly, not through `pair`'s redeem. |
|
||||||
|
| | `anchor_test.go`: `TestClaimAnchorWithNoToken` | `claim-anchor` with no token on a machine anchored to that host succeeds and changes nothing, the host named in full, by handle or not at all; when the anchor's pairing is pending or revoked it is refused and the state is left as it was; another host is refused without `--rotate`. |
|
||||||
|
| A root reinstall keeps a paired local machine local | `install_sh_test.go`: `TestInstallShKeepsAPairedLocalMachineLocal` | `install.sh`'s check finds a pairing in a folder whose mode is local (or unset), in either folder it looks in; a networked folder, or one with no pairing, gets the networked default. The rest of the installer is not run by a test. |
|
||||||
| A terminal is checked here and logged | `session_test.go`: `TestSessionOpensWithoutAGrantOnALocalBox` | On a local machine a terminal with no grant and no approval opens and is audited. The same test calls `sessionPinOfferedKey` directly: the first key offered is pinned and a different one is refused. |
|
| A terminal is checked here and logged | `session_test.go`: `TestSessionOpensWithoutAGrantOnALocalBox` | On a local machine a terminal with no grant and no approval opens and is audited. The same test calls `sessionPinOfferedKey` directly: the first key offered is pinned and a different one is refused. |
|
||||||
| | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | A grant that is sent is verified: a bad signature, an op not listed, or an op the grant denies is refused and audited. |
|
| | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | A grant that is sent is verified: a bad signature, an op not listed, or an op the grant denies is refused and audited. |
|
||||||
| | `fuzz_grants_test.go`: `FuzzEachOpIsAllowedOnlyByItsOwnFamily` | On an anchored machine the family check (`capsAllow`) lets a host other than the anchor run an op, the session ops included, only if its pairing grant names that op's family. Called directly, not through the terminal handler. |
|
| | `fuzz_grants_test.go`: `FuzzEachOpIsAllowedOnlyByItsOwnFamily` | On an anchored machine the family check (`capsAllow`) lets a host other than the anchor run an op, the session ops included, only if its pairing grant names that op's family. Called directly, not through the terminal handler. |
|
||||||
@@ -1422,6 +1546,7 @@ client-go/ the bastion: one Go package, one binary
|
|||||||
coder_socket.go a coder run's socket for a Host on this machine
|
coder_socket.go a coder run's socket for a Host on this machine
|
||||||
anchor.go networked-mode anchor
|
anchor.go networked-mode anchor
|
||||||
mgmt*.go management lane, allowlist and audit
|
mgmt*.go management lane, allowlist and audit
|
||||||
|
auditlog*.go every audit row also written to the system log
|
||||||
writes.go which management ops are writes (and need an approval)
|
writes.go which management ops are writes (and need an approval)
|
||||||
device*.go the approval device
|
device*.go the approval device
|
||||||
quarantine.go closing a pairing's write lanes after repeated refusals
|
quarantine.go closing a pairing's write lanes after repeated refusals
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHO RUNS AN AGENT, NOT ONLY WHICH ACCOUNT.
|
||||||
|
//
|
||||||
|
// The process scan names the account an agent runs as (procUser on every
|
||||||
|
// platform, winTokenUser on Windows) and says nothing else about it. The
|
||||||
|
// question an assessment asks is about the account: is it an administrator, is
|
||||||
|
// it a person's own account or one that exists for a service, and is that
|
||||||
|
// person logged in right now. "An agent runs as a person, holds administrator
|
||||||
|
// rights, and nobody is at the keyboard" is a different finding from "an agent
|
||||||
|
// runs under a service account with no rights", and the account name alone
|
||||||
|
// cannot tell them apart.
|
||||||
|
//
|
||||||
|
// Three facts, each with an honest third value:
|
||||||
|
//
|
||||||
|
// owner_class domain_user, local_user, service, system, or unknown
|
||||||
|
// owner_admin yes, no, or unknown
|
||||||
|
// owner_interactive yes, no, or unknown
|
||||||
|
//
|
||||||
|
// UNKNOWN IS NEVER FOLDED INTO NO. A box that could not read the group list
|
||||||
|
// has not learned that the account is unprivileged, and saying "no" there is
|
||||||
|
// the confident-wrong answer this plane refuses everywhere else
|
||||||
|
// (baseline_identity.go draws the same line for sudo rules it could not read).
|
||||||
|
//
|
||||||
|
// Every decision is in this file, with no build tag, so the Linux test run
|
||||||
|
// exercises the macOS and Windows rules too. The platform files
|
||||||
|
// (agents_<goos>.go) only read: /proc and /etc on Linux, `id`, `dscl` and
|
||||||
|
// `who` on macOS, the process token and the session list on Windows.
|
||||||
|
//
|
||||||
|
// These ride on agentObs, so they travel only where `agents` does: inside the
|
||||||
|
// heartbeat's `observing` gate (agents.go).
|
||||||
|
|
||||||
|
const (
|
||||||
|
ownerClassDomain = "domain_user"
|
||||||
|
ownerClassLocal = "local_user"
|
||||||
|
ownerClassService = "service"
|
||||||
|
ownerClassSystem = "system"
|
||||||
|
ownerUnknown = "unknown"
|
||||||
|
ownerYes = "yes"
|
||||||
|
ownerNo = "no"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ownerFacts is the three answers for one account.
|
||||||
|
type ownerFacts struct {
|
||||||
|
Class string
|
||||||
|
Admin string
|
||||||
|
Interactive string
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknownOwner is the answer when nothing could be read: a process whose
|
||||||
|
// owner this bastion may not inspect, or a build with no reader.
|
||||||
|
var unknownOwner = ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
|
||||||
|
|
||||||
|
func yesNo(b bool) string {
|
||||||
|
if b {
|
||||||
|
return ownerYes
|
||||||
|
}
|
||||||
|
return ownerNo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Linux and macOS ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// unixOwner is what a Linux or macOS reader collected about one account.
|
||||||
|
// Every field that can be unreadable carries its own "known" flag.
|
||||||
|
type unixOwner struct {
|
||||||
|
UID int // -1 when the uid could not be read
|
||||||
|
Name string
|
||||||
|
// LocalRecord: "yes" when the account is in this machine's own account
|
||||||
|
// database (/etc/passwd; the macOS local directory node), "no" when it is
|
||||||
|
// not, "unknown" when that could not be read.
|
||||||
|
LocalRecord string
|
||||||
|
// DirectorySource: this machine resolves accounts from a directory
|
||||||
|
// (nsswitch names sss, ldap, winbind or nis; a macOS mobile account was
|
||||||
|
// copied from one). Only then does "not in the local database" mean a
|
||||||
|
// domain account rather than a uid with no account at all.
|
||||||
|
DirectorySource bool
|
||||||
|
// SystemUIDMax is the highest uid the platform hands to system accounts.
|
||||||
|
SystemUIDMax int
|
||||||
|
Groups []string // group names the account or process holds
|
||||||
|
GroupsKnown bool
|
||||||
|
AdminGroups map[string]bool
|
||||||
|
// SudoApplies: on this platform a sudo rule can grant administration to
|
||||||
|
// any user or group (Linux), so "in no admin group" is only a "no" once
|
||||||
|
// the rules were read as well. macOS leaves it false and decides on the
|
||||||
|
// admin group, which is what its stock sudoers grants.
|
||||||
|
// SudoNamed / SudoKnown: a sudo rule names the account directly or
|
||||||
|
// through a group (sudoPrincipals), and whether the rules were readable.
|
||||||
|
SudoApplies bool
|
||||||
|
SudoNamed bool
|
||||||
|
SudoKnown bool
|
||||||
|
// Sessions is the set of account names with a login session now
|
||||||
|
// (logind, utmp, `who`); SessionsKnown is false when none could be read.
|
||||||
|
Sessions map[string]bool
|
||||||
|
SessionsKnown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxSystemUIDMax is shadow-utils' default SYS_UID_MAX: accounts below
|
||||||
|
// UID_MIN (1000 by default) are system accounts. login.defs can move both;
|
||||||
|
// linuxUIDLimits reads it.
|
||||||
|
const linuxSystemUIDMax = 999
|
||||||
|
|
||||||
|
// darwinSystemUIDMax: macOS creates people's accounts from uid 501 up, and
|
||||||
|
// keeps the ids below for its own daemons (_www, _spotlight and the rest).
|
||||||
|
const darwinSystemUIDMax = 500
|
||||||
|
|
||||||
|
func classifyUnixOwner(o unixOwner) ownerFacts {
|
||||||
|
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
|
||||||
|
switch {
|
||||||
|
case o.UID == 0:
|
||||||
|
f.Class = ownerClassSystem
|
||||||
|
case o.UID == -1:
|
||||||
|
// Unread uid: the class cannot be told from a name alone. Other
|
||||||
|
// negative ids are real: macOS's directory gives nobody -2.
|
||||||
|
case o.UID == 65534 || o.UID <= o.SystemUIDMax:
|
||||||
|
// nobody, and every uid in the system range, is an account that exists
|
||||||
|
// for a program rather than a person.
|
||||||
|
f.Class = ownerClassService
|
||||||
|
case strings.ContainsAny(o.Name, `@\`):
|
||||||
|
// user@realm (SSSD) and DOMAIN\user (winbind) are directory names.
|
||||||
|
f.Class = ownerClassDomain
|
||||||
|
case o.LocalRecord == ownerYes:
|
||||||
|
f.Class = ownerClassLocal
|
||||||
|
case o.LocalRecord == ownerNo && o.DirectorySource:
|
||||||
|
f.Class = ownerClassDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case o.UID == 0:
|
||||||
|
f.Admin = ownerYes
|
||||||
|
case o.GroupsKnown && hasAnyGroup(o.Groups, o.AdminGroups):
|
||||||
|
f.Admin = ownerYes
|
||||||
|
case o.SudoKnown && o.SudoNamed:
|
||||||
|
f.Admin = ownerYes
|
||||||
|
case o.GroupsKnown && (o.SudoKnown || !o.SudoApplies):
|
||||||
|
f.Admin = ownerNo
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.SessionsKnown && o.Name != "" {
|
||||||
|
f.Interactive = yesNo(o.Sessions[o.Name])
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAnyGroup(groups []string, want map[string]bool) bool {
|
||||||
|
for _, g := range groups {
|
||||||
|
if want[g] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxAdminGroups: the groups a stock distribution's sudoers grants
|
||||||
|
// administration to (sudo on Debian and Ubuntu, wheel on Fedora, RHEL and
|
||||||
|
// Arch, admin on older Ubuntu).
|
||||||
|
var linuxAdminGroups = map[string]bool{"sudo": true, "wheel": true, "admin": true}
|
||||||
|
|
||||||
|
// darwinAdminGroups: macOS administrators are members of `admin`.
|
||||||
|
var darwinAdminGroups = map[string]bool{"admin": true}
|
||||||
|
|
||||||
|
// parseStatusIDs reads the real and effective uid and the supplementary gid
|
||||||
|
// list out of /proc/<pid>/status. ok is false when there is no Uid line.
|
||||||
|
func parseStatusIDs(status string) (realUID, effUID int, gids []int, ok bool) {
|
||||||
|
realUID, effUID = -1, -1
|
||||||
|
for _, line := range strings.Split(status, "\n") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "Uid:"):
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) >= 3 {
|
||||||
|
r, err1 := strconv.Atoi(f[1])
|
||||||
|
e, err2 := strconv.Atoi(f[2])
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
realUID, effUID, ok = r, e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(line, "Groups:"):
|
||||||
|
for _, g := range strings.Fields(strings.TrimPrefix(line, "Groups:")) {
|
||||||
|
if n, err := strconv.Atoi(g); err == nil {
|
||||||
|
gids = append(gids, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return realUID, effUID, gids, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGroupNames maps gid -> name from an /etc/group body.
|
||||||
|
func parseGroupNames(body string) map[int]string {
|
||||||
|
out := map[int]string{}
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
p := strings.Split(strings.TrimSpace(line), ":")
|
||||||
|
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(p[2]); err == nil {
|
||||||
|
out[n] = p[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePasswdUIDs is the set of uids /etc/passwd itself lists.
|
||||||
|
func parsePasswdUIDs(body string) map[int]bool {
|
||||||
|
out := map[int]bool{}
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
p := strings.Split(strings.TrimSpace(line), ":")
|
||||||
|
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(p[2]); err == nil {
|
||||||
|
out[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLoginDefsSysMax returns the highest system uid login.defs declares:
|
||||||
|
// SYS_UID_MAX when present, else UID_MIN-1, else the shadow-utils default.
|
||||||
|
func parseLoginDefsSysMax(body string) int {
|
||||||
|
sysMax, uidMin := -1, -1
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
f := strings.Fields(strings.TrimSpace(line))
|
||||||
|
if len(f) < 2 || strings.HasPrefix(f[0], "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(f[1])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch f[0] {
|
||||||
|
case "SYS_UID_MAX":
|
||||||
|
sysMax = n
|
||||||
|
case "UID_MIN":
|
||||||
|
uidMin = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case sysMax >= 0:
|
||||||
|
return sysMax
|
||||||
|
case uidMin > 0:
|
||||||
|
return uidMin - 1
|
||||||
|
}
|
||||||
|
return linuxSystemUIDMax
|
||||||
|
}
|
||||||
|
|
||||||
|
// nsswitchHasDirectory reports whether the passwd line of nsswitch.conf names
|
||||||
|
// a directory source, so an account missing from /etc/passwd is resolved from
|
||||||
|
// one rather than simply absent.
|
||||||
|
func nsswitchHasDirectory(body string) bool {
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "passwd:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, src := range strings.Fields(strings.TrimPrefix(line, "passwd:")) {
|
||||||
|
switch src {
|
||||||
|
case "sss", "ldap", "winbind", "nis", "nisplus":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLogindUserState reads systemd-logind's per-user state file
|
||||||
|
// (/run/systemd/users/<uid>). active and online mean the user has a session
|
||||||
|
// now; lingering means services run for them with no session.
|
||||||
|
func parseLogindUserState(body string) (interactive bool, ok bool) {
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
if v, found := strings.CutPrefix(strings.TrimSpace(line), "STATE="); found {
|
||||||
|
switch v {
|
||||||
|
case "active", "online":
|
||||||
|
return true, true
|
||||||
|
case "lingering", "closing", "offline":
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// utmp record layout on glibc Linux (bits/utmp.h): 384 bytes, ut_type a
|
||||||
|
// 16-bit value at offset 0, ut_user 32 bytes at offset 44. USER_PROCESS (7)
|
||||||
|
// is a login session.
|
||||||
|
const (
|
||||||
|
utmpRecordSize = 384
|
||||||
|
utmpUserOffset = 44
|
||||||
|
utmpUserSize = 32
|
||||||
|
utmpUserProcess = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseUtmpUsers is the set of account names with a USER_PROCESS record.
|
||||||
|
func parseUtmpUsers(b []byte) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for off := 0; off+utmpRecordSize <= len(b); off += utmpRecordSize {
|
||||||
|
rec := b[off : off+utmpRecordSize]
|
||||||
|
if int(rec[0])|int(rec[1])<<8 != utmpUserProcess {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := rec[utmpUserOffset : utmpUserOffset+utmpUserSize]
|
||||||
|
if i := bytes.IndexByte(name, 0); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
if len(name) > 0 {
|
||||||
|
out[string(name)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWhoUsers is the set of account names `who` lists (macOS): the first
|
||||||
|
// field of every line.
|
||||||
|
func parseWhoUsers(out string) map[string]bool {
|
||||||
|
users := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if f := strings.Fields(line); len(f) > 0 {
|
||||||
|
users[f[0]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseIDGroups reads `id -Gn <user>`: group names separated by spaces.
|
||||||
|
func parseIDGroups(out string) []string { return strings.Fields(out) }
|
||||||
|
|
||||||
|
// dsclRecordMissing: dscl ran to the end and exited non-zero, which is how it
|
||||||
|
// says the local node has no such record. A dscl the timeout killed has an
|
||||||
|
// exit code of -1 and has said nothing about the account.
|
||||||
|
func dsclRecordMissing(err error) bool {
|
||||||
|
var ee *exec.ExitError
|
||||||
|
return errors.As(err, &ee) && ee.ExitCode() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDsclUser reads `dscl . -read /Users/<name> UniqueID OriginalNodeName`.
|
||||||
|
// A mobile account (cached from a directory) carries OriginalNodeName.
|
||||||
|
func parseDsclUser(out string) (uid int, mobile bool, ok bool) {
|
||||||
|
uid = -1
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if v, found := strings.CutPrefix(line, "UniqueID:"); found {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
||||||
|
uid, ok = n, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "OriginalNodeName:") {
|
||||||
|
mobile = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uid, mobile, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Windows ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Well-known SIDs, from Microsoft's tables:
|
||||||
|
// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids and
|
||||||
|
// https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers
|
||||||
|
const (
|
||||||
|
sidLocalSystem = "S-1-5-18"
|
||||||
|
sidLocalService = "S-1-5-19"
|
||||||
|
sidNetworkService = "S-1-5-20"
|
||||||
|
sidAdministrators = "S-1-5-32-544"
|
||||||
|
sidInteractive = "S-1-5-4"
|
||||||
|
sidRemoteInteractive = "S-1-5-14"
|
||||||
|
sidLocalAccount = "S-1-5-113"
|
||||||
|
sidNTServicePrefix = "S-1-5-80-"
|
||||||
|
sidVirtualMachinePfx = "S-1-5-83-"
|
||||||
|
)
|
||||||
|
|
||||||
|
// winOwner is what the Windows reader collected from one process token.
|
||||||
|
type winOwner struct {
|
||||||
|
SID string
|
||||||
|
Domain string // the account's domain as LookupAccount names it
|
||||||
|
Computer string // this machine's NetBIOS name
|
||||||
|
// Groups are the token's group SIDs, deny-only ones included: an
|
||||||
|
// administrator running without elevation holds Administrators as a
|
||||||
|
// deny-only group, and is still an administrator account.
|
||||||
|
Groups map[string]bool
|
||||||
|
GroupsKnown bool
|
||||||
|
// SessionSIDs: accounts with a Remote Desktop Services session now
|
||||||
|
// (WTSEnumerateSessions + WTSQueryUserToken, which needs LocalSystem).
|
||||||
|
SessionSIDs map[string]bool
|
||||||
|
SessionsKnown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyWinOwner(o winOwner) ownerFacts {
|
||||||
|
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
|
||||||
|
switch {
|
||||||
|
case o.SID == "":
|
||||||
|
case o.SID == sidLocalSystem:
|
||||||
|
f.Class = ownerClassSystem
|
||||||
|
case o.SID == sidLocalService || o.SID == sidNetworkService ||
|
||||||
|
strings.HasPrefix(o.SID, sidNTServicePrefix) || strings.HasPrefix(o.SID, sidVirtualMachinePfx):
|
||||||
|
f.Class = ownerClassService
|
||||||
|
case o.GroupsKnown && o.Groups[sidLocalAccount]:
|
||||||
|
f.Class = ownerClassLocal
|
||||||
|
case o.Domain == "":
|
||||||
|
case strings.EqualFold(o.Domain, o.Computer):
|
||||||
|
f.Class = ownerClassLocal
|
||||||
|
case strings.HasPrefix(o.SID, "S-1-5-21-") || strings.EqualFold(o.Domain, "AzureAD"):
|
||||||
|
// A machine or domain account whose domain is not this computer:
|
||||||
|
// an Active Directory or Entra ID account.
|
||||||
|
f.Class = ownerClassDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case o.SID == sidLocalSystem:
|
||||||
|
// "System is a hidden member of Administrators" (the second page above).
|
||||||
|
f.Admin = ownerYes
|
||||||
|
case o.GroupsKnown:
|
||||||
|
f.Admin = yesNo(o.Groups[sidAdministrators])
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case o.SessionsKnown && o.SID != "":
|
||||||
|
f.Interactive = yesNo(o.SessionSIDs[o.SID])
|
||||||
|
case o.GroupsKnown && (o.Groups[sidInteractive] || o.Groups[sidRemoteInteractive]):
|
||||||
|
// No session list (the bastion is not LocalSystem), but this very
|
||||||
|
// process was started by an interactive logon, so the account has
|
||||||
|
// one. The converse proves nothing: a scheduled task's token is not
|
||||||
|
// interactive while its owner may well be logged in.
|
||||||
|
f.Interactive = ownerYes
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Linux reader end to end over an invented machine: a /proc with two
|
||||||
|
// processes and an /etc and /run that say who they belong to. Nothing here is
|
||||||
|
// read from the box the test runs on.
|
||||||
|
func TestLinuxOwnerFactsReaderOverAFixtureMachine(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
write := func(rel, body string) {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(root, rel)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write("etc/passwd", "root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n"+
|
||||||
|
"kim:x:1002:1002::/home/kim:/bin/bash\nsvcdemo:x:120:120::/nonexistent:/usr/sbin/nologin\n")
|
||||||
|
write("etc/group", "root:x:0:\nsudo:x:27:pat\npat:x:1001:\nkim:x:1002:\n")
|
||||||
|
write("etc/sudoers", "root ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n")
|
||||||
|
write("etc/nsswitch.conf", "passwd: files\n")
|
||||||
|
write("run/systemd/users/1001", "UID=1001\nSTATE=active\n")
|
||||||
|
write("proc/400/status", "Name:\tdemo\nUid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001\n")
|
||||||
|
write("proc/401/status", "Name:\tdemo\nUid:\t1002\t1002\t1002\t1002\nGroups:\t1002\n")
|
||||||
|
write("proc/402/status", "Name:\tdemo\nUid:\t120\t120\t120\t120\nGroups:\t\n")
|
||||||
|
write("proc/403/status", "Name:\tdemo\nUid:\t1002\t0\t1002\t1002\nGroups:\t1002\n")
|
||||||
|
|
||||||
|
oldID, oldProc := identityRoot, procRoot
|
||||||
|
identityRoot, procRoot = root, filepath.Join(root, "proc")
|
||||||
|
t.Cleanup(func() { identityRoot, procRoot = oldID, oldProc })
|
||||||
|
|
||||||
|
read := ownerFactsReader()
|
||||||
|
for _, c := range []struct {
|
||||||
|
pid int
|
||||||
|
user string
|
||||||
|
want ownerFacts
|
||||||
|
}{
|
||||||
|
{400, "pat", ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
|
||||||
|
{401, "kim", ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
|
||||||
|
{402, "svcdemo", ownerFacts{ownerClassService, ownerNo, ownerNo}},
|
||||||
|
// Effective root under kim's account: administrator, class unchanged.
|
||||||
|
{403, "kim", ownerFacts{ownerClassLocal, ownerYes, ownerNo}},
|
||||||
|
// A pid with no status file: everything unknown, nothing guessed.
|
||||||
|
{404, "ghost", unknownOwner},
|
||||||
|
} {
|
||||||
|
if got := read(c.pid, c.user); got != c.want {
|
||||||
|
t.Errorf("pid %d (%s): got %+v, want %+v", c.pid, c.user, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every observation scanAgents returns carries the owner facts: the wiring,
|
||||||
|
// not only the classifier. The one process is this test itself, so the facts
|
||||||
|
// come from the real /proc and are only checked for being filled in.
|
||||||
|
func TestScanAgentsCarriesOwnerFacts(t *testing.T) {
|
||||||
|
prev := scanProcessesFn
|
||||||
|
t.Cleanup(func() { scanProcessesFn = prev })
|
||||||
|
self := os.Getpid()
|
||||||
|
scanProcessesFn = func() (map[int]agentObs, error) {
|
||||||
|
return map[int]agentObs{self: {PID: self, User: procUser(self), Signature: "claude-code",
|
||||||
|
Cmdline: "claude", raw: "claude"}}, nil
|
||||||
|
}
|
||||||
|
got, err := scanAgents()
|
||||||
|
if err != nil || len(got) == 0 {
|
||||||
|
t.Fatalf("scan: %d observations, %v", len(got), err)
|
||||||
|
}
|
||||||
|
// The network signal may add this box's own model clients; every
|
||||||
|
// observation, however it was caught, must carry the facts.
|
||||||
|
for _, a := range got {
|
||||||
|
if a.OwnerClass == "" || a.OwnerAdmin == "" || a.OwnerInteractive == "" {
|
||||||
|
t.Fatalf("owner facts missing from pid %d", a.PID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every value below is invented. No account, group or SID here belongs to a
|
||||||
|
// real machine.
|
||||||
|
|
||||||
|
func TestClassifyUnixOwner(t *testing.T) {
|
||||||
|
linux := func(o unixOwner) unixOwner {
|
||||||
|
o.SystemUIDMax = linuxSystemUIDMax
|
||||||
|
o.AdminGroups = linuxAdminGroups
|
||||||
|
o.SudoApplies = true
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in unixOwner
|
||||||
|
want ownerFacts
|
||||||
|
}{
|
||||||
|
{"root is the system and an administrator",
|
||||||
|
linux(unixOwner{UID: 0, Name: "root", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
|
||||||
|
Sessions: map[string]bool{}, SessionsKnown: true}),
|
||||||
|
ownerFacts{ownerClassSystem, ownerYes, ownerNo}},
|
||||||
|
{"a daemon account in the system range",
|
||||||
|
linux(unixOwner{UID: 115, Name: "svc-demo", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
|
||||||
|
Sessions: map[string]bool{}, SessionsKnown: true}),
|
||||||
|
ownerFacts{ownerClassService, ownerNo, ownerNo}},
|
||||||
|
{"nobody is a service account",
|
||||||
|
linux(unixOwner{UID: 65534, Name: "nobody", LocalRecord: ownerYes}),
|
||||||
|
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
|
||||||
|
{"a person in the sudo group, logged in",
|
||||||
|
linux(unixOwner{UID: 1001, Name: "pat", LocalRecord: ownerYes, Groups: []string{"pat", "sudo"},
|
||||||
|
GroupsKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
|
||||||
|
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
|
||||||
|
{"a person a sudo rule names directly",
|
||||||
|
linux(unixOwner{UID: 1002, Name: "lee", LocalRecord: ownerYes, Groups: []string{"lee"},
|
||||||
|
GroupsKnown: true, SudoNamed: true, SudoKnown: true}),
|
||||||
|
ownerFacts{ownerClassLocal, ownerYes, ownerUnknown}},
|
||||||
|
{"no admin group and the sudo rules read: no",
|
||||||
|
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"},
|
||||||
|
GroupsKnown: true, SudoKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
|
||||||
|
ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
|
||||||
|
{"no admin group but the sudo rules unreadable: unknown, never no",
|
||||||
|
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"}, GroupsKnown: true}),
|
||||||
|
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
|
||||||
|
{"groups unreadable: unknown, never no",
|
||||||
|
linux(unixOwner{UID: 1004, Name: "ash", LocalRecord: ownerYes, SudoKnown: true}),
|
||||||
|
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
|
||||||
|
{"not in passwd on a machine that resolves from a directory",
|
||||||
|
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo, DirectorySource: true}),
|
||||||
|
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
|
||||||
|
{"not in passwd and no directory: a uid with no account, not a domain user",
|
||||||
|
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo}),
|
||||||
|
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
|
||||||
|
{"a realm-qualified name is a directory account",
|
||||||
|
linux(unixOwner{UID: 70001, Name: "pat@corp.example", LocalRecord: ownerUnknown}),
|
||||||
|
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
|
||||||
|
{"uid unread",
|
||||||
|
linux(unixOwner{UID: -1, Name: ""}),
|
||||||
|
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
|
||||||
|
{"macOS admin group member",
|
||||||
|
unixOwner{UID: 501, Name: "sam", LocalRecord: ownerYes, Groups: []string{"staff", "admin"},
|
||||||
|
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
|
||||||
|
Sessions: map[string]bool{"sam": true}, SessionsKnown: true},
|
||||||
|
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
|
||||||
|
{"macOS standard user: the admin group decides",
|
||||||
|
unixOwner{UID: 502, Name: "jo", LocalRecord: ownerYes, Groups: []string{"staff"},
|
||||||
|
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
|
||||||
|
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
|
||||||
|
{"macOS daemon",
|
||||||
|
unixOwner{UID: 88, Name: "_demo", LocalRecord: ownerYes, SystemUIDMax: darwinSystemUIDMax,
|
||||||
|
AdminGroups: darwinAdminGroups},
|
||||||
|
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
|
||||||
|
{"macOS mobile account",
|
||||||
|
unixOwner{UID: 1204567, Name: "rio", LocalRecord: ownerNo, DirectorySource: true,
|
||||||
|
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
|
||||||
|
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := classifyUnixOwner(c.in); got != c.want {
|
||||||
|
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyWinOwner(t *testing.T) {
|
||||||
|
const box = "DESK-DEMO"
|
||||||
|
user := "S-1-5-21-1111111111-2222222222-3333333333-1001"
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in winOwner
|
||||||
|
want ownerFacts
|
||||||
|
}{
|
||||||
|
{"LocalSystem",
|
||||||
|
winOwner{SID: sidLocalSystem, Domain: "NT AUTHORITY", Computer: box},
|
||||||
|
ownerFacts{ownerClassSystem, ownerYes, ownerUnknown}},
|
||||||
|
{"NetworkService",
|
||||||
|
winOwner{SID: sidNetworkService, Domain: "NT AUTHORITY", Computer: box,
|
||||||
|
Groups: map[string]bool{}, GroupsKnown: true},
|
||||||
|
ownerFacts{ownerClassService, ownerNo, ownerUnknown}},
|
||||||
|
{"a virtual service account",
|
||||||
|
winOwner{SID: "S-1-5-80-1-2-3-4-5", Domain: "NT SERVICE", Computer: box},
|
||||||
|
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
|
||||||
|
{"local administrator, unelevated, at the desktop",
|
||||||
|
winOwner{SID: user, Domain: box, Computer: box,
|
||||||
|
Groups: map[string]bool{sidAdministrators: true, sidInteractive: true},
|
||||||
|
GroupsKnown: true, SessionSIDs: map[string]bool{user: true}, SessionsKnown: true},
|
||||||
|
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
|
||||||
|
{"domain user, not an administrator, no session now",
|
||||||
|
winOwner{SID: user, Domain: "CORPDEMO", Computer: box,
|
||||||
|
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true,
|
||||||
|
SessionSIDs: map[string]bool{}, SessionsKnown: true},
|
||||||
|
ownerFacts{ownerClassDomain, ownerNo, ownerNo}},
|
||||||
|
{"the local-account group decides even with no domain name",
|
||||||
|
winOwner{SID: user, Groups: map[string]bool{sidLocalAccount: true}, GroupsKnown: true},
|
||||||
|
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
|
||||||
|
{"no session list, but this process is an interactive logon",
|
||||||
|
winOwner{SID: user, Domain: box, Computer: box,
|
||||||
|
Groups: map[string]bool{sidRemoteInteractive: true, sidInteractive: true}, GroupsKnown: true},
|
||||||
|
ownerFacts{ownerClassLocal, ownerNo, ownerYes}},
|
||||||
|
{"no session list and a batch token: unknown, never no",
|
||||||
|
winOwner{SID: user, Domain: box, Computer: box,
|
||||||
|
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true},
|
||||||
|
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
|
||||||
|
{"token unreadable",
|
||||||
|
winOwner{},
|
||||||
|
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := classifyWinOwner(c.in); got != c.want {
|
||||||
|
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerFileParsers(t *testing.T) {
|
||||||
|
status := "Name:\tdemo\nUid:\t1001\t0\t1001\t1001\nGid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001 \n"
|
||||||
|
r, e, gids, ok := parseStatusIDs(status)
|
||||||
|
if !ok || r != 1001 || e != 0 || len(gids) != 2 || gids[0] != 27 {
|
||||||
|
t.Fatalf("status: %d %d %v %v", r, e, gids, ok)
|
||||||
|
}
|
||||||
|
if _, _, _, ok := parseStatusIDs("Name:\tdemo\n"); ok {
|
||||||
|
t.Fatal("a status with no Uid line must not read as uid 0")
|
||||||
|
}
|
||||||
|
names := parseGroupNames("root:x:0:\nsudo:x:27:pat\n# comment\nbad\n")
|
||||||
|
if names[27] != "sudo" || names[0] != "root" || len(names) != 2 {
|
||||||
|
t.Fatalf("groups: %v", names)
|
||||||
|
}
|
||||||
|
uids := parsePasswdUIDs("root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n")
|
||||||
|
if !uids[1001] || !uids[0] || uids[5] {
|
||||||
|
t.Fatalf("passwd: %v", uids)
|
||||||
|
}
|
||||||
|
for body, want := range map[string]int{
|
||||||
|
"": linuxSystemUIDMax,
|
||||||
|
"UID_MIN 500\n": 499,
|
||||||
|
"UID_MIN 1000\nSYS_UID_MAX 899\n": 899,
|
||||||
|
"# SYS_UID_MAX 10\n": linuxSystemUIDMax,
|
||||||
|
} {
|
||||||
|
if got := parseLoginDefsSysMax(body); got != want {
|
||||||
|
t.Errorf("login.defs %q: %d, want %d", body, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !nsswitchHasDirectory("group: files\npasswd: files sss systemd\n") ||
|
||||||
|
nsswitchHasDirectory("passwd: files systemd\n") {
|
||||||
|
t.Fatal("nsswitch directory detection")
|
||||||
|
}
|
||||||
|
for body, want := range map[string][2]bool{
|
||||||
|
"UID=1001\nSTATE=active\nSESSIONS=3\n": {true, true},
|
||||||
|
"STATE=lingering\n": {false, true},
|
||||||
|
"STATE=something-new\n": {false, false},
|
||||||
|
"UID=1001\n": {false, false},
|
||||||
|
} {
|
||||||
|
got, ok := parseLogindUserState(body)
|
||||||
|
if got != want[0] || ok != want[1] {
|
||||||
|
t.Errorf("logind %q: %v %v", body, got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u := parseWhoUsers("sam console Oct 20 09:00\njo ttys001 Oct 20 09:05\n"); !u["sam"] || !u["jo"] || len(u) != 2 {
|
||||||
|
t.Fatalf("who: %v", u)
|
||||||
|
}
|
||||||
|
uid, mobile, ok := parseDsclUser("OriginalNodeName:\n /Active Directory/DEMO\nUniqueID: 1204567\n")
|
||||||
|
if !ok || uid != 1204567 || !mobile {
|
||||||
|
t.Fatalf("dscl: %d %v %v", uid, mobile, ok)
|
||||||
|
}
|
||||||
|
if g := parseIDGroups("staff admin everyone\n"); len(g) != 3 || g[1] != "admin" {
|
||||||
|
t.Fatalf("id: %v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUtmpUsersReadsOnlyLoginSessions(t *testing.T) {
|
||||||
|
rec := func(typ uint16, user string) []byte {
|
||||||
|
b := make([]byte, utmpRecordSize)
|
||||||
|
binary.LittleEndian.PutUint16(b, typ)
|
||||||
|
copy(b[utmpUserOffset:], user)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
var blob []byte
|
||||||
|
blob = append(blob, rec(utmpUserProcess, "pat")...)
|
||||||
|
blob = append(blob, rec(8, "gone")...) // DEAD_PROCESS: a session that ended
|
||||||
|
blob = append(blob, rec(utmpUserProcess, "lee")...)
|
||||||
|
blob = append(blob, 1, 2, 3) // a torn tail is ignored, not misread
|
||||||
|
got := parseUtmpUsers(blob)
|
||||||
|
if !got["pat"] || !got["lee"] || got["gone"] || len(got) != 2 {
|
||||||
|
t.Fatalf("utmp: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
-3
@@ -213,9 +213,17 @@ type agentObs struct {
|
|||||||
// what makes a service RESTART the same entry rather than a new one
|
// what makes a service RESTART the same entry rather than a new one
|
||||||
// (baseline.go). Empty where the platform does not supply one: darwin and
|
// (baseline.go). Empty where the platform does not supply one: darwin and
|
||||||
// windows never populate procFacts.Unit, so those fall back to the origin.
|
// windows never populate procFacts.Unit, so those fall back to the origin.
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
Evidence []evidence `json:"evidence,omitempty"`
|
// The account behind User (agentowner.go): domain_user, local_user,
|
||||||
raw string // the UNTRUNCATED command line, for classification only; never reported
|
// service, system or unknown; whether it administers this machine; and
|
||||||
|
// whether it has a login session now. yes, no or unknown each — unknown
|
||||||
|
// is what an unreadable token or group list says, never no. Empty only on
|
||||||
|
// an observation made outside scanAgents.
|
||||||
|
OwnerClass string `json:"owner_class,omitempty"`
|
||||||
|
OwnerAdmin string `json:"owner_admin,omitempty"`
|
||||||
|
OwnerInteractive string `json:"owner_interactive,omitempty"`
|
||||||
|
Evidence []evidence `json:"evidence,omitempty"`
|
||||||
|
raw string // the UNTRUNCATED command line, for classification only; never reported
|
||||||
// automation: the signature came from the automation tables, so the
|
// automation: the signature came from the automation tables, so the
|
||||||
// classifier gives this kind `automation` unless model traffic promotes it.
|
// classifier gives this kind `automation` unless model traffic promotes it.
|
||||||
automation bool
|
automation bool
|
||||||
@@ -304,6 +312,7 @@ func scanAgents() ([]agentObs, error) {
|
|||||||
}
|
}
|
||||||
extra := originExtras()
|
extra := originExtras()
|
||||||
facts := procFactsReader()
|
facts := procFactsReader()
|
||||||
|
owners := ownerFactsReader()
|
||||||
out := make([]agentObs, 0, len(byPid))
|
out := make([]agentObs, 0, len(byPid))
|
||||||
for pid, a := range byPid {
|
for pid, a := range byPid {
|
||||||
if faIsOwnChild(pid) {
|
if faIsOwnChild(pid) {
|
||||||
@@ -317,6 +326,11 @@ func scanAgents() ([]agentObs, error) {
|
|||||||
if f, ok := facts(pid); ok {
|
if f, ok := facts(pid); ok {
|
||||||
a.Unit = f.Unit
|
a.Unit = f.Unit
|
||||||
}
|
}
|
||||||
|
// Who the owning account is, beside who it is called. Read here
|
||||||
|
// rather than in each platform's walk so a process the network
|
||||||
|
// signal alone caught (mergeConnections) gets the same answer.
|
||||||
|
o := owners(pid, a.User)
|
||||||
|
a.OwnerClass, a.OwnerAdmin, a.OwnerInteractive = o.Class, o.Admin, o.Interactive
|
||||||
a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...)
|
a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...)
|
||||||
if a.DelegationToken != "" {
|
if a.DelegationToken != "" {
|
||||||
a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"})
|
a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"})
|
||||||
@@ -327,6 +341,133 @@ func scanAgents() ([]agentObs, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanMCPProcessesFn is the seam a test uses to hand the correlation a
|
||||||
|
// process list; production never reassigns it.
|
||||||
|
var scanMCPProcessesFn = scanMCPProcesses
|
||||||
|
|
||||||
|
// mcpServersReport reads the declared tool servers and correlates them with
|
||||||
|
// the process list. It lives here, not in mcpconfig.go, because reading the
|
||||||
|
// process list runs `ps` on macOS: the config reader itself starts nothing,
|
||||||
|
// and the claims test holds it to that.
|
||||||
|
func mcpServersReport() ([]mcpServerObs, mcpConfigScan) {
|
||||||
|
declared, scan, read := cachedMCPConfigs()
|
||||||
|
if !scan.Supported {
|
||||||
|
scan.ProcessScan = "unsupported"
|
||||||
|
return []mcpServerObs{}, scan
|
||||||
|
}
|
||||||
|
procs, err := scanMCPProcessesFn(mcpWantedBases(declared))
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
scan.ProcessScan = "ok"
|
||||||
|
case agentScanSupported:
|
||||||
|
scan.ProcessScan = "failed"
|
||||||
|
default:
|
||||||
|
scan.ProcessScan = "unsupported"
|
||||||
|
}
|
||||||
|
if !read {
|
||||||
|
// No read of the files has finished yet (timed_out says so). With
|
||||||
|
// nothing known to be declared, every tool-server-looking process
|
||||||
|
// would read as running_not_declared, which nobody has checked.
|
||||||
|
return []mcpServerObs{}, scan
|
||||||
|
}
|
||||||
|
out := correlateMCP(declared, procs, err == nil)
|
||||||
|
if out == nil {
|
||||||
|
out = []mcpServerObs{}
|
||||||
|
}
|
||||||
|
if len(out) >= mcpMaxServers {
|
||||||
|
scan.Truncated = true
|
||||||
|
}
|
||||||
|
return out, scan
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpCache holds the last complete read of the config files. The heartbeat
|
||||||
|
// is every 60 s and the read is up to a thousand files across every account;
|
||||||
|
// like the scheduler inventory (schedCacheFor) it is redone every ten
|
||||||
|
// minutes, while the process correlation above runs on every beat. One read
|
||||||
|
// runs at a time, and the beats wait for it at most mcpReadTimeout IN TOTAL,
|
||||||
|
// counted from when it started: a home on a network mount that stopped
|
||||||
|
// answering holds that one goroutine, delays one beat by at most that much,
|
||||||
|
// never the beats after it, and never starts a second read.
|
||||||
|
var mcpCache struct {
|
||||||
|
sync.Mutex
|
||||||
|
at time.Time
|
||||||
|
have bool
|
||||||
|
declared []mcpServerObs
|
||||||
|
scan mcpConfigScan
|
||||||
|
inflight chan struct{}
|
||||||
|
started time.Time // when the read in flight began
|
||||||
|
gen int
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpCacheFor = 10 * time.Minute
|
||||||
|
|
||||||
|
// mcpReadTimeout is how long one beat waits for a read in progress.
|
||||||
|
var mcpReadTimeout = 20 * time.Second
|
||||||
|
|
||||||
|
// cachedMCPConfigs is the last complete read. read is false when no read has
|
||||||
|
// finished yet, so the list says nothing about what is declared.
|
||||||
|
func cachedMCPConfigs() (declared []mcpServerObs, scan mcpConfigScan, read bool) {
|
||||||
|
mcpCache.Lock()
|
||||||
|
if mcpCache.have && time.Since(mcpCache.at) < mcpCacheFor {
|
||||||
|
d, s := mcpCache.declared, mcpCache.scan
|
||||||
|
mcpCache.Unlock()
|
||||||
|
return d, s, true
|
||||||
|
}
|
||||||
|
done := mcpCache.inflight
|
||||||
|
if done == nil {
|
||||||
|
done = make(chan struct{})
|
||||||
|
mcpCache.inflight, mcpCache.started = done, time.Now()
|
||||||
|
gen := mcpCache.gen
|
||||||
|
go func() {
|
||||||
|
d, s := readMCPConfigs()
|
||||||
|
mcpCache.Lock()
|
||||||
|
if mcpCache.gen == gen {
|
||||||
|
mcpCache.declared, mcpCache.scan, mcpCache.at, mcpCache.have = d, s, time.Now(), true
|
||||||
|
mcpCache.inflight = nil
|
||||||
|
}
|
||||||
|
mcpCache.Unlock()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wait := mcpReadTimeout - time.Since(mcpCache.started)
|
||||||
|
mcpCache.Unlock()
|
||||||
|
timedOut := false
|
||||||
|
if wait <= 0 {
|
||||||
|
// An earlier beat already waited the whole time for this read.
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
default:
|
||||||
|
timedOut = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t := time.NewTimer(wait)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-t.C:
|
||||||
|
timedOut = true
|
||||||
|
}
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
mcpCache.Lock()
|
||||||
|
defer mcpCache.Unlock()
|
||||||
|
if !mcpCache.have {
|
||||||
|
// Nothing read yet: say so, rather than "no servers".
|
||||||
|
return nil, mcpConfigScan{Supported: true, TimedOut: true}, false
|
||||||
|
}
|
||||||
|
s := mcpCache.scan
|
||||||
|
s.TimedOut = timedOut
|
||||||
|
return mcpCache.declared, s, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetMCPConfigCache forgets the last read; a read still running is
|
||||||
|
// ignored when it finishes. Tests use it to start clean.
|
||||||
|
func resetMCPConfigCache() {
|
||||||
|
mcpCache.Lock()
|
||||||
|
defer mcpCache.Unlock()
|
||||||
|
mcpCache.gen++
|
||||||
|
mcpCache.have, mcpCache.declared, mcpCache.scan, mcpCache.inflight = false, nil, mcpConfigScan{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
llmIPCache map[string]string
|
llmIPCache map[string]string
|
||||||
llmIPCacheAt time.Time
|
llmIPCacheAt time.Time
|
||||||
@@ -554,11 +695,21 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
|
|||||||
// done for nothing, on the user's own hardware.
|
// done for nothing, on the user's own hardware.
|
||||||
var schedEntries []schedEntry
|
var schedEntries []schedEntry
|
||||||
var schedScan schedScanReport
|
var schedScan schedScanReport
|
||||||
|
// Declared tool servers (mcpconfig.go): the agent clients' own config
|
||||||
|
// files, correlated with the process list. Same gate and same
|
||||||
|
// quiet-vs-blind rule as the schedulers: read only for a pairing entitled
|
||||||
|
// to observe, and the capability travels only from a build that can.
|
||||||
|
var mcpServers []mcpServerObs
|
||||||
|
var mcpScan mcpConfigScan
|
||||||
if observing {
|
if observing {
|
||||||
schedEntries, schedScan = scanSchedulers()
|
schedEntries, schedScan = scanSchedulers()
|
||||||
if schedScan.Supported {
|
if schedScan.Supported {
|
||||||
caps = append(caps, "scheduler-observe")
|
caps = append(caps, "scheduler-observe")
|
||||||
}
|
}
|
||||||
|
mcpServers, mcpScan = mcpServersReport()
|
||||||
|
if mcpScan.Supported {
|
||||||
|
caps = append(caps, "tool-server-observe")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Context preparation (contextprep.go): the tools on this box that
|
// Context preparation (contextprep.go): the tools on this box that
|
||||||
// turn a user's bytes into their content — a PDF's text, an image's OCR.
|
// turn a user's bytes into their content — a PDF's text, an image's OCR.
|
||||||
@@ -674,6 +825,13 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
|
|||||||
// that read 0 crontabs is visibly different from one that read 40.
|
// that read 0 crontabs is visibly different from one that read 40.
|
||||||
body["schedulers"] = schedEntries
|
body["schedulers"] = schedEntries
|
||||||
body["scheduler_scan"] = schedScan
|
body["scheduler_scan"] = schedScan
|
||||||
|
// The tool servers each agent client declares, and which are running
|
||||||
|
// (or running with no declaration). Paths, server names and command
|
||||||
|
// lines, so inside this gate with the agent list; env values and
|
||||||
|
// header values never leave mcpconfig.go, only their names and
|
||||||
|
// whether one looked like a secret.
|
||||||
|
body["tool_servers"] = mcpServers
|
||||||
|
body["tool_server_scan"] = mcpScan
|
||||||
// Where this machine keeps its own record — the findings journal and
|
// Where this machine keeps its own record — the findings journal and
|
||||||
// the signed accounting (machinestore.go, baselinesign.go). The host
|
// the signed accounting (machinestore.go, baselinesign.go). The host
|
||||||
// drafts a machine store at this root for the user to approve, once;
|
// drafts a machine store at this root for the user to approve, once;
|
||||||
|
|||||||
@@ -101,6 +101,79 @@ func scanProcesses() (map[int]agentObs, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownerFactsReader answers the three owner facts agentowner.go decides on.
|
||||||
|
// macOS keeps accounts in Open Directory, not /etc/passwd, so it asks the
|
||||||
|
// system's own tools, each a fixed argv under runTimed's timeout: `dscl .`
|
||||||
|
// (the LOCAL node only) for the uid and whether the account is a mobile copy
|
||||||
|
// of a directory account, `id -Gn` for its groups (nested groups resolved by
|
||||||
|
// the system), and `who` once per scan for who is logged in. Answers are kept
|
||||||
|
// per account for the scan, so ten agents of one person cost three commands.
|
||||||
|
func ownerFactsReader() func(pid int, user string) ownerFacts {
|
||||||
|
var sessions map[string]bool
|
||||||
|
sessionsOK, sessionsRead := false, false
|
||||||
|
cache := map[string]ownerFacts{}
|
||||||
|
return func(pid int, user string) ownerFacts {
|
||||||
|
if user == "" || strings.ContainsAny(user, "/ \t\n") {
|
||||||
|
return unknownOwner
|
||||||
|
}
|
||||||
|
if f, ok := cache[user]; ok {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
if !sessionsRead {
|
||||||
|
sessionsRead = true
|
||||||
|
if out, err := runTimed("who"); err == nil {
|
||||||
|
sessions, sessionsOK = parseWhoUsers(string(out)), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o := unixOwner{UID: -1, Name: user, LocalRecord: ownerUnknown,
|
||||||
|
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
|
||||||
|
Sessions: sessions, SessionsKnown: sessionsOK}
|
||||||
|
out, err := runTimed("dscl", ".", "-read", "/Users/"+user, "UniqueID", "OriginalNodeName")
|
||||||
|
if err == nil {
|
||||||
|
if uid, mobile, ok := parseDsclUser(string(out)); ok {
|
||||||
|
o.UID = uid
|
||||||
|
o.LocalRecord = ownerYes
|
||||||
|
if mobile {
|
||||||
|
o.LocalRecord, o.DirectorySource = ownerNo, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if dsclRecordMissing(err) {
|
||||||
|
// dscl ran and has no such record in the local node: the account
|
||||||
|
// comes from a directory. The uid then has to come from id.
|
||||||
|
o.LocalRecord, o.DirectorySource = ownerNo, true
|
||||||
|
if out, err := runTimed("id", "-u", "--", user); err == nil {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
|
||||||
|
o.UID = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, err := runTimed("id", "-Gn", "--", user); err == nil {
|
||||||
|
o.Groups, o.GroupsKnown = parseIDGroups(string(out)), true
|
||||||
|
}
|
||||||
|
f := classifyUnixOwner(o)
|
||||||
|
cache[user] = f
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
|
||||||
|
// servers against: one `ps` sweep, every process with its parent and
|
||||||
|
// arguments. An error is a sweep that did not happen.
|
||||||
|
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
|
||||||
|
_, argvs, parents, err := psSweep()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
self := os.Getpid()
|
||||||
|
out := make([]mcpProc, 0, len(argvs))
|
||||||
|
for pid, argv := range argvs {
|
||||||
|
if pid != self {
|
||||||
|
out = append(out, mcpProc{PID: pid, PPID: parents[pid], Argv: argv})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// procParent answers from the sweep scanProcesses just took.
|
// procParent answers from the sweep scanProcesses just took.
|
||||||
func procParent(pid int) int {
|
func procParent(pid int) int {
|
||||||
psCache.Lock()
|
psCache.Lock()
|
||||||
|
|||||||
@@ -64,6 +64,35 @@ func scanProcesses() (map[int]agentObs, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
|
||||||
|
// servers against: every process, with its parent and command line, from one
|
||||||
|
// /proc walk. The want set only matters on Windows, where reading a command
|
||||||
|
// line costs a handle per process. An error is a walk that did not happen,
|
||||||
|
// and then no running state is guessed.
|
||||||
|
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
|
||||||
|
entries, err := os.ReadDir(procRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", procRoot, err)
|
||||||
|
}
|
||||||
|
self := os.Getpid()
|
||||||
|
var out []mcpProc
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || pid == self || !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := mcpProc{PID: pid}
|
||||||
|
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/stat"); err == nil {
|
||||||
|
p.PPID = parseProcStatPPID(string(raw))
|
||||||
|
}
|
||||||
|
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/cmdline"); err == nil {
|
||||||
|
p.Argv = splitNul(raw)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// procParent reads one process's parent pid from /proc/<pid>/stat.
|
// procParent reads one process's parent pid from /proc/<pid>/stat.
|
||||||
func procParent(pid int) int {
|
func procParent(pid int) int {
|
||||||
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||||
@@ -126,6 +155,109 @@ func procUser(pid int) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownerFactsReader answers, per process, the three facts about its owner that
|
||||||
|
// agentowner.go decides on. The machine-wide tables (/etc/passwd, /etc/group,
|
||||||
|
// login.defs, nsswitch.conf, the sudo rules, who is logged in) are read once
|
||||||
|
// per scan, on first use, and every process looks itself up in them.
|
||||||
|
//
|
||||||
|
// The uid and groups come from /proc/<pid>/status, so they are what the
|
||||||
|
// PROCESS holds, the same source procUser names it from.
|
||||||
|
func ownerFactsReader() func(pid int, user string) ownerFacts {
|
||||||
|
var (
|
||||||
|
loaded bool
|
||||||
|
groupNames map[int]string
|
||||||
|
groupsOK bool
|
||||||
|
passwd map[int]bool
|
||||||
|
passwdOK bool
|
||||||
|
sysMax int
|
||||||
|
directory bool
|
||||||
|
sudoers map[string]bool
|
||||||
|
sudoKnown bool
|
||||||
|
sessions map[string]bool
|
||||||
|
sessionsOK bool
|
||||||
|
logindUsers = identityPath("/run/systemd/users")
|
||||||
|
)
|
||||||
|
load := func() {
|
||||||
|
loaded = true
|
||||||
|
if b, err := os.ReadFile(identityPath("/etc/group")); err == nil {
|
||||||
|
groupNames, groupsOK = parseGroupNames(string(b)), true
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(identityPath("/etc/passwd")); err == nil {
|
||||||
|
passwd, passwdOK = parsePasswdUIDs(string(b)), true
|
||||||
|
}
|
||||||
|
sysMax = linuxSystemUIDMax
|
||||||
|
if b, err := os.ReadFile(identityPath("/etc/login.defs")); err == nil {
|
||||||
|
sysMax = parseLoginDefsSysMax(string(b))
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(identityPath("/etc/nsswitch.conf")); err == nil {
|
||||||
|
directory = nsswitchHasDirectory(string(b))
|
||||||
|
}
|
||||||
|
var partial string
|
||||||
|
sudoers, partial = sudoPrincipals()
|
||||||
|
sudoKnown = partial == ""
|
||||||
|
// Who has a session: logind's own record when it runs, else utmp.
|
||||||
|
// Neither readable leaves the answer unknown rather than "nobody".
|
||||||
|
if _, err := os.Stat(logindUsers); err != nil {
|
||||||
|
if b, err := os.ReadFile(identityPath("/var/run/utmp")); err == nil {
|
||||||
|
sessions, sessionsOK = parseUtmpUsers(b), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return func(pid int, user string) ownerFacts {
|
||||||
|
if !loaded {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(procRoot + "/" + strconv.Itoa(pid) + "/status")
|
||||||
|
if err != nil {
|
||||||
|
return unknownOwner
|
||||||
|
}
|
||||||
|
realUID, effUID, gids, ok := parseStatusIDs(string(raw))
|
||||||
|
if !ok {
|
||||||
|
return unknownOwner
|
||||||
|
}
|
||||||
|
o := unixOwner{UID: realUID, Name: user, LocalRecord: ownerUnknown, DirectorySource: directory,
|
||||||
|
SystemUIDMax: sysMax, AdminGroups: linuxAdminGroups, SudoApplies: true,
|
||||||
|
SudoNamed: sudoers[user], SudoKnown: sudoKnown}
|
||||||
|
if passwdOK {
|
||||||
|
o.LocalRecord = yesNo(passwd[realUID])
|
||||||
|
}
|
||||||
|
if groupsOK {
|
||||||
|
o.GroupsKnown = true
|
||||||
|
for _, g := range gids {
|
||||||
|
if n, ok := groupNames[g]; ok {
|
||||||
|
o.Groups = append(o.Groups, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case sessionsOK:
|
||||||
|
o.Sessions, o.SessionsKnown = sessions, true
|
||||||
|
default:
|
||||||
|
b, err := os.ReadFile(logindUsers + "/" + strconv.Itoa(realUID))
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
if live, ok := parseLogindUserState(string(b)); ok {
|
||||||
|
o.Sessions, o.SessionsKnown = map[string]bool{user: live}, true
|
||||||
|
}
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
// logind keeps a file for every user it tracks; none means
|
||||||
|
// this account has no session and no lingering services.
|
||||||
|
if _, derr := os.Stat(logindUsers); derr == nil {
|
||||||
|
o.Sessions, o.SessionsKnown = map[string]bool{}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f := classifyUnixOwner(o)
|
||||||
|
if effUID == 0 {
|
||||||
|
// A process running with effective uid 0 holds root whatever
|
||||||
|
// account started it: that is the administrator answer that
|
||||||
|
// matters, while the class stays the account's own.
|
||||||
|
f.Admin = ownerYes
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func procCmdline(pid int) string {
|
func procCmdline(pid int) string {
|
||||||
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
|
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -34,3 +34,11 @@ func procEnvToken(pid int) string { return "" }
|
|||||||
func procExe(pid int) string { return "" }
|
func procExe(pid int) string { return "" }
|
||||||
func procParent(pid int) int { return 0 }
|
func procParent(pid int) int { return 0 }
|
||||||
func processParents() map[int]int { return map[int]int{} }
|
func processParents() map[int]int { return map[int]int{} }
|
||||||
|
|
||||||
|
// ownerFactsReader: no reader here, so every owner fact is unknown.
|
||||||
|
func ownerFactsReader() func(pid int, user string) ownerFacts {
|
||||||
|
return func(int, string) ownerFacts { return unknownOwner }
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanMCPProcesses: no enumerator, so running state is never measured here.
|
||||||
|
func scanMCPProcesses(map[string]bool) ([]mcpProc, error) { return nil, errNoEnumerator }
|
||||||
|
|||||||
+169
-5
@@ -306,6 +306,7 @@ func processParents() map[int]int {
|
|||||||
// scanProcesses: an error is a snapshot that did not happen (Toolhelp can
|
// scanProcesses: an error is a snapshot that did not happen (Toolhelp can
|
||||||
// fail transiently), never an empty box; the heartbeat withholds `agents`.
|
// fail transiently), never an empty box; the heartbeat withholds `agents`.
|
||||||
func scanProcesses() (map[int]agentObs, error) {
|
func scanProcesses() (map[int]agentObs, error) {
|
||||||
|
winBeginSIDScan()
|
||||||
out := map[int]agentObs{}
|
out := map[int]agentObs{}
|
||||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -390,6 +391,50 @@ func scanProcesses() (map[int]agentObs, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
|
||||||
|
// servers against. Every process comes from one Toolhelp snapshot with its
|
||||||
|
// parent; the command line, which costs a handle and a memory read, is read
|
||||||
|
// only for a program in want (interpreters, package runners, and every
|
||||||
|
// program a declared server names) or whose own name carries the word mcp.
|
||||||
|
// The rest keep their parent link with no command line, which is all the
|
||||||
|
// correlation needs from them. An error is a snapshot that did not happen.
|
||||||
|
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
|
||||||
|
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("process snapshot: %w", err)
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(snap)
|
||||||
|
var e windows.ProcessEntry32
|
||||||
|
e.Size = uint32(unsafe.Sizeof(e))
|
||||||
|
self := os.Getpid()
|
||||||
|
var out []mcpProc
|
||||||
|
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
|
||||||
|
pid := int(e.ProcessID)
|
||||||
|
if pid <= 0 || pid == self {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := mcpProc{PID: pid, PPID: int(e.ParentProcessID)}
|
||||||
|
exe := windows.UTF16ToString(e.ExeFile[:])
|
||||||
|
if base := binBase(exe); want[base] || mcpWord(base) {
|
||||||
|
cmdline := ""
|
||||||
|
if h, herr := winOpenForRead(pid); herr == nil {
|
||||||
|
cmdline = winCmdline(h)
|
||||||
|
windows.CloseHandle(h)
|
||||||
|
}
|
||||||
|
if argv, derr := windows.DecomposeCommandLine(cmdline); derr == nil && len(argv) > 0 {
|
||||||
|
p.Argv = argv
|
||||||
|
} else {
|
||||||
|
p.Argv = []string{exe}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||||
|
return nil, fmt.Errorf("process walk: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ── connections: GetExtendedTcpTable ────────────────────────────────────────
|
// ── connections: GetExtendedTcpTable ────────────────────────────────────────
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -463,14 +508,133 @@ func winTokenUser(h windows.Handle) string {
|
|||||||
if err != nil || tu.User.Sid == nil {
|
if err != nil || tu.User.Sid == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
account, domain, _, err := tu.User.Sid.LookupAccount("")
|
return winAccountLabel(tu.User.Sid.String(), winScanSIDNames())
|
||||||
|
}
|
||||||
|
|
||||||
|
// winSIDScan is the current scan's account-name cache (sidnames.go), shared
|
||||||
|
// by winTokenUser and ownerFactsReader so a SID is looked up once per scan
|
||||||
|
// and the scan's lookups stay within sidLookupBudget. scanProcesses starts a
|
||||||
|
// new one; the readers that run after it in scanAgents use that one.
|
||||||
|
var winSIDScan struct {
|
||||||
|
sync.Mutex
|
||||||
|
names *sidNames
|
||||||
|
}
|
||||||
|
|
||||||
|
func winBeginSIDScan() *sidNames {
|
||||||
|
n := newSIDNames(winLookupSID, sidLookupBudget, sidLookupSlots)
|
||||||
|
winSIDScan.Lock()
|
||||||
|
winSIDScan.names = n
|
||||||
|
winSIDScan.Unlock()
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func winScanSIDNames() *sidNames {
|
||||||
|
winSIDScan.Lock()
|
||||||
|
n := winSIDScan.names
|
||||||
|
winSIDScan.Unlock()
|
||||||
|
if n == nil {
|
||||||
|
return winBeginSIDScan()
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// winLookupSID is LookupAccountSid on this machine for one SID string.
|
||||||
|
func winLookupSID(s string) (string, string, error) {
|
||||||
|
a, err := winLookupSIDAccount(s)
|
||||||
|
return a.Account, a.Domain, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// winLookupSIDAccount is LookupAccountSid with the SID's type. Unbounded:
|
||||||
|
// callers go through sidNames or winAccounts.
|
||||||
|
func winLookupSIDAccount(s string) (sidAccount, error) {
|
||||||
|
sid, err := windows.StringToSid(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tu.User.Sid.String()
|
return sidAccount{}, err
|
||||||
}
|
}
|
||||||
if domain != "" {
|
account, domain, typ, err := sid.LookupAccount("")
|
||||||
return domain + `\` + account
|
return sidAccount{Account: account, Domain: domain, Type: typ}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// winAccounts is the process-wide account cache for lookups made outside a
|
||||||
|
// scan: the approvals pipe and the folder agent's drop (sidnames.go).
|
||||||
|
var winAccounts = newSIDDirectory(winLookupSIDAccount)
|
||||||
|
|
||||||
|
// ownerFactsReader answers the three owner facts agentowner.go decides on,
|
||||||
|
// from each process's own token: its user SID and that SID's domain, and its
|
||||||
|
// group SIDs (deny-only ones included, which is how an administrator running
|
||||||
|
// without elevation still reads as an administrator account). The session
|
||||||
|
// list is read once per scan and needs LocalSystem, which the system service
|
||||||
|
// is; elsewhere it is left unknown and the token's own logon type answers
|
||||||
|
// what it can (classifyWinOwner). The domain comes from the scan's shared
|
||||||
|
// account-name cache (winScanSIDNames), so a SID winTokenUser already looked
|
||||||
|
// up is not asked again, and an unreachable domain controller leaves the
|
||||||
|
// class unknown instead of holding the scan.
|
||||||
|
func ownerFactsReader() func(pid int, user string) ownerFacts {
|
||||||
|
computer, _ := windows.ComputerName()
|
||||||
|
names := winScanSIDNames()
|
||||||
|
var sessions map[string]bool
|
||||||
|
sessionsOK, sessionsRead := false, false
|
||||||
|
return func(pid int, _ string) ownerFacts {
|
||||||
|
if !sessionsRead {
|
||||||
|
sessionsRead = true
|
||||||
|
sessions, sessionsOK = winSessionSIDs()
|
||||||
|
}
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||||
|
if err != nil {
|
||||||
|
return unknownOwner
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
var tok windows.Token
|
||||||
|
if err := windows.OpenProcessToken(h, windows.TOKEN_QUERY, &tok); err != nil {
|
||||||
|
return unknownOwner
|
||||||
|
}
|
||||||
|
defer tok.Close()
|
||||||
|
o := winOwner{Computer: computer, SessionSIDs: sessions, SessionsKnown: sessionsOK}
|
||||||
|
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
|
||||||
|
o.SID = tu.User.Sid.String()
|
||||||
|
o.Domain = winAccountDomain(o.SID, names)
|
||||||
|
}
|
||||||
|
if tg, err := tok.GetTokenGroups(); err == nil {
|
||||||
|
o.Groups, o.GroupsKnown = map[string]bool{}, true
|
||||||
|
for _, g := range tg.AllGroups() {
|
||||||
|
if g.Sid != nil {
|
||||||
|
o.Groups[g.Sid.String()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return classifyWinOwner(o)
|
||||||
}
|
}
|
||||||
return account
|
}
|
||||||
|
|
||||||
|
// winSessionSIDs is the set of user SIDs with an active or disconnected
|
||||||
|
// Remote Desktop Services session (the console is one). ok is false when the
|
||||||
|
// list could not be read: WTSQueryUserToken needs LocalSystem's privilege.
|
||||||
|
func winSessionSIDs() (map[string]bool, bool) {
|
||||||
|
var info *windows.WTS_SESSION_INFO
|
||||||
|
var count uint32
|
||||||
|
if err := windows.WTSEnumerateSessions(0, 0, 1, &info, &count); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
|
||||||
|
list := unsafe.Slice(info, count)
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, s := range list {
|
||||||
|
if s.State != windows.WTSActive && s.State != windows.WTSDisconnected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var tok windows.Token
|
||||||
|
if err := windows.WTSQueryUserToken(s.SessionID, &tok); err != nil {
|
||||||
|
if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
continue // a session with no user (the services session, a login screen)
|
||||||
|
}
|
||||||
|
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
|
||||||
|
out[tu.User.Sid.String()] = true
|
||||||
|
}
|
||||||
|
tok.Close()
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func procUser(pid int) string {
|
func procUser(pid int) string {
|
||||||
|
|||||||
@@ -978,6 +978,20 @@ func cmdRepinAnchor() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anchorAlreadyHolds: the anchor names an active pairing here, and host
|
||||||
|
// (claim-anchor --host, "" for none) names that same pairing.
|
||||||
|
func anchorAlreadyHolds(cfgs []*Config, prev *anchorClaim, host string) bool {
|
||||||
|
c := pairingFor(cfgs, prev.AnchorHost)
|
||||||
|
if c == nil || !c.active() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
named, err := pickPairing(cfgs, host)
|
||||||
|
return err == nil && named.HostName == c.HostName
|
||||||
|
}
|
||||||
|
|
||||||
// pickPairing resolves --host against this box's pairings. It accepts the full
|
// pickPairing resolves --host against this box's pairings. It accepts the full
|
||||||
// host_name the box reports, or just the handle in front of it, because the
|
// host_name the box reports, or just the handle in front of it, because the
|
||||||
// name the user is looking at depends on which surface they read it from —
|
// name the user is looking at depends on which surface they read it from —
|
||||||
@@ -1021,6 +1035,17 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
prev := currentAnchor()
|
prev := currentAnchor()
|
||||||
|
// Already anchored to the host this asks for (no token, no other host
|
||||||
|
// named), and that host's pairing is active: done, not an error. `pair`
|
||||||
|
// anchors a networked box to its first host by itself, and an installer
|
||||||
|
// that runs claim-anchor after pairing must find that a success. An anchor
|
||||||
|
// whose pairing is pending or revoked is not done, and is refused below as
|
||||||
|
// before. --host takes the short handle as well as the full name
|
||||||
|
// (pickPairing), so the same host named either way is the same request.
|
||||||
|
if prev != nil && !rotate && token == "" && anchorAlreadyHolds(cfgs, prev, host) {
|
||||||
|
fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if prev != nil && !rotate {
|
if prev != nil && !rotate {
|
||||||
return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate <token>` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24))
|
return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate <token>` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -658,14 +658,58 @@ func TestClaimAnchorWithNoToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("already anchored — still refused without --rotate", func(t *testing.T) {
|
t.Run("already anchored to that host — success, and nothing changes", func(t *testing.T) {
|
||||||
|
// `pair` anchors a networked box by itself, and an installer that runs
|
||||||
|
// claim-anchor afterwards must find that done, not an error.
|
||||||
freshBox(t, true)
|
freshBox(t, true)
|
||||||
writePairing(t, tMember)
|
writePairing(t, tMember)
|
||||||
if err := writeAnchor("Acme", tMember); err != nil {
|
if err := writeAnchor("Acme", tMember); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "already anchored") {
|
if err := cmdClaimAnchor("", false, "", ""); err != nil {
|
||||||
t.Fatalf("dropping the token must not let a second claim through: %v", err)
|
t.Fatalf("claim-anchor on a box already anchored to its host must succeed: %v", err)
|
||||||
|
}
|
||||||
|
if err := cmdClaimAnchor("", false, "", tMember); err != nil {
|
||||||
|
t.Fatalf("naming the anchor host itself must succeed: %v", err)
|
||||||
|
}
|
||||||
|
if err := cmdClaimAnchor("", false, "", handleOf(tMember)); err != nil {
|
||||||
|
t.Fatalf("naming the anchor host by its handle (as --host accepts) must succeed: %v", err)
|
||||||
|
}
|
||||||
|
if a := currentAnchor(); a == nil || a.AnchorHost != tMember || a.Owner != "Acme" {
|
||||||
|
t.Fatalf("the anchor must be untouched: %+v", a)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("anchored to a host whose pairing is pending or revoked — not reported done", func(t *testing.T) {
|
||||||
|
// A success here would tell an installer the anchor holds while its
|
||||||
|
// host has no active lanes on this box.
|
||||||
|
for _, st := range []string{pairingPending, pairingRevoked} {
|
||||||
|
freshBox(t, true)
|
||||||
|
writePairingState(t, tMember, st)
|
||||||
|
if err := writeAnchor("Acme", tMember); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cmdClaimAnchor("", false, "", ""); err == nil {
|
||||||
|
t.Fatalf("anchor pairing %s: claim-anchor must not report the anchor done", st)
|
||||||
|
}
|
||||||
|
if got := loadState(t, tMember); got != st {
|
||||||
|
t.Fatalf("anchor pairing %s: state changed to %q", st, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("already anchored — another host is still refused without --rotate", func(t *testing.T) {
|
||||||
|
freshBox(t, true)
|
||||||
|
writePairing(t, tMember)
|
||||||
|
writePairing(t, tAnchor)
|
||||||
|
if err := writeAnchor("Acme", tMember); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cmdClaimAnchor("", false, "", tAnchor); err == nil || !contains(err.Error(), "already anchored") {
|
||||||
|
t.Fatalf("naming a different host must not move the anchor without --rotate: %v", err)
|
||||||
|
}
|
||||||
|
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
|
||||||
|
t.Fatalf("the anchor must be untouched: %+v", a)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,30 @@ type pipeClient struct {
|
|||||||
// "" when nobody is.
|
// "" when nobody is.
|
||||||
SessionUserSID string
|
SessionUserSID string
|
||||||
SessionActive bool // WTSActive: the session someone is in front of
|
SessionActive bool // WTSActive: the session someone is in front of
|
||||||
|
// NameTimedOut: the account directory did not answer for UserSID within
|
||||||
|
// pipeSIDLane's budget, so UserName is empty and nobody can say whose
|
||||||
|
// account approved. pipeApprove refuses; list and deny go on, by SID.
|
||||||
|
NameTimedOut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// pipeSIDLane bounds the client's account-name lookup (sidnames.go). The tray
|
||||||
|
// waits 2*pipeIOTimeout for its answer, and the name is looked up before the
|
||||||
|
// request is read, so the lookup gets well under that: a directory that does
|
||||||
|
// not answer costs one connection 2 s, and holds at most one goroutine for
|
||||||
|
// the pipe however many connections come.
|
||||||
|
var pipeSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 2 * time.Second}
|
||||||
|
|
||||||
|
// pipeClientName fills c.UserName from the account directory, or sets
|
||||||
|
// c.NameTimedOut when the directory did not answer in time. A SID with no
|
||||||
|
// account leaves UserName empty: the log shows the SID, as before.
|
||||||
|
func pipeClientName(c *pipeClient, dir *sidDirectory, lane sidLane) {
|
||||||
|
acct, outcome := dir.resolve(c.UserSID, lane)
|
||||||
|
switch outcome {
|
||||||
|
case sidResolved:
|
||||||
|
c.UserName = sidAccountLabel(acct)
|
||||||
|
case sidTimedOut:
|
||||||
|
c.NameTimedOut = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// purposeDigest is the sha256 of a purpose as the tray carries it back.
|
// purposeDigest is the sha256 of a purpose as the tray carries it back.
|
||||||
@@ -330,6 +354,14 @@ func pipeTarget(req pipeRequest) (dir string, r stepupRequest, err error) {
|
|||||||
var pipeApproveMu sync.Mutex
|
var pipeApproveMu sync.Mutex
|
||||||
|
|
||||||
func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
|
func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
|
||||||
|
// FAILS CLOSED ON A SLOW DIRECTORY. The rules above decide by SID, but an
|
||||||
|
// approval's audit line names the account that gave it, and when the
|
||||||
|
// directory could not say whose account this is, nothing is approved.
|
||||||
|
// Before the throttle and the code, so this counts as no wrong code.
|
||||||
|
if c.NameTimedOut {
|
||||||
|
return pipeResponse{Error: "not approved: this machine's account directory did not answer for " + c.UserSID +
|
||||||
|
" in time; try again in a minute, or approve from an Administrator terminal"}
|
||||||
|
}
|
||||||
pipeApproveMu.Lock()
|
pipeApproveMu.Lock()
|
||||||
defer pipeApproveMu.Unlock()
|
defer pipeApproveMu.Unlock()
|
||||||
code := strings.TrimSpace(req.Code)
|
code := strings.TrimSpace(req.Code)
|
||||||
|
|||||||
@@ -311,12 +311,9 @@ func pipeClientOf(h windows.Handle) (pipeClient, error) {
|
|||||||
return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
|
return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
|
||||||
}
|
}
|
||||||
c.UserSID = u.User.Sid.String()
|
c.UserSID = u.User.Sid.String()
|
||||||
if name, dom, _, err := u.User.Sid.LookupAccount(""); err == nil {
|
// Bounded and cached (sidnames.go): a directory that does not answer
|
||||||
c.UserName = name
|
// leaves NameTimedOut set, and pipeApprove refuses.
|
||||||
if dom != "" {
|
pipeClientName(&c, winAccounts, pipeSIDLane)
|
||||||
c.UserName = dom + `\` + name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var sess, n uint32
|
var sess, n uint32
|
||||||
if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil {
|
if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil {
|
||||||
return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err)
|
return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err)
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Audit rows to the system log, as well as to their files.
|
||||||
|
//
|
||||||
|
// Every audit file on this box (mgmt_audit.jsonl, pairing_audit.jsonl, and a
|
||||||
|
// pairing's audit.jsonl for terminals, root commands, store ops and op runs)
|
||||||
|
// lives here, on this machine. The host can fetch them over the tunnel but
|
||||||
|
// never edit them (mgmt_audit.go handleAuditFetch). That still leaves the host
|
||||||
|
// as the only central view of a fleet, and the host is the agent the audit is
|
||||||
|
// about. So each row is also written to the machine's own system log, where
|
||||||
|
// whatever already collects that log (journald or syslog forwarding, a log
|
||||||
|
// shipper, a SIEM) takes it to a store the host cannot write:
|
||||||
|
//
|
||||||
|
// Linux, macOS syslog, facility auth, tag privatae-bastion-audit
|
||||||
|
// (journald on a systemd machine: SYSLOG_IDENTIFIER=privatae-bastion-audit)
|
||||||
|
// Windows the Application event log, source PrivataeBastionAudit, event id 1
|
||||||
|
//
|
||||||
|
// The system-log line is the file's row with two fields added in front of it:
|
||||||
|
// `audit`, which file the row went to, and `pairing`, the pairing it belongs to
|
||||||
|
// ("" for a box-level file). The file stays the record: a system log that is
|
||||||
|
// missing or refuses a write costs that copy and nothing else, and is never a
|
||||||
|
// reason to refuse or slow the op being audited.
|
||||||
|
//
|
||||||
|
// A root command's text is the one thing the file has that the system-log line
|
||||||
|
// does not: it is whatever the host typed, a password on a command line
|
||||||
|
// included, and the system log is readable by more accounts than the 0600 file
|
||||||
|
// and forwarded off the machine by design. The line keeps command_sha256 and
|
||||||
|
// command_bytes, so a collector can still match it to the file and the
|
||||||
|
// transcript and see if either was changed.
|
||||||
|
//
|
||||||
|
// The write itself never runs on the audited op's goroutine. The syslog socket
|
||||||
|
// is a blocking write: a journald or syslog daemon that is stuck or slow would
|
||||||
|
// otherwise hold every audited op, and every other audit writer behind the
|
||||||
|
// lock, for as long as it stays stuck. Lines go into a bounded queue that one
|
||||||
|
// goroutine drains; a full queue drops the line and counts it, and the next
|
||||||
|
// line queued after the gap carries the count (auditDroppedField).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
auditSyslogTag = "privatae-bastion-audit"
|
||||||
|
auditEventSource = "PrivataeBastionAudit"
|
||||||
|
auditEventID = 1
|
||||||
|
auditFilePairing = "pairing_audit.jsonl"
|
||||||
|
auditFilePairingD = "audit.jsonl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditSink takes one line for the system log. A variable so a test can watch
|
||||||
|
// the lines; the default queues it for the platform's writer and never blocks.
|
||||||
|
var auditSink = auditEnqueue
|
||||||
|
|
||||||
|
const (
|
||||||
|
// auditQueueMax lines wait for the system log; beyond that they are
|
||||||
|
// dropped and counted. A burst of audited ops far larger than any lane
|
||||||
|
// produces fits; a system log stuck for good holds no more than this.
|
||||||
|
auditQueueMax = 4096
|
||||||
|
// auditDroppedField is added to the first line queued after a drop: how
|
||||||
|
// many lines the system log lost just before it, so the gap shows where.
|
||||||
|
auditDroppedField = "system_log_dropped"
|
||||||
|
// auditFlushOnExit bounds how long a finished command waits for its rows
|
||||||
|
// to reach a slow system log.
|
||||||
|
auditFlushOnExit = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditQueue is the bounded queue in front of a system-log writer, drained by
|
||||||
|
// one goroutine so lines keep their order.
|
||||||
|
type auditQueue struct {
|
||||||
|
ch chan []byte
|
||||||
|
write func([]byte)
|
||||||
|
once sync.Once
|
||||||
|
dropped atomic.Int64
|
||||||
|
pending atomic.Int64 // queued or being written
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuditQueue(n int, write func([]byte)) *auditQueue {
|
||||||
|
return &auditQueue{ch: make(chan []byte, n), write: write}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The box's one queue, in front of systemAuditWrite (auditlog_*.go).
|
||||||
|
var auditQ = newAuditQueue(auditQueueMax, func(l []byte) { systemAuditWrite(l) })
|
||||||
|
|
||||||
|
func auditEnqueue(line []byte) { auditQ.enqueue(line) }
|
||||||
|
|
||||||
|
// auditFlush waits, at most d, for queued lines to reach the system log. A
|
||||||
|
// command that audits and then exits (pair, claim-anchor, set-mode) calls it
|
||||||
|
// on the way out (main.go), so its rows are not lost with the process; a
|
||||||
|
// stuck system log costs that wait and nothing more.
|
||||||
|
func auditFlush(d time.Duration) { auditQ.flush(d) }
|
||||||
|
|
||||||
|
// enqueue hands one line to the drain goroutine, or drops it and counts it
|
||||||
|
// when the queue is full. It never waits on the writer.
|
||||||
|
func (q *auditQueue) enqueue(line []byte) {
|
||||||
|
q.once.Do(func() { go q.drain() })
|
||||||
|
n := q.dropped.Swap(0)
|
||||||
|
if n > 0 {
|
||||||
|
line = auditWithDropped(line, n)
|
||||||
|
}
|
||||||
|
q.pending.Add(1)
|
||||||
|
select {
|
||||||
|
case q.ch <- line:
|
||||||
|
default:
|
||||||
|
q.pending.Add(-1)
|
||||||
|
q.dropped.Add(n + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *auditQueue) drain() {
|
||||||
|
for line := range q.ch {
|
||||||
|
q.write(line)
|
||||||
|
q.pending.Add(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *auditQueue) flush(d time.Duration) {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
for q.pending.Load() > 0 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditWithDropped adds the drop count to a line (every line here is the
|
||||||
|
// JSON object auditToSystemLog made).
|
||||||
|
func auditWithDropped(line []byte, n int64) []byte {
|
||||||
|
fields := map[string]any{}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(line))
|
||||||
|
dec.UseNumber()
|
||||||
|
if err := dec.Decode(&fields); err != nil {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
fields[auditDroppedField] = n
|
||||||
|
out, err := json.Marshal(fields)
|
||||||
|
if err != nil {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditToSystemLog sends one audit row, as it was written to its file, to the
|
||||||
|
// system log, with the file's name and the pairing in front.
|
||||||
|
func auditToSystemLog(file, pairing string, rec []byte) {
|
||||||
|
fields := map[string]any{}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(rec))
|
||||||
|
dec.UseNumber() // numbers go out exactly as the file has them
|
||||||
|
if err := dec.Decode(&fields); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cmd, ok := fields["command"].(string); ok {
|
||||||
|
// The text stays in the file; see the top of this file.
|
||||||
|
delete(fields, "command")
|
||||||
|
if _, ok := fields["command_bytes"]; !ok {
|
||||||
|
fields["command_bytes"] = len(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields["audit"] = file
|
||||||
|
fields["pairing"] = pairing
|
||||||
|
line, err := json.Marshal(fields)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditSink(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditPairingOfDir names the pairing of a per-pairing audit file from its
|
||||||
|
// directory: the last path element, which is the pairing's host name
|
||||||
|
// (Config.dcnDir: dcn/<host_name>), except for a legacy pairing that kept the
|
||||||
|
// old shared directory, where it is that directory's name.
|
||||||
|
func auditPairingOfDir(dir string) string {
|
||||||
|
if dir == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Base(filepath.Clean(dir))
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/syslog"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
auditSyslogMu sync.Mutex
|
||||||
|
auditSyslog *syslog.Writer
|
||||||
|
)
|
||||||
|
|
||||||
|
// systemAuditWrite sends one line to syslog (journald on a systemd machine),
|
||||||
|
// facility auth, at info. Called only from auditQ's drain (auditlog.go), never on
|
||||||
|
// an audited op's own goroutine: the write blocks while the daemon is stuck. The connection is opened on first use and dropped
|
||||||
|
// after a failed write, so the next row tries again; a machine with no syslog
|
||||||
|
// socket costs the copy, never the row in the file.
|
||||||
|
func systemAuditWrite(line []byte) {
|
||||||
|
auditSyslogMu.Lock()
|
||||||
|
defer auditSyslogMu.Unlock()
|
||||||
|
if auditSyslog == nil {
|
||||||
|
w, err := syslog.New(syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditSyslog = w
|
||||||
|
}
|
||||||
|
if err := auditSyslog.Info(string(line)); err != nil {
|
||||||
|
_ = auditSyslog.Close()
|
||||||
|
auditSyslog = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerAuditEventSource has nothing to register outside Windows: syslog
|
||||||
|
// takes any tag.
|
||||||
|
func registerAuditEventSource() error { return nil }
|
||||||
|
|
||||||
|
func removeAuditEventSource() {}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The sink the binary ships with, read before auditlog_test.go's init swaps
|
||||||
|
// it out (package variables are set before any init runs).
|
||||||
|
var auditSinkShipped = auditSink
|
||||||
|
|
||||||
|
// TestAuditSinkIsTheQueue: the shipped sink is the queue, never the
|
||||||
|
// platform's writer called on the audited op's own goroutine.
|
||||||
|
func TestAuditSinkIsTheQueue(t *testing.T) {
|
||||||
|
if reflect.ValueOf(auditSinkShipped).Pointer() != reflect.ValueOf(auditEnqueue).Pointer() {
|
||||||
|
t.Fatal("the system-log sink must be auditEnqueue: a direct write blocks the audited op while syslog is stuck")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditQueueNeverWaitsOnAStuckWriter: with a writer that never returns,
|
||||||
|
// far more rows than the queue holds are all taken at once; the ones past the
|
||||||
|
// bound are dropped and counted, and the count rides on the next line that
|
||||||
|
// gets into the queue.
|
||||||
|
func TestAuditQueueNeverWaitsOnAStuckWriter(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
var mu sync.Mutex
|
||||||
|
var got []string
|
||||||
|
q := newAuditQueue(8, func(l []byte) {
|
||||||
|
<-release
|
||||||
|
mu.Lock()
|
||||||
|
got = append(got, string(l))
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
q.enqueue([]byte(`{"n":1}`))
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("audit rows waited on a stuck system log")
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
q.flush(5 * time.Second)
|
||||||
|
// Nothing lost without a count: every one of the 1001 rows is either a
|
||||||
|
// line written or counted on a line written after the gap.
|
||||||
|
q.enqueue([]byte(`{"n":2}`))
|
||||||
|
q.flush(5 * time.Second)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
var counted float64
|
||||||
|
for _, l := range got {
|
||||||
|
m := map[string]any{}
|
||||||
|
if err := json.Unmarshal([]byte(l), &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, ok := m[auditDroppedField].(float64); ok {
|
||||||
|
counted += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(got) > 10 || counted == 0 || float64(len(got))+counted != 1001 {
|
||||||
|
t.Fatalf("lines written %d (at most 10), dropped and counted %v: want 1001 in all", len(got), counted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditSystemLogOmitsTheRootCommandText: a root command's text stays in
|
||||||
|
// the 0600 file; the system-log line carries its digest and length.
|
||||||
|
func TestAuditSystemLogOmitsTheRootCommandText(t *testing.T) {
|
||||||
|
var line string
|
||||||
|
prev := auditSink
|
||||||
|
auditSink = func(l []byte) { line = string(l) }
|
||||||
|
t.Cleanup(func() { auditSink = prev })
|
||||||
|
auditToSystemLog(auditFilePairingD, "p", []byte(`{"event":"start","command":"PGPASSWORD=hunter2 psql","command_sha256":"ab"}`))
|
||||||
|
m := map[string]any{}
|
||||||
|
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "hunter2") || m["command"] != nil {
|
||||||
|
t.Fatalf("the command text reached the system log: %s", line)
|
||||||
|
}
|
||||||
|
if m["command_sha256"] != "ab" || m["command_bytes"] != float64(len("PGPASSWORD=hunter2 psql")) {
|
||||||
|
t.Fatalf("the line must keep the digest and the length: %s", line)
|
||||||
|
}
|
||||||
|
auditToSystemLog(auditFilePairingD, "p", []byte(`{"command":"x","command_bytes":70000}`))
|
||||||
|
if !strings.Contains(line, `"command_bytes":70000`) {
|
||||||
|
t.Fatalf("a row's own command_bytes (the full length) is kept: %s", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/syslog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAuditRowsDoNotWaitOnAStuckSyslogSocket: a real syslog writer on a
|
||||||
|
// datagram socket nobody reads blocks once the socket's queue is full, as
|
||||||
|
// /dev/log does while journald is stuck. Audit rows behind the queue all
|
||||||
|
// return at once; the same writer called inline (as 0.54.0 first did) does not.
|
||||||
|
func TestAuditRowsDoNotWaitOnAStuckSyslogSocket(t *testing.T) {
|
||||||
|
dir, err := os.MkdirTemp("", "al")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||||
|
sock := filepath.Join(dir, "s")
|
||||||
|
l, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sock, Net: "unixgram"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Closing the reading end is what lets the stuck writes below return
|
||||||
|
// (refused) so their goroutines end; the writer itself is never closed,
|
||||||
|
// since its Close would wait behind a write that is stuck.
|
||||||
|
t.Cleanup(func() { l.Close() })
|
||||||
|
w, err := syslog.Dial("unixgram", sock, syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
write := func(line []byte) { _ = w.Info(string(line)) }
|
||||||
|
|
||||||
|
// The inline writer does block: without this the test proves nothing.
|
||||||
|
inline := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
write([]byte(`{"op":"x"}`))
|
||||||
|
}
|
||||||
|
close(inline)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-inline:
|
||||||
|
t.Skip("this kernel never blocked a datagram writer; nothing to bound")
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
|
||||||
|
q := newAuditQueue(64, write)
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
q.enqueue([]byte(`{"op":"y"}`))
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("audit rows waited on a stuck syslog socket")
|
||||||
|
}
|
||||||
|
if q.dropped.Load() == 0 {
|
||||||
|
t.Fatal("rows past the bound must be counted as dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The test binary never writes to the real system log: the suite's audit
|
||||||
|
// rows would otherwise land in the journal of whatever machine runs it.
|
||||||
|
func init() { auditSink = func([]byte) {} }
|
||||||
|
|
||||||
|
// auditSinkCapture swaps the system-log sink for one that keeps the lines.
|
||||||
|
func auditSinkCapture(t *testing.T) func() []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
var lines [][]byte
|
||||||
|
prev := auditSink
|
||||||
|
auditSink = func(line []byte) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
lines = append(lines, append([]byte(nil), line...))
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { auditSink = prev })
|
||||||
|
return func() []map[string]any {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
var out []map[string]any
|
||||||
|
for _, l := range lines {
|
||||||
|
m := map[string]any{}
|
||||||
|
if err := json.Unmarshal(l, &m); err != nil {
|
||||||
|
t.Fatalf("system-log line is not JSON: %s", l)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditFileRows(t *testing.T, path string) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("audit file %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
var out []map[string]any
|
||||||
|
sc := bufio.NewScanner(fh)
|
||||||
|
for sc.Scan() {
|
||||||
|
m := map[string]any{}
|
||||||
|
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
|
||||||
|
t.Fatalf("audit row is not JSON: %s", sc.Text())
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditSameRow: the system-log line is the file's row, every field equal,
|
||||||
|
// with `audit` and `pairing` added.
|
||||||
|
func auditSameRow(t *testing.T, what string, file, logged map[string]any, wantFile, wantPairing string) {
|
||||||
|
t.Helper()
|
||||||
|
if logged["audit"] != wantFile || logged["pairing"] != wantPairing {
|
||||||
|
t.Fatalf("%s: system-log line names audit=%v pairing=%v, want %q %q", what, logged["audit"], logged["pairing"], wantFile, wantPairing)
|
||||||
|
}
|
||||||
|
for k, v := range file {
|
||||||
|
if lv, ok := logged[k]; !ok || !jsonEq(lv, v) {
|
||||||
|
t.Fatalf("%s: field %q is %v in the file and %v in the system log", what, k, v, logged[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(logged) != len(file)+2 {
|
||||||
|
t.Fatalf("%s: system-log line has %d fields, the file row %d (+2 expected)", what, len(logged), len(file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonEq(a, b any) bool {
|
||||||
|
ja, _ := json.Marshal(a)
|
||||||
|
jb, _ := json.Marshal(b)
|
||||||
|
return string(ja) == string(jb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditRowsReachTheSystemLog: every audit writer on this box sends the row
|
||||||
|
// it writes to its file to the system log as well, unchanged, naming the file
|
||||||
|
// and the pairing: the management lane (mgmt_audit.jsonl), pairing changes
|
||||||
|
// (pairing_audit.jsonl), and a pairing's audit.jsonl for store ops and op runs,
|
||||||
|
// terminals and root commands.
|
||||||
|
func TestAuditRowsReachTheSystemLog(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
got := auditSinkCapture(t)
|
||||||
|
cfgDir, err := configDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
|
||||||
|
if err := os.MkdirAll(pairDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mgmtAudit("acme.host.example", "uptime", "observe", "allowed", "", 0, []string{"a"}, "")
|
||||||
|
pairingAudit("pairing_add", "other.host.example", "ok")
|
||||||
|
dcnAudit(pairDir, "dcn_read", 7, "/srv/notes.txt", "allowed")
|
||||||
|
sessionAudit(pairDir, "session", "s-1", "ops", "start", 0, "opened")
|
||||||
|
rootExecAudit(pairDir, map[string]any{"session_id": "s-2", "event": "stop", "exit": 0})
|
||||||
|
|
||||||
|
lines := got()
|
||||||
|
if len(lines) != 5 {
|
||||||
|
t.Fatalf("got %d system-log lines, want 5: %v", len(lines), lines)
|
||||||
|
}
|
||||||
|
mgmt := auditFileRows(t, filepath.Join(cfgDir, mgmtAuditFile))
|
||||||
|
pair := auditFileRows(t, filepath.Join(cfgDir, auditFilePairing))
|
||||||
|
per := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD))
|
||||||
|
if len(mgmt) != 1 || len(pair) != 1 || len(per) != 3 {
|
||||||
|
t.Fatalf("file rows: mgmt %d, pairing %d, per-pairing %d; want 1, 1, 3", len(mgmt), len(pair), len(per))
|
||||||
|
}
|
||||||
|
auditSameRow(t, "management lane", mgmt[0], lines[0], mgmtAuditFile, "acme.host.example")
|
||||||
|
auditSameRow(t, "pairing change", pair[0], lines[1], auditFilePairing, "")
|
||||||
|
auditSameRow(t, "store op", per[0], lines[2], auditFilePairingD, "acme.host.example")
|
||||||
|
auditSameRow(t, "terminal", per[1], lines[3], auditFilePairingD, "acme.host.example")
|
||||||
|
auditSameRow(t, "root command", per[2], lines[4], auditFilePairingD, "acme.host.example")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditFileRowStandsWithoutASystemLog: a system log that takes nothing
|
||||||
|
// costs the copy only; the row is still written to its file.
|
||||||
|
func TestAuditFileRowStandsWithoutASystemLog(t *testing.T) {
|
||||||
|
prev := auditSink
|
||||||
|
auditSink = func([]byte) {}
|
||||||
|
t.Cleanup(func() { auditSink = prev })
|
||||||
|
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
|
||||||
|
if err := os.MkdirAll(pairDir, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dcnAudit(pairDir, "dcn_read", 1, "/x", "allowed")
|
||||||
|
if rows := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD)); len(rows) != 1 {
|
||||||
|
t.Fatalf("file rows = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuditSystemLogKeepsNumbersExact: a large id goes to the system log as
|
||||||
|
// the digits the file has, not as a float.
|
||||||
|
func TestAuditSystemLogKeepsNumbersExact(t *testing.T) {
|
||||||
|
var line string
|
||||||
|
prev := auditSink
|
||||||
|
auditSink = func(l []byte) { line = string(l) }
|
||||||
|
t.Cleanup(func() { auditSink = prev })
|
||||||
|
auditToSystemLog(auditFilePairingD, "p", []byte(`{"store_id":9007199254740993}`))
|
||||||
|
if !json.Valid([]byte(line)) || !strings.Contains(line, `"store_id":9007199254740993`) {
|
||||||
|
t.Fatalf("system-log line %s does not carry the id exactly", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/svc/eventlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
auditEventMu sync.Mutex
|
||||||
|
auditEventLog *eventlog.Log
|
||||||
|
)
|
||||||
|
|
||||||
|
// systemAuditWrite sends one line to the Application event log under the
|
||||||
|
// source PrivataeBastionAudit, event id 1, at information level. The source
|
||||||
|
// is registered when the system service is installed
|
||||||
|
// (registerAuditEventSource); before that, or if the log refuses, the copy is
|
||||||
|
// lost and the row in the file is not. Called only from auditQ's drain
|
||||||
|
// (auditlog.go): the event log service can be slow to answer.
|
||||||
|
func systemAuditWrite(line []byte) {
|
||||||
|
auditEventMu.Lock()
|
||||||
|
defer auditEventMu.Unlock()
|
||||||
|
if auditEventLog == nil {
|
||||||
|
l, err := eventlog.Open(auditEventSource)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditEventLog = l
|
||||||
|
}
|
||||||
|
if err := auditEventLog.Info(auditEventID, string(line)); err != nil {
|
||||||
|
_ = auditEventLog.Close()
|
||||||
|
auditEventLog = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerAuditEventSource registers PrivataeBastionAudit as an Application
|
||||||
|
// event log source, so the rows show with their own source name. Already
|
||||||
|
// registered is success.
|
||||||
|
func registerAuditEventSource() error {
|
||||||
|
err := eventlog.InstallAsEventCreate(auditEventSource, eventlog.Info|eventlog.Warning|eventlog.Error)
|
||||||
|
if err != nil && strings.Contains(err.Error(), "already exists") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeAuditEventSource unregisters the source when the service is removed.
|
||||||
|
// Events already written stay in the log.
|
||||||
|
func removeAuditEventSource() {
|
||||||
|
_ = eventlog.Remove(auditEventSource)
|
||||||
|
}
|
||||||
+418
-33
@@ -11,6 +11,7 @@ package main
|
|||||||
// runs) is driven in claims_unix_test.go and beside each lane's own tests.
|
// runs) is driven in claims_unix_test.go and beside each lane's own tests.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"go/ast"
|
"go/ast"
|
||||||
"go/parser"
|
"go/parser"
|
||||||
"go/token"
|
"go/token"
|
||||||
@@ -49,23 +50,7 @@ func parseSources(t *testing.T) *srcPkg {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parse %s: %v", n, err)
|
t.Fatalf("parse %s: %v", n, err)
|
||||||
}
|
}
|
||||||
p.files[n] = f
|
p.addFile(n, f)
|
||||||
for _, d := range f.Decls {
|
|
||||||
fd, ok := d.(*ast.FuncDecl)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := funcKey(fd)
|
|
||||||
// The same name in two platform files (processIsRoot, …) is kept
|
|
||||||
// once per file so neither body is lost to the other.
|
|
||||||
if _, dup := p.funcs[key]; dup {
|
|
||||||
key = key + "@" + n
|
|
||||||
}
|
|
||||||
p.funcs[key] = fd
|
|
||||||
if fd.Recv != nil {
|
|
||||||
p.methods[fd.Name.Name] = append(p.methods[fd.Name.Name], key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(p.files) < 50 {
|
if len(p.files) < 50 {
|
||||||
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files))
|
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files))
|
||||||
@@ -73,6 +58,46 @@ func parseSources(t *testing.T) *srcPkg {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addFile registers one parsed file and its functions. A test that proves a
|
||||||
|
// check can fail adds a synthetic file through here, so it is indexed exactly
|
||||||
|
// as a real one would be.
|
||||||
|
func (p *srcPkg) addFile(n string, f *ast.File) {
|
||||||
|
p.files[n] = f
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
fd, ok := d.(*ast.FuncDecl)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := funcKey(fd)
|
||||||
|
// The same name in two platform files (processIsRoot, …) is kept
|
||||||
|
// once per file so neither body is lost to the other.
|
||||||
|
if _, dup := p.funcs[key]; dup {
|
||||||
|
key = key + "@" + n
|
||||||
|
}
|
||||||
|
p.funcs[key] = fd
|
||||||
|
if fd.Recv != nil {
|
||||||
|
p.methods[fd.Name.Name] = append(p.methods[fd.Name.Name], key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// funcKeysIn is the p.funcs key of every function declared in file n.
|
||||||
|
func (p *srcPkg) funcKeysIn(n string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, d := range p.files[n].Decls {
|
||||||
|
fd, ok := d.(*ast.FuncDecl)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := funcKey(fd)
|
||||||
|
if p.funcs[key] != fd {
|
||||||
|
key = key + "@" + n
|
||||||
|
}
|
||||||
|
out = append(out, key)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// funcKey is "name" for a function and "Type.name" for a method.
|
// funcKey is "name" for a function and "Type.name" for a method.
|
||||||
func funcKey(fd *ast.FuncDecl) string {
|
func funcKey(fd *ast.FuncDecl) string {
|
||||||
if fd.Recv == nil || len(fd.Recv.List) == 0 {
|
if fd.Recv == nil || len(fd.Recv.List) == 0 {
|
||||||
@@ -488,10 +513,30 @@ func TestListenAddressesAreOnlyEverAllocatedOnLoopback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No MCP endpoint. Every HTTP route the binary registers, on any listener,
|
// No MCP endpoint: the bastion never speaks the protocol. Every HTTP route
|
||||||
// is in this table; none of them, and no op name on the management lane,
|
// the binary registers, on any listener, is in this table; none of them, and
|
||||||
// names MCP; no string in the code addresses an /mcp path or speaks JSON-RPC,
|
// no op name on the management lane, names MCP; no string in the code is a
|
||||||
// the protocol MCP is carried on; and no dependency is an MCP library.
|
// URL or route with an /mcp path, or speaks JSON-RPC, the protocol MCP is
|
||||||
|
// carried on; and no dependency is an MCP library.
|
||||||
|
//
|
||||||
|
// WHAT CHANGED, AND WHY THE GUARANTEE DID NOT. This test used to refuse any
|
||||||
|
// string containing "/mcp" at all. The connection-config reader
|
||||||
|
// (mcpconfig.go) has to name the files agent clients declare their tool
|
||||||
|
// servers in — .cursor/mcp.json, claude_desktop_config.json, an mcpServers
|
||||||
|
// key — and READING A FILE SPEAKS NO PROTOCOL. So the check now targets the
|
||||||
|
// protocol instead of the word (mcpReaderViolations):
|
||||||
|
//
|
||||||
|
// - no literal anywhere is a URL or route with an mcp path segment;
|
||||||
|
// - the config basenames and keys may appear in mcpconfig.go and nowhere
|
||||||
|
// else, so a second file that starts reading (or writing) them is a
|
||||||
|
// change a reviewer sees here;
|
||||||
|
// - nothing declared in a config-reader file (mcpconfig*.go) imports, or
|
||||||
|
// can reach through this package's call graph, net.Dial or net.Listen,
|
||||||
|
// net/http, os/exec, TLS dialling or the websocket client. The reader is
|
||||||
|
// handed its process list by agents.go; it starts and dials nothing.
|
||||||
|
//
|
||||||
|
// TestMCPReaderCheckCatchesADialAndAStrayBasename proves each of those can
|
||||||
|
// fail.
|
||||||
func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
||||||
p := parseSources(t)
|
p := parseSources(t)
|
||||||
// A route served by more than one surface names each file, "a.go|b.go":
|
// A route served by more than one surface names each file, "a.go|b.go":
|
||||||
@@ -547,14 +592,10 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, v := range mcpReaderViolations(p) {
|
||||||
|
t.Error(v)
|
||||||
|
}
|
||||||
for name, f := range p.files {
|
for name, f := range p.files {
|
||||||
for _, s := range stringLits(f) {
|
|
||||||
l := strings.ToLower(s)
|
|
||||||
if strings.Contains(l, "/mcp") || strings.Contains(l, "jsonrpc") ||
|
|
||||||
strings.Contains(l, "modelcontextprotocol") || strings.Contains(l, "tools/call") {
|
|
||||||
t.Errorf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, imp := range f.Imports {
|
for _, imp := range f.Imports {
|
||||||
if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") {
|
if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") {
|
||||||
t.Errorf("%s imports %s", name, imp.Path.Value)
|
t.Errorf("%s imports %s", name, imp.Path.Value)
|
||||||
@@ -575,6 +616,346 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mcpConfigNames are the file basenames and keys agent clients declare tool
|
||||||
|
// servers under. Only mcpconfig.go may name them. settings.json and
|
||||||
|
// config.toml are left out on purpose: many programs use those names (the
|
||||||
|
// coder lane reads the NVIDIA runtime's config.toml), so they say nothing
|
||||||
|
// about tool servers; the keys beside them do.
|
||||||
|
var mcpConfigNames = []string{"claude_desktop_config", "mcp.json", "mcp_config.json", ".claude.json",
|
||||||
|
"mcpservers", "context_servers", "mcp_servers",
|
||||||
|
// The clients' own directories, which name a settings.json or
|
||||||
|
// config.toml as surely as the basename would.
|
||||||
|
".cursor", ".codex", ".zed", ".codeium", ".vscode"}
|
||||||
|
|
||||||
|
// mcpURLOrRoute reports whether a string literal is a URL or route that
|
||||||
|
// addresses an mcp path. Outside a URL, every "/mcp" counts (/mcp, /mcp/x,
|
||||||
|
// /mcp-proxy) except a client's config FILE name: /mcp.json and
|
||||||
|
// /mcp_config.json.
|
||||||
|
func mcpURLOrRoute(l string) bool {
|
||||||
|
l = strings.ToLower(l)
|
||||||
|
if i := strings.Index(l, "://"); i >= 0 {
|
||||||
|
rest := l[i+3:]
|
||||||
|
if j := strings.IndexByte(rest, '/'); j >= 0 {
|
||||||
|
return strings.Contains(rest[j:], "/mcp")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; ; {
|
||||||
|
j := strings.Index(l[i:], "/mcp")
|
||||||
|
if j < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rest := l[i+j+len("/mcp"):]
|
||||||
|
if !strings.HasPrefix(rest, ".json") && !strings.HasPrefix(rest, "_config.json") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
i += j + len("/mcp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpReaderSink: a package-level function or type that opens a connection,
|
||||||
|
// resolves a name, or starts a process — named as a call or as a value.
|
||||||
|
func mcpReaderSink(recv, sel string) bool {
|
||||||
|
switch {
|
||||||
|
case recv == "net" && (strings.HasPrefix(sel, "Dial") || strings.HasPrefix(sel, "Listen") ||
|
||||||
|
strings.HasPrefix(sel, "Lookup") || strings.HasPrefix(sel, "Resolve") || sel == "Dialer" || sel == "Resolver"):
|
||||||
|
return true
|
||||||
|
case strings.Contains(recv, "net.Dialer") || strings.Contains(recv, "net.Resolver"):
|
||||||
|
return true
|
||||||
|
case recv == "http" || recv == "exec" || recv == "websocket" || recv == "rpc" || recv == "jsonrpc":
|
||||||
|
return true
|
||||||
|
case recv == "tls" && strings.HasPrefix(sel, "Dial"):
|
||||||
|
return true
|
||||||
|
case (recv == "os" || recv == "syscall" || recv == "unix" || recv == "windows") &&
|
||||||
|
(sel == "StartProcess" || sel == "ForkExec" || sel == "Exec" || strings.HasPrefix(sel, "CreateProcess") ||
|
||||||
|
strings.HasPrefix(sel, "ShellExecute") || sel == "WinExec"):
|
||||||
|
return true
|
||||||
|
case (recv == "syscall" || recv == "unix" || recv == "windows") && (sel == "Socket" || sel == "Connect" || sel == "Bind" ||
|
||||||
|
sel == "Listen" || sel == "Sendto" || sel == "Sendmsg" || sel == "SendmsgN" || strings.HasPrefix(sel, "WSA") ||
|
||||||
|
sel == "ConnectEx" || sel == "GetAddrInfoW" || sel == "GetHostByName" || strings.HasPrefix(sel, "DnsQuery")):
|
||||||
|
return true
|
||||||
|
case (recv == "syscall" || recv == "windows") && (strings.HasPrefix(sel, "LoadLibrary") || sel == "LoadDLL" ||
|
||||||
|
sel == "MustLoadDLL" || sel == "NewLazyDLL" || sel == "NewLazySystemDLL"):
|
||||||
|
// A DLL loaded by name reaches any function in it, past every check here.
|
||||||
|
return true
|
||||||
|
case recv == "registry" && sel == "OpenRemoteKey":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpReaderMethodSink: a METHOD call that connects, resolves or starts,
|
||||||
|
// whatever the receiver is called — d.Dial on a net.Dialer variable,
|
||||||
|
// c.Get on an http.Client, cmd.Run on an exec.Cmd. Over-approximate on
|
||||||
|
// purpose: it only applies to code the reader can reach.
|
||||||
|
var mcpReaderMethodSink = map[string]bool{"Dial": true, "DialContext": true, "DialTCP": true, "DialUDP": true,
|
||||||
|
"DialUnix": true, "DialIP": true, "Listen": true, "ListenPacket": true, "LookupHost": true, "LookupIP": true,
|
||||||
|
"LookupIPAddr": true, "LookupAddr": true, "LookupNetIP": true, "Do": true, "Get": true, "Post": true,
|
||||||
|
"PostForm": true, "Head": true, "RoundTrip": true, "Start": true, "Run": true, "Output": true,
|
||||||
|
"CombinedOutput": true, "Connect": true}
|
||||||
|
|
||||||
|
// mcpReaderAllowedImports is every package a config-reader file may import:
|
||||||
|
// parsing, strings, and opening files. Anything else is a change a reviewer
|
||||||
|
// has to see here first. x/sys/windows and its registry package are the
|
||||||
|
// Windows open (CreateFile and the handle's own type, attributes and owner)
|
||||||
|
// and the profile list it checks owners against; each can also start a
|
||||||
|
// process, open a socket, load a DLL or open a remote registry, and those
|
||||||
|
// calls are sinks in mcpReaderSink like their unix equivalents.
|
||||||
|
var mcpReaderAllowedImports = map[string]bool{"bytes": true, "encoding/json": true, "errors": true, "io": true,
|
||||||
|
"io/fs": true, "net": true, "net/url": true, "os": true, "path/filepath": true, "regexp": true,
|
||||||
|
"runtime": true, "sort": true, "strconv": true, "strings": true, "syscall": true, "unicode": true,
|
||||||
|
"unicode/utf8": true, "golang.org/x/sys/unix": true, "golang.org/x/sys/windows": true,
|
||||||
|
"golang.org/x/sys/windows/registry": true}
|
||||||
|
|
||||||
|
// mcpValueSink: a sink named as a VALUE rather than called (f := net.Dial).
|
||||||
|
// The http, exec and websocket packages also export constants and types
|
||||||
|
// (http.StatusOK, websocket.BinaryMessage), which name no connection; only
|
||||||
|
// their functions count here.
|
||||||
|
func mcpValueSink(recv, sel string) bool {
|
||||||
|
switch recv {
|
||||||
|
case "http", "exec", "websocket", "rpc", "jsonrpc":
|
||||||
|
switch sel {
|
||||||
|
case "Get", "Post", "Head", "PostForm", "Command", "CommandContext", "Dial", "DialContext",
|
||||||
|
"DialHTTP", "DefaultClient", "DefaultDialer", "DefaultTransport", "NewClient":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mcpReaderSink(recv, sel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpSinksIn lists the network and process calls inside one declaration.
|
||||||
|
func mcpSinksIn(n ast.Node) []string {
|
||||||
|
var out []string
|
||||||
|
called := map[*ast.SelectorExpr]bool{}
|
||||||
|
ast.Inspect(n, func(x ast.Node) bool {
|
||||||
|
if c, ok := x.(*ast.CallExpr); ok {
|
||||||
|
if sel, ok := c.Fun.(*ast.SelectorExpr); ok {
|
||||||
|
called[sel] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
ast.Inspect(n, func(x ast.Node) bool {
|
||||||
|
switch v := x.(type) {
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
recv := exprText(v.X)
|
||||||
|
if (called[v] && mcpReaderSink(recv, v.Sel.Name)) || (!called[v] && mcpValueSink(recv, v.Sel.Name)) {
|
||||||
|
out = append(out, recv+"."+v.Sel.Name)
|
||||||
|
}
|
||||||
|
case *ast.CallExpr:
|
||||||
|
if sel, ok := v.Fun.(*ast.SelectorExpr); ok && mcpReaderMethodSink[sel.Sel.Name] {
|
||||||
|
out = append(out, exprText(sel.X)+"."+sel.Sel.Name+"()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpReaderViolations is every way the package breaks the guarantee above.
|
||||||
|
func mcpReaderViolations(p *srcPkg) []string {
|
||||||
|
var out []string
|
||||||
|
names := make([]string, 0, len(p.files))
|
||||||
|
for n := range p.files {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
var readerFiles []string
|
||||||
|
for _, name := range names {
|
||||||
|
if strings.HasPrefix(name, "mcpconfig") {
|
||||||
|
readerFiles = append(readerFiles, name)
|
||||||
|
}
|
||||||
|
for _, s := range stringLits(p.files[name]) {
|
||||||
|
l := strings.ToLower(s)
|
||||||
|
if mcpURLOrRoute(s) || strings.Contains(l, "jsonrpc") || strings.Contains(l, "tools/call") {
|
||||||
|
out = append(out, fmt.Sprintf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s))
|
||||||
|
}
|
||||||
|
// The protocol's namespace. The reader may name the npm scope
|
||||||
|
// its reference servers are published under, to recognise one
|
||||||
|
// running; nothing else may name it at all.
|
||||||
|
if strings.Contains(l, "modelcontextprotocol") && !(name == "mcpconfig.go" && l == "@modelcontextprotocol/") {
|
||||||
|
out = append(out, fmt.Sprintf("%s carries %q: only the config reader may name the reference servers' npm scope", name, s))
|
||||||
|
}
|
||||||
|
if name == "mcpconfig.go" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, b := range mcpConfigNames {
|
||||||
|
if strings.Contains(l, b) {
|
||||||
|
out = append(out, fmt.Sprintf("%s names %q (%s): only mcpconfig.go reads agent clients' tool-server config", name, s, b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(readerFiles) == 0 {
|
||||||
|
return append(out, "no mcpconfig*.go file: the reader moved, so this check is no longer checking it")
|
||||||
|
}
|
||||||
|
var roots []string
|
||||||
|
for _, name := range readerFiles {
|
||||||
|
for _, imp := range p.files[name].Imports {
|
||||||
|
path, _ := strconv.Unquote(imp.Path.Value)
|
||||||
|
if !mcpReaderAllowedImports[path] {
|
||||||
|
out = append(out, fmt.Sprintf("%s imports %s: the config reader reads files and nothing else", name, path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots = append(roots, p.funcKeysIn(name)...)
|
||||||
|
}
|
||||||
|
// Package-level variables are nodes too ("var:name"): a function held in
|
||||||
|
// one (var f = net.Dial, var g = func() { ... }) is reached by whoever
|
||||||
|
// reads the variable, and a reader file's own variables are roots, since
|
||||||
|
// their initialisers run with the package.
|
||||||
|
g := p.callGraph()
|
||||||
|
vars := map[string][]ast.Expr{}
|
||||||
|
for _, name := range names {
|
||||||
|
for _, d := range p.files[name].Decls {
|
||||||
|
gd, ok := d.(*ast.GenDecl)
|
||||||
|
if !ok || gd.Tok != token.VAR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sp := range gd.Specs {
|
||||||
|
vs := sp.(*ast.ValueSpec)
|
||||||
|
for _, id := range vs.Names {
|
||||||
|
key := "var:" + id.Name
|
||||||
|
vars[key] = append(vars[key], vs.Values...)
|
||||||
|
if strings.HasPrefix(name, "mcpconfig") {
|
||||||
|
roots = append(roots, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refs := func(n ast.Node) []string {
|
||||||
|
var out []string
|
||||||
|
ast.Inspect(n, func(x ast.Node) bool {
|
||||||
|
if id, ok := x.(*ast.Ident); ok {
|
||||||
|
if _, isVar := vars["var:"+id.Name]; isVar {
|
||||||
|
out = append(out, "var:"+id.Name)
|
||||||
|
}
|
||||||
|
if _, isFn := p.funcs[id.Name]; isFn {
|
||||||
|
out = append(out, id.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
sinks := map[string][]string{}
|
||||||
|
for key, fd := range p.funcs {
|
||||||
|
g[key] = append(g[key], refs(fd)...)
|
||||||
|
if s := mcpSinksIn(fd); len(s) > 0 {
|
||||||
|
sinks[key] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, vals := range vars {
|
||||||
|
for _, v := range vals {
|
||||||
|
g[key] = append(g[key], refs(v)...)
|
||||||
|
sinks[key] = append(sinks[key], mcpSinksIn(v)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := reach(g, roots, nil)
|
||||||
|
for fn, path := range seen {
|
||||||
|
if what := sinks[fn]; len(what) > 0 {
|
||||||
|
out = append(out, fmt.Sprintf("the config reader can reach %s, which reaches a network or process call (%s): %s",
|
||||||
|
fn, strings.Join(what, ", "), path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE CHECK ABOVE CAN FAIL. A reader that starts a process, imports a
|
||||||
|
// network package, reaches a dialler through another function, or a second
|
||||||
|
// file that names a client's config file or an /mcp URL: each one is added
|
||||||
|
// as a synthetic source and must be reported. The real tree must report
|
||||||
|
// nothing, and the scan must have found sinks and roots to compare, so a
|
||||||
|
// broken extraction cannot pass by finding nothing.
|
||||||
|
func TestMCPReaderCheckCatchesADialAndAStrayBasename(t *testing.T) {
|
||||||
|
clean := parseSources(t)
|
||||||
|
if v := mcpReaderViolations(clean); len(v) != 0 {
|
||||||
|
t.Fatalf("the real tree must be clean: %v", v)
|
||||||
|
}
|
||||||
|
if len(clean.funcKeysIn("mcpconfig.go")) < 10 {
|
||||||
|
t.Fatal("found almost no functions in mcpconfig.go; the root extraction is broken")
|
||||||
|
}
|
||||||
|
add := func(p *srcPkg, name, src string) {
|
||||||
|
t.Helper()
|
||||||
|
f, err := parser.ParseFile(p.fset, name, src, parser.SkipObjectResolution)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p.addFile(name, f)
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name, want string
|
||||||
|
files map[string]string
|
||||||
|
}{
|
||||||
|
{"the reader execs", "can reach mcpSneakRun", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nfunc mcpSneakRun() { _ = exec.Command(\"x\") }\n"}},
|
||||||
|
{"the reader dials through a helper elsewhere", "can reach sneakDialHelper", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nfunc mcpSneakCall() { sneakDialHelper() }\n",
|
||||||
|
"sneak_helper.go": "package main\nfunc sneakDialHelper() { net.Dial(\"tcp\", \"x\") }\n"}},
|
||||||
|
{"the reader imports net/http", "imports net/http", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nimport _ \"net/http\"\n"}},
|
||||||
|
{"another file names a config file", "only mcpconfig.go reads", map[string]string{
|
||||||
|
"sneak_other.go": "package main\nvar sneakPath = \".cursor/mcp.json\"\n"}},
|
||||||
|
{"another file names the servers key", "only mcpconfig.go reads", map[string]string{
|
||||||
|
"sneak_other.go": "package main\nvar sneakKey = \"mcpServers\"\n"}},
|
||||||
|
{"a URL with an mcp path", "addresses an MCP endpoint", map[string]string{
|
||||||
|
"sneak_other.go": "package main\nvar sneakURL = \"https://h.example.com/v1/mcp\"\n"}},
|
||||||
|
{"a route", "addresses an MCP endpoint", map[string]string{
|
||||||
|
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp\"\n"}},
|
||||||
|
{"a route with a suffix", "addresses an MCP endpoint", map[string]string{
|
||||||
|
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp-proxy\"\n"}},
|
||||||
|
{"the reader dials through a Dialer variable", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nfunc mcpSneakD() { var d net.Dialer; d.Dial(\"tcp\", \"x\") }\n"}},
|
||||||
|
{"the reader dials through a function value", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nfunc mcpSneakF() { f := net.Dial; f(\"tcp\", \"x\") }\n"}},
|
||||||
|
{"the reader dials from a package-level function literal", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nvar mcpSneakV = func() { net.Dial(\"tcp\", \"x\") }\n"}},
|
||||||
|
{"the reader opens a raw socket", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nfunc mcpSneakS() { syscall.Socket(2, 1, 0) }\n"}},
|
||||||
|
{"the reader resolves a name", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nfunc mcpSneakL() { net.LookupHost(\"x\") }\n"}},
|
||||||
|
{"the reader imports a websocket client not on a deny list", "imports golang.org/x/net/websocket", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nimport ws \"golang.org/x/net/websocket\"\nfunc mcpSneakW() { ws.Dial(\"x\", \"\", \"y\") }\n"}},
|
||||||
|
{"the reader calls a dialler held in another file's variable", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nfunc mcpSneakH() { sneakDialer(\"tcp\", \"x\") }\n",
|
||||||
|
"sneak_helper.go": "package main\nvar sneakDialer = net.Dial\n"}},
|
||||||
|
{"another file names a client's config directory", "only mcpconfig.go reads", map[string]string{
|
||||||
|
"sneak_other.go": "package main\nvar sneakDir = \".codex/\"\n"}},
|
||||||
|
{"the Windows reader starts a process", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakCP() { windows.CreateProcess(nil, nil, nil, nil, false, 0, nil, nil, nil, nil) }\n"}},
|
||||||
|
{"the Windows reader opens a socket", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakWS() { windows.WSASocket(2, 1, 0, nil, 0, 0) }\n"}},
|
||||||
|
{"the Windows reader loads a DLL by name", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nvar mcpSneakDLL = windows.NewLazySystemDLL(\"ws2_32.dll\")\n"}},
|
||||||
|
{"the Windows reader opens a remote registry", "reaches a network or process call", map[string]string{
|
||||||
|
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows/registry\"\nfunc mcpSneakRR() { registry.OpenRemoteKey(\"h\", registry.LOCAL_MACHINE) }\n"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
p := parseSources(t)
|
||||||
|
for n, src := range c.files {
|
||||||
|
add(p, n, src)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, v := range mcpReaderViolations(p) {
|
||||||
|
if strings.Contains(v, c.want) {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("%s: not reported (want %q in %v)", c.name, c.want, mcpReaderViolations(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A config file name is not a URL path, and a file in the reader may
|
||||||
|
// name one.
|
||||||
|
for _, ok := range []string{".cursor/mcp.json", "mcp_config.json", ".codeium/windsurf/mcp_config.json", "managed-mcp.json"} {
|
||||||
|
if mcpURLOrRoute(ok) {
|
||||||
|
t.Errorf("%q read as an MCP URL or route", ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// callGraph maps each function to what it calls or refers to, by name.
|
// callGraph maps each function to what it calls or refers to, by name.
|
||||||
// A method call through a value is resolved to every method of that name in
|
// A method call through a value is resolved to every method of that name in
|
||||||
// the package, so the graph over-approximates: a path it cannot rule out is
|
// the package, so the graph over-approximates: a path it cannot rule out is
|
||||||
@@ -582,6 +963,14 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
|||||||
// followed; none of the handlers below does that.
|
// followed; none of the handlers below does that.
|
||||||
func (p *srcPkg) callGraph() map[string][]string {
|
func (p *srcPkg) callGraph() map[string][]string {
|
||||||
g := map[string][]string{}
|
g := map[string][]string{}
|
||||||
|
// The per-file duplicates ("name@file"), by name, built once: looking
|
||||||
|
// them up by prefix for every identifier made this graph cost seconds.
|
||||||
|
dups := map[string][]string{}
|
||||||
|
for k := range p.funcs {
|
||||||
|
if i := strings.IndexByte(k, '@'); i >= 0 {
|
||||||
|
dups[k[:i]] = append(dups[k[:i]], k)
|
||||||
|
}
|
||||||
|
}
|
||||||
for key, fd := range p.funcs {
|
for key, fd := range p.funcs {
|
||||||
if fd.Body == nil {
|
if fd.Body == nil {
|
||||||
continue
|
continue
|
||||||
@@ -593,11 +982,7 @@ func (p *srcPkg) callGraph() map[string][]string {
|
|||||||
if _, ok := p.funcs[v.Name]; ok {
|
if _, ok := p.funcs[v.Name]; ok {
|
||||||
out = append(out, v.Name)
|
out = append(out, v.Name)
|
||||||
}
|
}
|
||||||
for k := range p.funcs {
|
out = append(out, dups[v.Name]...)
|
||||||
if strings.HasPrefix(k, v.Name+"@") {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case *ast.CallExpr:
|
case *ast.CallExpr:
|
||||||
if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
|
if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
|
||||||
out = append(out, p.methods[sel.Sel.Name]...)
|
out = append(out, p.methods[sel.Sel.Name]...)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !linux && !darwin && !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// The release builds linux, darwin and windows (deploy/build.sh). Any other
|
||||||
|
// unix (freebsd, openbsd) still builds, with the local context socket unable
|
||||||
|
// to tell who is on the other end: contextPeerUser then fails, and
|
||||||
|
// contextSockServe answers every connection with that reason and forwards
|
||||||
|
// nothing. An ask that cannot be attributed is refused, never let through.
|
||||||
|
func contextPeerUID(uintptr) (uint32, error) { return 0, contextSockUnsupported }
|
||||||
@@ -189,6 +189,7 @@ func dcnAudit(dir, op string, storeID int64, path, decision string) {
|
|||||||
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
|
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
|
||||||
"store_id": storeID, "path": path, "decision": decision,
|
"store_id": storeID, "path": path, "decision": decision,
|
||||||
})
|
})
|
||||||
|
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
|
||||||
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"),
|
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"),
|
||||||
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -98,15 +98,13 @@ func faResolveDropOwner(root, stateDir string) (*faOwner, error) {
|
|||||||
if err != nil || sid == nil {
|
if err != nil || sid == nil {
|
||||||
return nil, fmt.Errorf("cannot read the folder's owner: %v", err)
|
return nil, fmt.Errorf("cannot read the folder's owner: %v", err)
|
||||||
}
|
}
|
||||||
name, domain, typ, err := sid.LookupAccount("")
|
// Bounded and cached (sidnames.go); no answer in time refuses the start.
|
||||||
|
acct, err := faWinDropOwnerAccount(sid.String(), winAccounts, faDropSIDLane)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid.String())
|
return nil, err
|
||||||
}
|
}
|
||||||
full := name
|
name, full := acct.Account, sidAccountLabel(acct)
|
||||||
if domain != "" {
|
if why := faWinOwnerRefusal(sid.String(), acct.Type, full); why != "" {
|
||||||
full = domain + `\` + name
|
|
||||||
}
|
|
||||||
if why := faWinOwnerRefusal(sid.String(), typ, full); why != "" {
|
|
||||||
return nil, errors.New(why)
|
return nil, errors.New(why)
|
||||||
}
|
}
|
||||||
home := faWinProfileDir(sid.String())
|
home := faWinProfileDir(sid.String())
|
||||||
|
|||||||
@@ -5,12 +5,38 @@ package main
|
|||||||
// that gather their inputs are in the tagged file.
|
// that gather their inputs are in the tagged file.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf16"
|
"unicode/utf16"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// faDropSIDLane bounds the lookup of the granted folder's owner (sidnames.go).
|
||||||
|
// The start runs on the store's own goroutine and nothing waits on it, so the
|
||||||
|
// budget is generous enough for a slow directory that does answer. It is a
|
||||||
|
// bound all the same: the store is marked as ensuring for as long as the start
|
||||||
|
// runs, and every reconcile skips it while it is, so a lookup that never
|
||||||
|
// returned would hold the store as "installing" for good. Timed out, the store
|
||||||
|
// is failed with the reason and the reconcile tries again
|
||||||
|
// (faReconcileEvery), by when a late answer is in the cache.
|
||||||
|
var faDropSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 10 * time.Second}
|
||||||
|
|
||||||
|
// faWinDropOwnerAccount is the folder owner's account from the directory. It
|
||||||
|
// fails closed: no account, or no answer in time, and no agent is started.
|
||||||
|
func faWinDropOwnerAccount(sid string, dir *sidDirectory, lane sidLane) (sidAccount, error) {
|
||||||
|
acct, outcome := dir.resolve(sid, lane)
|
||||||
|
switch outcome {
|
||||||
|
case sidTimedOut:
|
||||||
|
return sidAccount{}, fmt.Errorf("refusing to run the agent for now: this machine's account directory did not answer "+
|
||||||
|
"for the granted folder's owner (%s) within %v; it is tried again at the next check", sid, lane.budget)
|
||||||
|
case sidUnmapped:
|
||||||
|
return sidAccount{}, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid)
|
||||||
|
}
|
||||||
|
return acct, nil
|
||||||
|
}
|
||||||
|
|
||||||
// winSidTypeUser is SidTypeUser: the SID names a person's account rather than
|
// winSidTypeUser is SidTypeUser: the SID names a person's account rather than
|
||||||
// a group, an alias or a well-known identity.
|
// a group, an alias or a well-known identity.
|
||||||
const winSidTypeUser = 1
|
const winSidTypeUser = 1
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/build"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EVERY PLATFORM FUNCTION EXISTS ON EVERY TARGET THAT BUILDS THIS PACKAGE.
|
||||||
|
//
|
||||||
|
// A function with one file per platform (contextPeerUID in contextsock_linux.go
|
||||||
|
// and contextsock_darwin.go) and a caller in a file every unix builds
|
||||||
|
// (contextsock_unix.go) leaves any other unix with an undefined name, and
|
||||||
|
// nothing in CI cross-builds one: `GOOS=freebsd go build` failed that way.
|
||||||
|
// The release targets are linux, darwin and windows (deploy/build.sh);
|
||||||
|
// freebsd and openbsd are not released, but they build, with what has no
|
||||||
|
// implementation there refusing (contextsock_other.go and the other
|
||||||
|
// *_other.go files).
|
||||||
|
//
|
||||||
|
// This asks the toolchain which files each target compiles (go/build's
|
||||||
|
// MatchFile) and checks that every package function called from those files
|
||||||
|
// is declared in them. It is not a type check, and it does not compile; it
|
||||||
|
// catches the missing-platform-file mistake, which is the one that happened,
|
||||||
|
// on the platform the tests already run on.
|
||||||
|
func TestEveryCalledPlatformFunctionIsDeclaredOnEveryTarget(t *testing.T) {
|
||||||
|
entries, err := os.ReadDir(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
type parsed struct {
|
||||||
|
name string
|
||||||
|
file *ast.File
|
||||||
|
}
|
||||||
|
var files []parsed
|
||||||
|
declaredAnywhere := map[string]bool{}
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
for _, e := range entries {
|
||||||
|
n := e.Name()
|
||||||
|
if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, err := parser.ParseFile(fset, n, nil, parser.SkipObjectResolution)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", n, err)
|
||||||
|
}
|
||||||
|
files = append(files, parsed{n, f})
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil {
|
||||||
|
declaredAnywhere[fd.Name.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(files) < 50 {
|
||||||
|
t.Fatalf("parsed only %d files; is the test running in the package directory?", len(files))
|
||||||
|
}
|
||||||
|
for _, goos := range []string{"linux", "darwin", "windows", "freebsd", "openbsd"} {
|
||||||
|
ctx := build.Default
|
||||||
|
ctx.GOOS = goos
|
||||||
|
ctx.CgoEnabled = false
|
||||||
|
declared := map[string]bool{}
|
||||||
|
var matched []parsed
|
||||||
|
for _, p := range files {
|
||||||
|
ok, err := ctx.MatchFile(".", p.name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", p.name, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matched = append(matched, p)
|
||||||
|
for _, d := range p.file.Decls {
|
||||||
|
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil {
|
||||||
|
declared[fd.Name.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing := map[string]string{}
|
||||||
|
for _, p := range matched {
|
||||||
|
ast.Inspect(p.file, func(x ast.Node) bool {
|
||||||
|
c, ok := x.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if id, ok := c.Fun.(*ast.Ident); ok && declaredAnywhere[id.Name] && !declared[id.Name] {
|
||||||
|
missing[id.Name] = p.name
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for fn, where := range missing {
|
||||||
|
out = append(out, fn+" (called in "+where+")")
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
if len(out) > 0 {
|
||||||
|
t.Errorf("GOOS=%s: called but not declared for this target: %s", goos, strings.Join(out, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// installShFunc returns one shell function's text from deploy/install.sh.
|
||||||
|
func installShFunc(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := regexp.MustCompile(`(?ms)^` + regexp.QuoteMeta(name) + `\(\) \{\n.*?^\}\n`).Find(raw)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("install.sh defines no %s()", name)
|
||||||
|
}
|
||||||
|
return string(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstallShKeepsAPairedLocalMachineLocal: a root reinstall defaults to
|
||||||
|
// networked, except on a machine already paired in local mode, which keeps
|
||||||
|
// its mode (paired_local). Switched, its pairing would sit on an unanchored
|
||||||
|
// networked machine, which refuses every op until claim-anchor.
|
||||||
|
func TestInstallShKeepsAPairedLocalMachineLocal(t *testing.T) {
|
||||||
|
fn := installShFunc(t, "paired_local")
|
||||||
|
run := func(dirs ...string) bool {
|
||||||
|
t.Helper()
|
||||||
|
args := append([]string{"-c", fn + `paired_local "$@"`, "sh"}, dirs...)
|
||||||
|
err := exec.Command("sh", args...).Run()
|
||||||
|
if _, ok := err.(*exec.ExitError); err != nil && !ok {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
mk := func(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
d := t.TempDir()
|
||||||
|
for name, body := range files {
|
||||||
|
p := filepath.Join(d, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
missing := filepath.Join(t.TempDir(), "none")
|
||||||
|
|
||||||
|
if !run(mk(t, map[string]string{"pairings/a.host.example.json": "{}"}), missing) {
|
||||||
|
t.Fatal("a paired machine with no mode file is local and must stay local")
|
||||||
|
}
|
||||||
|
if !run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": `{"mode":"local"}`})) {
|
||||||
|
t.Fatal("a paired machine in local mode must stay local")
|
||||||
|
}
|
||||||
|
if !run(mk(t, map[string]string{"config.json": "{}"})) {
|
||||||
|
t.Fatal("a pre-0.10 single pairing (config.json) is a pairing")
|
||||||
|
}
|
||||||
|
if !run(missing, mk(t, map[string]string{"pairings/a.json": "{}"})) {
|
||||||
|
t.Fatal("a pairing in the second folder counts")
|
||||||
|
}
|
||||||
|
if run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": "{\n \"mode\": \"networked\"\n}"})) {
|
||||||
|
t.Fatal("a networked machine is not kept local")
|
||||||
|
}
|
||||||
|
if run(mk(t, map[string]string{"pairings/.keep": ""}), missing) {
|
||||||
|
t.Fatal("a machine with no pairing gets the networked default")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,6 +229,7 @@ func main() {
|
|||||||
// Started by the Windows Service Control Manager: the service runs `run`
|
// Started by the Windows Service Control Manager: the service runs `run`
|
||||||
// itself and never reaches the argument parsing below.
|
// itself and never reaches the argument parsing below.
|
||||||
if maybeRunAsWindowsService() {
|
if maybeRunAsWindowsService() {
|
||||||
|
auditFlush(auditFlushOnExit)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
@@ -618,6 +619,9 @@ func main() {
|
|||||||
usage()
|
usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
// The command's audit rows reach the system log before the process ends
|
||||||
|
// (auditlog.go queues them off the op's own path).
|
||||||
|
auditFlush(auditFlushOnExit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "error:", err)
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every value below is invented. Each case is a way a real config carries a
|
||||||
|
// credential on the command line; the record that leaves the machine must
|
||||||
|
// carry none of them, in any field.
|
||||||
|
func TestMCPRecordNeverCarriesASecretValue(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
e mcpEntry
|
||||||
|
secrets []string
|
||||||
|
}{
|
||||||
|
{"a database URL with a password", mcpEntry{command: "npx", args: []string{"-y", "@modelcontextprotocol/server-postgres", "postgresql://demo:PwDemo7731@localhost/demo"}}, []string{"PwDemo7731"}},
|
||||||
|
{"a key in a URL query, and an Authorization header argument", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://mcp.example.com/sse?api_key=QkeyDemo1234", "--header", "Authorization: Bearer HdrDemo45678"}}, []string{"QkeyDemo1234", "HdrDemo45678"}},
|
||||||
|
{"a header given as --header=Name:value", mcpEntry{command: "npx", args: []string{"mcp-remote", "--header=X-Api-Key:HkeyDemo9999"}}, []string{"HkeyDemo9999"}},
|
||||||
|
{"a short password after a secret flag", mcpEntry{command: "demo-srv", args: []string{"--password", "shrt1"}}, []string{"shrt1"}},
|
||||||
|
{"a password that starts with a dollar sign", mcpEntry{command: "demo-srv", args: []string{"--password", "$DollarPw99x"}}, []string{"DollarPw99x"}},
|
||||||
|
{"a short value in docker -e NAME=value", mcpEntry{command: "docker", args: []string{"run", "-e", "API_KEY=ab12", "demo/img"}}, []string{"ab12"}},
|
||||||
|
{"NAME=value inside --env=", mcpEntry{command: "docker", args: []string{"run", "--env=API_TOKEN=TokDemo5678", "demo/img"}}, []string{"TokDemo5678"}},
|
||||||
|
{"the whole command line in command", mcpEntry{command: "demo-srv --token CmdTokDemo42"}, []string{"CmdTokDemo42"}},
|
||||||
|
{"a git URL with a password", mcpEntry{command: "uvx", args: []string{"--from", "git+https://demo:GitPwDemo31@git.example.com/o/r", "demo"}}, []string{"GitPwDemo31"}},
|
||||||
|
{"a git URL with a token as the user", mcpEntry{command: "npx", args: []string{"-y", "git+https://GitUserTok55@git.example.com/o/r"}}, []string{"GitUserTok55"}},
|
||||||
|
{"a token in a URL path", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://hooks.example.com/api/s/PathTokDemo8812/sse"}}, []string{"PathTokDemo8812"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
b, _ := json.Marshal(mcpAnalyze(c.e))
|
||||||
|
for _, s := range c.secrets {
|
||||||
|
if strings.Contains(string(b), s) {
|
||||||
|
t.Errorf("%s: the record carries %q: %s", c.name, s, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A running server no file declares goes through the same redaction.
|
||||||
|
out := correlateMCP(nil, []mcpProc{{PID: 7, PPID: 1, Argv: []string{"node", "/opt/demo/mcp-server.js",
|
||||||
|
"postgresql://demo:ShadowPw77x@db.example.com/x"}}}, true)
|
||||||
|
b, _ := json.Marshal(out)
|
||||||
|
if strings.Contains(string(b), "ShadowPw77x") {
|
||||||
|
t.Errorf("an undeclared server's record carries its password: %s", b)
|
||||||
|
}
|
||||||
|
// What is still kept: a reference to where the secret comes from, and
|
||||||
|
// the URL's host.
|
||||||
|
o := mcpAnalyze(mcpEntry{command: "demo-srv", args: []string{"--token", "${DEMO_TOKEN}", "https://mcp.example.com/sse"}})
|
||||||
|
if !strings.Contains(o.Command, "${DEMO_TOKEN}") || !strings.Contains(o.Command, "https://mcp.example.com") {
|
||||||
|
t.Errorf("redaction took more than the secret: %q", o.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One record, and the list, stay bounded however large the file's names are
|
||||||
|
// and however many processes look like servers.
|
||||||
|
func TestMCPRecordAndListStayBounded(t *testing.T) {
|
||||||
|
env := map[string]string{}
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
env[fmt.Sprintf("DEMO_VAR_%04d_%s", i, strings.Repeat("x", 200))] = ""
|
||||||
|
}
|
||||||
|
o := mcpAnalyze(mcpEntry{name: strings.Repeat("n", 100000), command: "npx",
|
||||||
|
args: []string{"-y", strings.Repeat("p", 100000)}, env: env})
|
||||||
|
if b, _ := json.Marshal(o); len(b) > 8192 {
|
||||||
|
t.Errorf("one stdio record is %d bytes", len(b))
|
||||||
|
}
|
||||||
|
o = mcpAnalyze(mcpEntry{name: "r", url: "https://" + strings.Repeat("a", 100000) + ".example.com/x"})
|
||||||
|
if b, _ := json.Marshal(o); len(b) > 8192 {
|
||||||
|
t.Errorf("one remote record is %d bytes", len(b))
|
||||||
|
}
|
||||||
|
var procs []mcpProc
|
||||||
|
for i := 0; i < 3000; i++ {
|
||||||
|
procs = append(procs, mcpProc{PID: 10 + i, PPID: 1, Argv: []string{"demo-mcp-server"}})
|
||||||
|
}
|
||||||
|
if out := correlateMCP(nil, procs, true); len(out) > mcpMaxServers {
|
||||||
|
t.Errorf("%d records from undeclared servers; the list is capped at %d", len(out), mcpMaxServers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A crafted config.toml nested thousands deep is refused, not walked on the
|
||||||
|
// stack; a realistic depth still parses.
|
||||||
|
func TestParseTOMLSubsetRefusesDeepNesting(t *testing.T) {
|
||||||
|
for name, src := range map[string]string{
|
||||||
|
"arrays": "a = " + strings.Repeat("[", 5000) + strings.Repeat("]", 5000) + "\n",
|
||||||
|
"inline tables": "a = " + strings.Repeat("{b = ", 5000) + "1" + strings.Repeat("}", 5000) + "\n",
|
||||||
|
} {
|
||||||
|
if _, err := parseTOMLSubset(src); err == nil {
|
||||||
|
t.Errorf("%s nested 5000 deep parsed", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := parseTOMLSubset("[mcp_servers.x]\nenv = { A = { B = [[1, 2], [3]] } }\n"); err != nil {
|
||||||
|
t.Errorf("a realistic depth: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS's nobody is uid -2 in its directory; it is a service account, not
|
||||||
|
// an unread uid.
|
||||||
|
func TestClassifyUnixOwnerNegativeDirectoryUID(t *testing.T) {
|
||||||
|
f := classifyUnixOwner(unixOwner{UID: -2, Name: "nobody", SystemUIDMax: darwinSystemUIDMax, LocalRecord: ownerYes})
|
||||||
|
if f.Class != ownerClassService {
|
||||||
|
t.Errorf("uid -2: %+v", f)
|
||||||
|
}
|
||||||
|
if f := classifyUnixOwner(unixOwner{UID: -1, Name: "demo"}); f.Class != ownerUnknown {
|
||||||
|
t.Errorf("an unread uid: %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dscl that was killed by the timeout has not said the record is missing:
|
||||||
|
// only a real non-zero exit does.
|
||||||
|
func TestDsclRecordMissingOnlyOnARealExit(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("needs sh")
|
||||||
|
}
|
||||||
|
_, killed := exec.Command("sh", "-c", "kill -9 $$").Output()
|
||||||
|
_, missing := exec.Command("sh", "-c", "exit 56").Output()
|
||||||
|
if dsclRecordMissing(killed) {
|
||||||
|
t.Error("a killed dscl read as no local record")
|
||||||
|
}
|
||||||
|
if !dsclRecordMissing(missing) {
|
||||||
|
t.Error("dscl exiting 56 did not read as no local record")
|
||||||
|
}
|
||||||
|
if dsclRecordMissing(nil) {
|
||||||
|
t.Error("success read as no local record")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read that stops answering (a home on a network mount) holds neither the
|
||||||
|
// beat nor a second reader, and a finished read is reused for the cache
|
||||||
|
// window instead of redone on every 60-second beat.
|
||||||
|
func TestMCPServersReportNeverWaitsOnAStuckRead(t *testing.T) {
|
||||||
|
oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout
|
||||||
|
block := make(chan struct{})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(block)
|
||||||
|
mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT
|
||||||
|
resetMCPConfigCache()
|
||||||
|
})
|
||||||
|
resetMCPConfigCache()
|
||||||
|
var calls int32
|
||||||
|
mcpHomesFn = func() ([]mcpHome, bool) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
<-block
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
mcpSystemFn = func() []mcpSource { return nil }
|
||||||
|
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, nil }
|
||||||
|
mcpReadTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
|
for beat := 0; beat < 3; beat++ {
|
||||||
|
done := make(chan mcpConfigScan, 1)
|
||||||
|
go func() { _, s := mcpServersReport(); done <- s }()
|
||||||
|
select {
|
||||||
|
case s := <-done:
|
||||||
|
if !s.TimedOut || !s.Supported {
|
||||||
|
t.Fatalf("beat %d: a read that has not finished must say so: %+v", beat, s)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatalf("beat %d waited on a stuck read", beat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||||
|
t.Fatalf("%d reads started while one was stuck", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetMCPConfigCache()
|
||||||
|
atomic.StoreInt32(&calls, 0)
|
||||||
|
home := t.TempDir()
|
||||||
|
mcpHomesFn = func() ([]mcpHome, bool) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
return []mcpHome{{Dir: home, ConfigDir: home}}, true
|
||||||
|
}
|
||||||
|
for beat := 0; beat < 3; beat++ {
|
||||||
|
if _, s := mcpServersReport(); s.TimedOut {
|
||||||
|
t.Fatalf("beat %d timed out: %+v", beat, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||||
|
t.Fatalf("the files were read %d times in one cache window", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read stuck on a home that stopped answering delays ONE beat by at most
|
||||||
|
// mcpReadTimeout; the beats after it do not wait on the same read again. And
|
||||||
|
// until a read has finished, nothing is known to be declared, so no running
|
||||||
|
// process may be reported as running_not_declared.
|
||||||
|
func TestMCPStuckReadDelaysOneBeatAndClaimsNoShadows(t *testing.T) {
|
||||||
|
oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout
|
||||||
|
block := make(chan struct{})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
close(block)
|
||||||
|
mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT
|
||||||
|
resetMCPConfigCache()
|
||||||
|
})
|
||||||
|
resetMCPConfigCache()
|
||||||
|
mcpHomesFn = func() ([]mcpHome, bool) { <-block; return nil, true }
|
||||||
|
mcpSystemFn = func() []mcpSource { return nil }
|
||||||
|
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) {
|
||||||
|
return []mcpProc{{PID: 4242, PPID: 1, Argv: []string{"demo-mcp-server", "--stdio"}}}, nil
|
||||||
|
}
|
||||||
|
mcpReadTimeout = 400 * time.Millisecond
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
out, s := mcpServersReport()
|
||||||
|
if took := time.Since(start); took < mcpReadTimeout/2 {
|
||||||
|
t.Fatalf("the first beat did not wait for the read at all (%v)", took)
|
||||||
|
}
|
||||||
|
if !s.TimedOut || len(out) != 0 {
|
||||||
|
t.Fatalf("before any read finished: timed_out %v, servers %+v", s.TimedOut, out)
|
||||||
|
}
|
||||||
|
for beat := 1; beat < 4; beat++ {
|
||||||
|
start := time.Now()
|
||||||
|
out, s := mcpServersReport()
|
||||||
|
if took := time.Since(start); took > mcpReadTimeout/2 {
|
||||||
|
t.Fatalf("beat %d waited %v on a read an earlier beat already waited out", beat, took)
|
||||||
|
}
|
||||||
|
if !s.TimedOut || len(out) != 0 {
|
||||||
|
t.Fatalf("beat %d: timed_out %v, servers %+v", beat, s.TimedOut, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The accounts whose agent clients mcpconfig.go reads on macOS: the service's
|
||||||
|
// own home and every directory under /Users except Shared. The per-user
|
||||||
|
// configuration directory is ~/Library/Application Support.
|
||||||
|
func mcpHomes() ([]mcpHome, bool) {
|
||||||
|
var homes []string
|
||||||
|
if h, err := os.UserHomeDir(); err == nil && h != "" {
|
||||||
|
homes = append(homes, h)
|
||||||
|
}
|
||||||
|
if entries, err := os.ReadDir("/Users"); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() && e.Name() != "Shared" && e.Name()[0] != '.' {
|
||||||
|
homes = append(homes, filepath.Join("/Users", e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []mcpHome
|
||||||
|
for _, h := range dedupeStrings(homes) {
|
||||||
|
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, "Library", "Application Support")})
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpSystemFiles() []mcpSource { return mcpSystemSources("darwin", "") }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The accounts whose agent clients mcpconfig.go reads on Linux: the same
|
||||||
|
// homes the app inventory reads (userHomes: the service's own home and every
|
||||||
|
// directory under /home). A home this process may not enter yields files it
|
||||||
|
// counts as unreadable, never as empty. The per-user configuration directory
|
||||||
|
// is the XDG default, ~/.config; another account's XDG_CONFIG_HOME lives in
|
||||||
|
// its own environment, which a system service does not see.
|
||||||
|
func mcpHomes() ([]mcpHome, bool) {
|
||||||
|
var out []mcpHome
|
||||||
|
for _, h := range userHomes() {
|
||||||
|
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, ".config")})
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpSystemFiles() []mcpSource { return mcpSystemSources("linux", "") }
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A FIFO left where a client keeps its config must not hold the read, and
|
||||||
|
// with it the heartbeat: it is counted unreadable and the scan moves on.
|
||||||
|
func TestReadMCPConfigsDoesNotBlockOnAFIFO(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := syscall.Mkfifo(filepath.Join(home, ".cursor", "mcp.json"), 0o600); err != nil {
|
||||||
|
t.Skipf("cannot make a FIFO here: %v", err)
|
||||||
|
}
|
||||||
|
oldH, oldS := mcpHomesFn, mcpSystemFn
|
||||||
|
t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldH, oldS })
|
||||||
|
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: home, ConfigDir: home}}, true }
|
||||||
|
mcpSystemFn = func() []mcpSource { return nil }
|
||||||
|
|
||||||
|
done := make(chan mcpConfigScan, 1)
|
||||||
|
go func() { _, scan := readMCPConfigs(); done <- scan }()
|
||||||
|
select {
|
||||||
|
case scan := <-done:
|
||||||
|
if scan.Unreadable != 1 {
|
||||||
|
t.Fatalf("the FIFO was not counted: %+v", scan)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("reading the config files blocked on a FIFO")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mcpOpenRegular opens path for reading only if what it names, after any
|
||||||
|
// symlinks, is a regular file, without ever opening anything else. macOS has
|
||||||
|
// no O_PATH, so the links are resolved first, the resolved path is required
|
||||||
|
// to be a regular file, and it is then opened with O_NOFOLLOW_ANY: a link
|
||||||
|
// put in its place since the look makes the open fail rather than lead
|
||||||
|
// somewhere new, and a device can only sit in devfs, which the look already
|
||||||
|
// refused. The opened file must be the one looked at.
|
||||||
|
func mcpOpenRegular(path string) (*os.File, string, error) {
|
||||||
|
resolved, err := filepath.EvalSymlinks(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
lst, err := os.Lstat(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if !lst.Mode().IsRegular() {
|
||||||
|
return nil, "", errMCPNotRegular
|
||||||
|
}
|
||||||
|
fd, err := unix.Open(resolved, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK|unix.O_NOFOLLOW_ANY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", &os.PathError{Op: "open", Path: path, Err: err}
|
||||||
|
}
|
||||||
|
f := os.NewFile(uintptr(fd), path)
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, lst) {
|
||||||
|
f.Close()
|
||||||
|
return nil, "", errMCPNotRegular
|
||||||
|
}
|
||||||
|
return f, mcpFileOwner(fi), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mcpOpenRegular opens path for reading only if what it names, after any
|
||||||
|
// symlinks, is a regular file, without ever opening anything else. The first
|
||||||
|
// open is O_PATH: it resolves the path and pins the inode but does not open
|
||||||
|
// the file (no driver runs, a FIFO does not wait). Only once fstat says
|
||||||
|
// regular is that same inode reopened for reading through /proc/self/fd, so
|
||||||
|
// swapping the path between the look and the read reaches nothing new.
|
||||||
|
func mcpOpenRegular(path string) (*os.File, string, error) {
|
||||||
|
pfd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", &os.PathError{Op: "open", Path: path, Err: err}
|
||||||
|
}
|
||||||
|
defer unix.Close(pfd)
|
||||||
|
var st unix.Stat_t
|
||||||
|
if err := unix.Fstat(pfd, &st); err != nil {
|
||||||
|
return nil, "", &os.PathError{Op: "fstat", Path: path, Err: err}
|
||||||
|
}
|
||||||
|
if st.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||||
|
return nil, "", errMCPNotRegular
|
||||||
|
}
|
||||||
|
fd, err := unix.Open("/proc/self/fd/"+strconv.Itoa(pfd), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", &os.PathError{Op: "reopen", Path: path, Err: err}
|
||||||
|
}
|
||||||
|
f := os.NewFile(uintptr(fd), path)
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil || !fi.Mode().IsRegular() {
|
||||||
|
f.Close()
|
||||||
|
return nil, "", errMCPNotRegular
|
||||||
|
}
|
||||||
|
return f, mcpFileOwner(fi), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A config path that is, or links to, something other than a regular file
|
||||||
|
// is never OPENED for reading. Opening is not harmless: a device's driver
|
||||||
|
// runs on open (a watchdog arms, a serial line toggles DTR), so a symlink at
|
||||||
|
// ~/.cursor/mcp.json pointing at one would let any account make this root
|
||||||
|
// service act on hardware. inotify reports every real open of the target;
|
||||||
|
// the reader's look at what the path is must not be one.
|
||||||
|
func TestMCPReadFileNeverOpensANonRegularTarget(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fifo := filepath.Join(dir, "target")
|
||||||
|
if err := unix.Mkfifo(fifo, 0o600); err != nil {
|
||||||
|
t.Skipf("cannot make a FIFO here: %v", err)
|
||||||
|
}
|
||||||
|
link := filepath.Join(dir, "mcp.json")
|
||||||
|
if err := os.Symlink(fifo, link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ino, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no inotify: %v", err)
|
||||||
|
}
|
||||||
|
defer unix.Close(ino)
|
||||||
|
if _, err := unix.InotifyAddWatch(ino, fifo, unix.IN_OPEN); err != nil {
|
||||||
|
t.Skipf("cannot watch: %v", err)
|
||||||
|
}
|
||||||
|
var scan mcpConfigScan
|
||||||
|
for _, p := range []string{link, fifo} {
|
||||||
|
if b, ok := mcpReadFile(p, &scan); ok || b != nil {
|
||||||
|
t.Fatalf("%s read as a config file", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
if n, _ := unix.Read(ino, buf); n > 0 {
|
||||||
|
t.Fatalf("the FIFO was opened %d time(s) by the reader", n/unix.SizeofInotifyEvent)
|
||||||
|
}
|
||||||
|
if scan.Unreadable != 2 {
|
||||||
|
t.Fatalf("scan: %+v", scan)
|
||||||
|
}
|
||||||
|
// A regular file through a symlink is still read, as the client would.
|
||||||
|
real := filepath.Join(dir, "real.json")
|
||||||
|
if err := os.WriteFile(real, []byte(`{"mcpServers":{}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
link2 := filepath.Join(dir, "link2.json")
|
||||||
|
if err := os.Symlink(real, link2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := mcpReadFile(link2, &scan); !ok {
|
||||||
|
t.Fatal("a symlink to a regular file was not read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file in one account's home, or in a project that account listed, is read
|
||||||
|
// only when that account or root owns it. Otherwise one account could point
|
||||||
|
// its own config path, or its list of projects, at another account's files
|
||||||
|
// and have this service read them on its behalf.
|
||||||
|
func TestReadMCPConfigsSkipsAFileAnotherAccountOwns(t *testing.T) {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
t.Skip("needs root to make files owned by other uids")
|
||||||
|
}
|
||||||
|
const owner, other = 42101, 42102
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Chmod(root, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home := filepath.Join(root, "home", "demo")
|
||||||
|
victim := filepath.Join(root, "victim")
|
||||||
|
write := func(p, body string, uid int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chown(p, uid, uid); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write(filepath.Join(victim, "secret.json"), `{"mcpServers": {"victim-file": {"command": "demo-v"}}}`, other)
|
||||||
|
write(filepath.Join(victim, ".mcp.json"), `{"mcpServers": {"victim-project": {"command": "demo-w"}}}`, other)
|
||||||
|
write(filepath.Join(root, "rootowned.json"), `{"mcpServers": {"root-file": {"command": "demo-r"}}}`, 0)
|
||||||
|
projJSON, _ := json.Marshal(victim)
|
||||||
|
write(filepath.Join(home, ".claude.json"), `{"mcpServers": {"own": {"command": "demo-o"}}, "projects": {`+string(projJSON)+`: {}}}`, owner)
|
||||||
|
if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(victim, "secret.json"), filepath.Join(home, ".cursor", "mcp.json")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A dotfile manager's link to a root-owned file is the account's own.
|
||||||
|
if err := os.MkdirAll(filepath.Join(home, ".codeium", "windsurf"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(root, "rootowned.json"), filepath.Join(home, ".codeium", "windsurf", "mcp_config.json")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chown(home, owner, owner); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldH, oldS, oldOS := mcpHomesFn, mcpSystemFn, mcpGOOS
|
||||||
|
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, mcpGOOS = oldH, oldS, oldOS })
|
||||||
|
mcpGOOS = "linux"
|
||||||
|
mcpHomesFn = func() ([]mcpHome, bool) {
|
||||||
|
return []mcpHome{{Dir: home, ConfigDir: filepath.Join(home, ".config")}}, true
|
||||||
|
}
|
||||||
|
mcpSystemFn = func() []mcpSource { return nil }
|
||||||
|
|
||||||
|
got, scan := readMCPConfigs()
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, o := range got {
|
||||||
|
names[o.Name] = true
|
||||||
|
}
|
||||||
|
if names["victim-file"] || names["victim-project"] {
|
||||||
|
t.Errorf("read another account's file on this account's behalf: %v", names)
|
||||||
|
}
|
||||||
|
if !names["own"] || !names["root-file"] {
|
||||||
|
t.Errorf("the account's own and root-owned files must still be read: %v", names)
|
||||||
|
}
|
||||||
|
if scan.ForeignOwner != 2 {
|
||||||
|
t.Errorf("scan: %+v", scan)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//go:build !linux && !darwin && !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// No account list on this platform (mcpconfig_other.go), so the reader never
|
||||||
|
// gets here; this keeps the package building. A path is still opened only
|
||||||
|
// when it was a regular file, and the opened file must be the one looked at.
|
||||||
|
func mcpOpenRegular(path string) (*os.File, string, error) {
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if !st.Mode().IsRegular() {
|
||||||
|
return nil, "", errMCPNotRegular
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, st) {
|
||||||
|
f.Close()
|
||||||
|
return nil, "", errMCPNotRegular
|
||||||
|
}
|
||||||
|
return f, mcpOwnerUnknown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var mcpAdminOwners = map[string]bool{}
|
||||||
|
|
||||||
|
// mcpPathOwner: no owner model here, so no owner check.
|
||||||
|
func mcpPathOwner(string) string { return mcpNoOwnerCheck }
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The decisions are in mcpconfig_winopen.go; this file makes the calls.
|
||||||
|
|
||||||
|
// The values mcpconfig_winopen.go spells out must be Windows' own: each line
|
||||||
|
// fails to compile if one differs.
|
||||||
|
var (
|
||||||
|
_ = [1]struct{}{}[mcpWinFileTypeDisk^windows.FILE_TYPE_DISK]
|
||||||
|
_ = [1]struct{}{}[mcpWinAttrDirectory^windows.FILE_ATTRIBUTE_DIRECTORY]
|
||||||
|
_ = [1]struct{}{}[mcpWinAttrDevice^windows.FILE_ATTRIBUTE_DEVICE]
|
||||||
|
_ = [1]struct{}{}[mcpWinAttrReparse^windows.FILE_ATTRIBUTE_REPARSE_POINT]
|
||||||
|
)
|
||||||
|
|
||||||
|
var mcpAdminOwners = mcpWinAdminOwners
|
||||||
|
|
||||||
|
// mcpOpenRegular opens path with CreateFile and keeps it only when the
|
||||||
|
// handle is a regular file on disk (mcpWinOpenWith). It shares read, write
|
||||||
|
// and delete with everyone else: the agent client that owns the file must be
|
||||||
|
// able to save it, including by replacing it, while this reads it.
|
||||||
|
func mcpOpenRegular(path string) (*os.File, string, error) {
|
||||||
|
return mcpWinOpenWith(mcpWinSystemOps, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mcpWinSystemOps = mcpWinOps{
|
||||||
|
openPath: mcpWinCreateFile,
|
||||||
|
handleInfo: mcpWinHandleFacts,
|
||||||
|
closeHandle: func(h uintptr) { windows.CloseHandle(windows.Handle(h)) },
|
||||||
|
asFile: func(h uintptr, path string) *os.File { return os.NewFile(h, path) },
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpWinCreateFile(path string) (uintptr, error) {
|
||||||
|
p, err := windows.UTF16PtrFromString(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, &os.PathError{Op: "open", Path: path, Err: err}
|
||||||
|
}
|
||||||
|
h, err := windows.CreateFile(p, windows.GENERIC_READ,
|
||||||
|
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil,
|
||||||
|
windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, &os.PathError{Op: "open", Path: path, Err: err}
|
||||||
|
}
|
||||||
|
return uintptr(h), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpWinHandleFacts reads the type, the attributes and the owner from the
|
||||||
|
// handle. GENERIC_READ carries READ_CONTROL, which GetSecurityInfo needs for
|
||||||
|
// the owner; an owner that cannot be read is left "" and the file is then
|
||||||
|
// read only where no owner check applies.
|
||||||
|
func mcpWinHandleFacts(h uintptr) (mcpWinHandleInfo, error) {
|
||||||
|
wh := windows.Handle(h)
|
||||||
|
var i mcpWinHandleInfo
|
||||||
|
t, err := windows.GetFileType(wh)
|
||||||
|
if err != nil {
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
i.FileType = t
|
||||||
|
if t != windows.FILE_TYPE_DISK {
|
||||||
|
return i, nil // refused by mcpWinRegular; nothing more to ask it
|
||||||
|
}
|
||||||
|
var bi windows.ByHandleFileInformation
|
||||||
|
if err := windows.GetFileInformationByHandle(wh, &bi); err != nil {
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
i.Attributes = bi.FileAttributes
|
||||||
|
if sd, err := windows.GetSecurityInfo(wh, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION); err == nil {
|
||||||
|
if o, _, err := sd.Owner(); err == nil && o != nil {
|
||||||
|
i.Owner = o.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpPathOwner is the SID whose profile dir is, from the ProfileList the
|
||||||
|
// User Profile Service keeps
|
||||||
|
// (HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\<SID>,
|
||||||
|
// value ProfileImagePath). A directory it does not name, or names twice,
|
||||||
|
// gives mcpOwnerUnknown, so only SYSTEM's and the Administrators group's
|
||||||
|
// files are read there.
|
||||||
|
func mcpPathOwner(dir string) string {
|
||||||
|
return mcpWinProfileSID(dir, mcpWinProfiles())
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpWinProfiles() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
||||||
|
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList`, registry.ENUMERATE_SUB_KEYS)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
sids, err := k.ReadSubKeyNames(-1)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, sid := range sids {
|
||||||
|
sk, err := registry.OpenKey(k, sid, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, typ, err := sk.GetStringValue("ProfileImagePath")
|
||||||
|
sk.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if typ == registry.EXPAND_SZ {
|
||||||
|
if x, err := registry.ExpandString(p); err == nil {
|
||||||
|
p = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[sid] = p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !linux && !darwin && !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// No account list on this platform: the scan says unsupported rather than
|
||||||
|
// reporting a machine with no tool servers.
|
||||||
|
func mcpHomes() ([]mcpHome, bool) { return nil, false }
|
||||||
|
|
||||||
|
func mcpSystemFiles() []mcpSource { return nil }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build linux || darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mcpAdminOwners: root owns files in any account's home without that being
|
||||||
|
// another account's file.
|
||||||
|
var mcpAdminOwners = map[string]bool{"0": true}
|
||||||
|
|
||||||
|
// mcpFileOwner is the uid that owns an opened file, as mcpOwnerAllowed
|
||||||
|
// compares it.
|
||||||
|
func mcpFileOwner(fi os.FileInfo) string {
|
||||||
|
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||||
|
return strconv.FormatUint(uint64(st.Uid), 10)
|
||||||
|
}
|
||||||
|
return mcpOwnerUnknown // matches no account and not root: the file is skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpPathOwner is the uid that owns an account's home: the account whose
|
||||||
|
// files are read there. A home that cannot be looked at gives root, so only
|
||||||
|
// root-owned files would be read in it.
|
||||||
|
func mcpPathOwner(dir string) string {
|
||||||
|
st, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
if s, ok := st.Sys().(*syscall.Stat_t); ok {
|
||||||
|
return strconv.FormatUint(uint64(s.Uid), 10)
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every fixture here is invented: server names, packages, hosts, paths and
|
||||||
|
// the secret-shaped values, which are built from repeated letters so no real
|
||||||
|
// credential can be in this file.
|
||||||
|
|
||||||
|
var (
|
||||||
|
fakeAnthropicKey = "sk-ant-api03-" + strings.Repeat("Q", 40)
|
||||||
|
fakeGitHubToken = "ghp_" + strings.Repeat("Z", 36)
|
||||||
|
fakeLiteral = "plain-" + strings.Repeat("k", 12)
|
||||||
|
)
|
||||||
|
|
||||||
|
func entriesByName(t *testing.T, format, body string) map[string]mcpEntry {
|
||||||
|
t.Helper()
|
||||||
|
es, _, err := mcpParse(format, []byte(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", format, err)
|
||||||
|
}
|
||||||
|
out := map[string]mcpEntry{}
|
||||||
|
for _, e := range es {
|
||||||
|
out[e.name] = e
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPParseClaudeDesktopShape(t *testing.T) {
|
||||||
|
es := entriesByName(t, mcpFormatServers, `{"mcpServers": {
|
||||||
|
"files": {"command": "npx", "args": ["-y", "@demo-scope/server-files@1.4.2", "/srv/demo"]},
|
||||||
|
"search": {"command": "uvx", "args": ["demo-search"], "env": {"DEMO_API_KEY": "`+fakeLiteral+`"}}
|
||||||
|
}, "otherSetting": true}`)
|
||||||
|
if len(es) != 2 || es["files"].command != "npx" || es["search"].env["DEMO_API_KEY"] == "" {
|
||||||
|
t.Fatalf("entries: %+v", es)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPParseClaudeCodeUserAndProjects(t *testing.T) {
|
||||||
|
body := `{"numStartups": 3, "mcpServers": {"u1": {"type": "stdio", "command": "demo-tool"}},
|
||||||
|
"projects": {
|
||||||
|
"/home/demo/proj-a": {"mcpServers": {"p1": {"type": "http", "url": "https://tools.example.com/x"}}},
|
||||||
|
"/home/demo/proj-b": {"allowedTools": []}
|
||||||
|
}}`
|
||||||
|
es, projects, err := mcpParse(mcpFormatClaude, []byte(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(es) != 2 || es[0].name != "u1" || es[0].projectScoped || es[1].name != "p1" || !es[1].projectScoped {
|
||||||
|
t.Fatalf("entries: %+v", es)
|
||||||
|
}
|
||||||
|
want := []string{filepath.FromSlash("/home/demo/proj-a"), filepath.FromSlash("/home/demo/proj-b")}
|
||||||
|
if !reflect.DeepEqual(projects, want) {
|
||||||
|
t.Fatalf("projects: %v", projects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPParseVSCodeJSONC(t *testing.T) {
|
||||||
|
es := entriesByName(t, mcpFormatVSCode, `{
|
||||||
|
// a comment the file is allowed to carry
|
||||||
|
"inputs": [{"id": "tok", "type": "promptString", "password": true}],
|
||||||
|
"servers": {
|
||||||
|
"remote": {"type": "sse", "url": "https://tools.example.com/sse", "headers": {"Authorization": "Bearer ${input:tok}"},},
|
||||||
|
/* a block comment, "with a quote" */
|
||||||
|
"local": {"command": "node", "args": ["/opt/demo/server.js"],},
|
||||||
|
},
|
||||||
|
}`)
|
||||||
|
if es["remote"].typ != "sse" || es["local"].command != "node" || len(es) != 2 {
|
||||||
|
t.Fatalf("entries: %+v", es)
|
||||||
|
}
|
||||||
|
// A // inside a string is data, not a comment.
|
||||||
|
es = entriesByName(t, mcpFormatVSCode, `{"servers": {"u": {"url": "https://h.example.com//x"}}}`)
|
||||||
|
if es["u"].url != "https://h.example.com//x" {
|
||||||
|
t.Fatalf("a URL lost its slashes: %q", es["u"].url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPParseZedBothCommandForms(t *testing.T) {
|
||||||
|
es := entriesByName(t, mcpFormatZed, `{
|
||||||
|
"theme": "One Dark", // Zed's settings carry everything else too
|
||||||
|
"context_servers": {
|
||||||
|
"flat": {"source": "custom", "command": "demo-mcp-server", "args": ["--stdio"], "env": {"DEMO_TOKEN": "`+fakeLiteral+`"}},
|
||||||
|
"nested": {"command": {"path": "/usr/local/bin/demo", "args": ["serve"], "env": {"LOG_LEVEL": "info"}}},
|
||||||
|
"remote": {"url": "https://zed-tools.example.com/mcp", "headers": {"X-Api-Key": "`+fakeLiteral+`"}}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
if es["flat"].command != "demo-mcp-server" || es["nested"].command != "/usr/local/bin/demo" ||
|
||||||
|
!reflect.DeepEqual(es["nested"].args, []string{"serve"}) || es["nested"].env["LOG_LEVEL"] != "info" ||
|
||||||
|
es["remote"].headers["X-Api-Key"] == "" {
|
||||||
|
t.Fatalf("entries: %+v", es)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPParseWindsurfServerURL(t *testing.T) {
|
||||||
|
es := entriesByName(t, mcpFormatServers, `{"mcpServers": {"w": {"serverUrl": "https://ws-tools.example.com/mcp"}}}`)
|
||||||
|
if es["w"].url != "https://ws-tools.example.com/mcp" {
|
||||||
|
t.Fatalf("serverUrl not read: %+v", es)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPParseCodexTOML(t *testing.T) {
|
||||||
|
body := `# invented Codex config
|
||||||
|
model = "demo-model"
|
||||||
|
|
||||||
|
[mcp_servers.docs]
|
||||||
|
command = "npx"
|
||||||
|
args = [
|
||||||
|
"-y", # a comment inside an array
|
||||||
|
"demo-docs-server@2.0.1",
|
||||||
|
]
|
||||||
|
env = { "DEMO_KEY" = "` + fakeLiteral + `", PLAIN = 'x' }
|
||||||
|
env_vars = ["HOME_HINT", { name = "FORWARDED", source = "local" }]
|
||||||
|
startup_timeout_sec = 20
|
||||||
|
|
||||||
|
[mcp_servers."remote tools"]
|
||||||
|
url = "https://codex-tools.example.com/mcp"
|
||||||
|
bearer_token_env_var = "DEMO_BEARER"
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[mcp_servers.docs.tools.search]
|
||||||
|
approval_mode = "approve"
|
||||||
|
|
||||||
|
[[profiles_list]]
|
||||||
|
command = "not a server"
|
||||||
|
`
|
||||||
|
es := entriesByName(t, mcpFormatCodex, body)
|
||||||
|
d := es["docs"]
|
||||||
|
if d.command != "npx" || !reflect.DeepEqual(d.args, []string{"-y", "demo-docs-server@2.0.1"}) ||
|
||||||
|
d.env["DEMO_KEY"] == "" || d.env["PLAIN"] != "x" ||
|
||||||
|
!reflect.DeepEqual(d.envNames, []string{"HOME_HINT", "FORWARDED"}) {
|
||||||
|
t.Fatalf("docs: %+v", d)
|
||||||
|
}
|
||||||
|
r := es["remote tools"]
|
||||||
|
if r.url == "" || !r.authHint || !r.disabled || !reflect.DeepEqual(r.envNames, []string{"DEMO_BEARER"}) {
|
||||||
|
t.Fatalf("remote: %+v", r)
|
||||||
|
}
|
||||||
|
if len(es) != 2 {
|
||||||
|
t.Fatalf("an [[array of tables]] leaked in, or a server was lost: %v", es)
|
||||||
|
}
|
||||||
|
if _, err := parseTOMLSubset("[mcp_servers.x]\ncommand = \"unterminated\n"); err == nil {
|
||||||
|
t.Fatal("a broken line must fail the file, not half-read it")
|
||||||
|
}
|
||||||
|
if v, _ := parseTOMLSubset(`k = "a\tb\u00e9\"q"`); v["k"] != "a\tbé\"q" {
|
||||||
|
t.Fatalf("escapes: %q", v["k"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE VALUE NEVER LEAVES. A secret in env, a header, an argument or the URL
|
||||||
|
// sets one bool; the marshalled record carries the variable's NAME and
|
||||||
|
// nothing of the value.
|
||||||
|
func TestMCPAnalyzeKeepsNamesAndDropsValues(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
e mcpEntry
|
||||||
|
}{
|
||||||
|
{"env, known prefix", mcpEntry{name: "a", command: "npx", args: []string{"demo"},
|
||||||
|
env: map[string]string{"ANTHROPIC_API_KEY": fakeAnthropicKey}}},
|
||||||
|
{"env, known prefix under a plain name", mcpEntry{name: "a2", command: "demo",
|
||||||
|
env: map[string]string{"DEMO_SETTING": fakeAnthropicKey}}},
|
||||||
|
{"env, literal under a secret name", mcpEntry{name: "b", command: "demo",
|
||||||
|
env: map[string]string{"SERVICE_TOKEN": fakeLiteral}}},
|
||||||
|
{"header", mcpEntry{name: "c", url: "https://h.example.com/mcp",
|
||||||
|
headers: map[string]string{"Authorization": "Bearer " + fakeLiteral}}},
|
||||||
|
{"argument after a flag", mcpEntry{name: "d", command: "demo", args: []string{"--api-key", fakeLiteral}}},
|
||||||
|
{"argument with =", mcpEntry{name: "e", command: "demo", args: []string{"--token=" + fakeLiteral}}},
|
||||||
|
{"bare token argument", mcpEntry{name: "f", command: "demo", args: []string{fakeGitHubToken}}},
|
||||||
|
{"docker -e NAME=value", mcpEntry{name: "g", command: "docker",
|
||||||
|
args: []string{"run", "-i", "--rm", "-e", "GITHUB_TOKEN=" + fakeLiteral, "ghcr.io/demo/server"}}},
|
||||||
|
{"URL query", mcpEntry{name: "h", url: "https://h.example.com/mcp?api_key=" + fakeLiteral}},
|
||||||
|
{"URL password", mcpEntry{name: "i", url: "https://u:" + fakeLiteral + "@h.example.com/mcp"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
o := mcpAnalyze(c.e)
|
||||||
|
if !o.SecretInPlaintext {
|
||||||
|
t.Errorf("%s: the secret was not seen", c.name)
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(o)
|
||||||
|
for _, v := range []string{fakeAnthropicKey, fakeLiteral, fakeGitHubToken} {
|
||||||
|
if strings.Contains(string(b), v) {
|
||||||
|
t.Errorf("%s: a value left in the record: %s", c.name, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o := mcpAnalyze(mcpEntry{name: "a", command: "npx", args: []string{"demo"},
|
||||||
|
env: map[string]string{"B_KEY": "${env:B_KEY}", "A_URL": "https://h.example.com", "LOG": "debug",
|
||||||
|
"KEY_FILE": "/etc/demo/key.pem"}, envNames: []string{"FORWARDED", "LOG"}})
|
||||||
|
if o.SecretInPlaintext {
|
||||||
|
t.Fatal("a reference, a URL, a path and a short value are not secrets in plain text")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(o.EnvNames, []string{"A_URL", "B_KEY", "FORWARDED", "KEY_FILE", "LOG"}) {
|
||||||
|
t.Fatalf("env names: %v", o.EnvNames)
|
||||||
|
}
|
||||||
|
if b, _ := json.Marshal(o); strings.Contains(string(b), "debug") || strings.Contains(string(b), "key.pem") {
|
||||||
|
t.Fatalf("an env value travelled: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPPackageAndPinned(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
cmd string
|
||||||
|
args []string
|
||||||
|
source, name, pinned string
|
||||||
|
}{
|
||||||
|
{"npx", []string{"-y", "@demo-scope/server-files@1.4.2", "/srv"}, "npx", "@demo-scope/server-files", "yes"},
|
||||||
|
{"npx", []string{"-y", "@demo-scope/server-files"}, "npx", "@demo-scope/server-files", "no"},
|
||||||
|
{"npx", []string{"demo-server@latest"}, "npx", "demo-server", "no"},
|
||||||
|
{"npx", []string{"demo-server@^1.2.0"}, "npx", "demo-server", "no"},
|
||||||
|
{"npx", []string{"--package", "demo-pkg@3.0.0", "demo-bin"}, "npx", "demo-pkg", "yes"},
|
||||||
|
{"C:\\Program Files\\nodejs\\npx.cmd", []string{"-y", "demo-win@0.1.0"}, "npx", "demo-win", "yes"},
|
||||||
|
{"pnpm", []string{"dlx", "demo-dlx@1.0.0"}, "npx", "demo-dlx", "yes"},
|
||||||
|
{"npx", []string{"github:demo/repo"}, "npx", "github:demo/repo", "unknown"},
|
||||||
|
{"uvx", []string{"demo-py==0.3.1"}, "uvx", "demo-py", "yes"},
|
||||||
|
{"uvx", []string{"demo-py@0.3.1"}, "uvx", "demo-py", "yes"},
|
||||||
|
{"uvx", []string{"--from", "demo-py>=0.3", "demo-cli"}, "uvx", "demo-py", "no"},
|
||||||
|
{"uvx", []string{"demo-py"}, "uvx", "demo-py", "no"},
|
||||||
|
{"uv", []string{"tool", "run", "demo-py==1.0"}, "uvx", "demo-py", "yes"},
|
||||||
|
{"pipx", []string{"run", "demo-pipx==2.1"}, "pip", "demo-pipx", "yes"},
|
||||||
|
{"docker", []string{"run", "-i", "--rm", "-e", "X", "ghcr.io/demo/srv:1.2"}, "docker", "ghcr.io/demo/srv", "no"},
|
||||||
|
{"docker", []string{"run", "--rm", "localhost:5000/demo/srv@sha256:" + strings.Repeat("a", 64)}, "docker", "localhost:5000/demo/srv", "yes"},
|
||||||
|
{"docker", []string{"run", "-i", "demo/srv"}, "docker", "demo/srv", "no"},
|
||||||
|
{"node", []string{"/opt/demo/server.js"}, "unknown", "", "unknown"},
|
||||||
|
{"/usr/local/bin/demo-mcp-server", []string{"stdio"}, "binary", "", "unknown"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
s, n, p := mcpPackage(c.cmd, c.args)
|
||||||
|
if s != c.source || n != c.name || p != c.pinned {
|
||||||
|
t.Errorf("%s %v: got %s %q %s, want %s %q %s", c.cmd, c.args, s, n, p, c.source, c.name, c.pinned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPTransportLocalityAuth(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
e mcpEntry
|
||||||
|
transport, locality, auth, urlHst string
|
||||||
|
}{
|
||||||
|
{mcpEntry{command: "demo"}, "stdio", "local_process", "not_applicable", ""},
|
||||||
|
{mcpEntry{typ: "streamable-http", url: "https://tools.example.com/mcp"}, "http", "remote", "unknown", "tools.example.com"},
|
||||||
|
{mcpEntry{url: "https://tools.example.com/sse"}, "sse", "remote", "unknown", "tools.example.com"},
|
||||||
|
{mcpEntry{url: "wss://tools.example.com/x"}, "websocket", "remote", "unknown", "tools.example.com"},
|
||||||
|
{mcpEntry{typ: "ws", url: "ws://127.0.0.1:9000"}, "websocket", "loopback", "unknown", "127.0.0.1"},
|
||||||
|
{mcpEntry{url: "http://localhost:3333/mcp"}, "http", "loopback", "unknown", "localhost"},
|
||||||
|
{mcpEntry{url: "http://10.20.30.40:80/mcp"}, "http", "lan", "unknown", "10.20.30.40"},
|
||||||
|
{mcpEntry{url: "http://toolbox.internal/mcp"}, "http", "lan", "unknown", "toolbox.internal"},
|
||||||
|
{mcpEntry{url: "http://toolbox/mcp"}, "http", "lan", "unknown", "toolbox"},
|
||||||
|
{mcpEntry{url: "https://t.example.com/mcp", headers: map[string]string{"Authorization": "Bearer ${env:T}"}}, "http", "remote", "yes", "t.example.com"},
|
||||||
|
{mcpEntry{url: "https://t.example.com/mcp", authHint: true}, "http", "remote", "yes", "t.example.com"},
|
||||||
|
{mcpEntry{typ: "carrier-pigeon", url: "https://t.example.com"}, "unknown", "remote", "unknown", "t.example.com"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
o := mcpAnalyze(c.e)
|
||||||
|
if o.Transport != c.transport || o.Locality != c.locality || o.AuthConfigured != c.auth || o.URLHost != c.urlHst {
|
||||||
|
t.Errorf("%+v: got %s %s %s %q", c.e, o.Transport, o.Locality, o.AuthConfigured, o.URLHost)
|
||||||
|
}
|
||||||
|
if c.transport == "stdio" && o.BindClass != "not_applicable" || c.transport != "stdio" && o.BindClass != "unknown" {
|
||||||
|
t.Errorf("%+v: bind %s", c.e, o.BindClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The command is the cmdline handling's: joined, and cut at 200.
|
||||||
|
long := mcpAnalyze(mcpEntry{command: "demo", args: []string{strings.Repeat("a", 300)}})
|
||||||
|
if len(long.Command) != 200 {
|
||||||
|
t.Fatalf("command not truncated like a cmdline: %d", len(long.Command))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorrelateMCPStates(t *testing.T) {
|
||||||
|
declared := []mcpServerObs{
|
||||||
|
mcpAnalyze(mcpEntry{name: "files", command: "npx", args: []string{"-y", "@demo-scope/server-files@1.4.2"}}),
|
||||||
|
mcpAnalyze(mcpEntry{name: "idle", command: "uvx", args: []string{"demo-idle"}}),
|
||||||
|
mcpAnalyze(mcpEntry{name: "bin", command: "/opt/demo/bin/demo-tool", args: []string{"serve"}}),
|
||||||
|
mcpAnalyze(mcpEntry{name: "saas", url: "https://tools.example.com/mcp"}),
|
||||||
|
mcpAnalyze(mcpEntry{name: "local-url", url: "http://127.0.0.1:7000/mcp"}),
|
||||||
|
}
|
||||||
|
procs := []mcpProc{
|
||||||
|
{PID: 10, PPID: 1, Argv: []string{"demo-client"}},
|
||||||
|
{PID: 20, PPID: 10, Argv: []string{"npm", "exec", "-y", "@demo-scope/server-files@1.4.2"}},
|
||||||
|
// npx's child: no package name in its own line, inherits the match.
|
||||||
|
{PID: 21, PPID: 20, Argv: []string{"node", "/home/demo/.npm/_npx/abc/node_modules/.bin/mcp-server-files"}},
|
||||||
|
{PID: 30, PPID: 10, Argv: []string{"/opt/demo/bin/demo-tool", "serve"}},
|
||||||
|
// Shadow: a server nobody declared, and its own child (collapsed).
|
||||||
|
{PID: 40, PPID: 1, Argv: []string{"node", "/opt/x/node_modules/@modelcontextprotocol/server-memory/dist/index.js"}},
|
||||||
|
{PID: 41, PPID: 40, Argv: []string{"node", "/opt/x/mcp-worker.js"}},
|
||||||
|
{PID: 50, PPID: 1, Argv: []string{"/usr/bin/github-mcp-server", "stdio", "--token=" + fakeGitHubToken}},
|
||||||
|
// Not servers: an agent run with an MCP flag, and a grep.
|
||||||
|
{PID: 60, PPID: 1, Argv: []string{"claude", "--mcp-config", "/tmp/mcp.json"}},
|
||||||
|
{PID: 61, PPID: 1, Argv: []string{"grep", "-r", "mcp-server", "/srv"}},
|
||||||
|
{PID: 70, PPID: 1}, // a process whose command line was not read
|
||||||
|
}
|
||||||
|
got := correlateMCP(declared, procs, true)
|
||||||
|
state := map[string]mcpServerObs{}
|
||||||
|
var shadows []mcpServerObs
|
||||||
|
for _, o := range got {
|
||||||
|
if o.RunningState == mcpStateShadow {
|
||||||
|
shadows = append(shadows, o)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state[o.Name] = o
|
||||||
|
}
|
||||||
|
if s := state["files"]; s.RunningState != mcpStateRunning || !reflect.DeepEqual(s.PIDs, []int{20, 21}) {
|
||||||
|
t.Errorf("files: %s %v", s.RunningState, s.PIDs)
|
||||||
|
}
|
||||||
|
if s := state["idle"]; s.RunningState != mcpStateIdle {
|
||||||
|
t.Errorf("idle: %s", s.RunningState)
|
||||||
|
}
|
||||||
|
if s := state["bin"]; s.RunningState != mcpStateRunning || !reflect.DeepEqual(s.PIDs, []int{30}) {
|
||||||
|
t.Errorf("bin: %s %v", s.RunningState, s.PIDs)
|
||||||
|
}
|
||||||
|
if s := state["saas"]; s.RunningState != mcpStateNotLocal {
|
||||||
|
t.Errorf("saas: %s", s.RunningState)
|
||||||
|
}
|
||||||
|
if s := state["local-url"]; s.RunningState != mcpStateUnknown {
|
||||||
|
t.Errorf("local-url: %s", s.RunningState)
|
||||||
|
}
|
||||||
|
if len(shadows) != 2 || !reflect.DeepEqual(shadows[0].PIDs, []int{40}) || !reflect.DeepEqual(shadows[1].PIDs, []int{50}) {
|
||||||
|
t.Fatalf("shadow servers: %+v", shadows)
|
||||||
|
}
|
||||||
|
if !shadows[1].SecretInPlaintext || strings.Contains(shadows[1].Command, fakeGitHubToken) {
|
||||||
|
t.Fatalf("a running server's secret argument travelled: %+v", shadows[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// No process list: nothing is guessed, and no shadow is invented.
|
||||||
|
for _, o := range correlateMCP(declared, nil, false) {
|
||||||
|
if o.RunningState == mcpStateShadow || (o.Transport == "stdio" && o.RunningState != mcpStateUnknown) {
|
||||||
|
t.Errorf("with no process list %s read %s", o.Name, o.RunningState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole reader over invented homes: the per-OS path table, the projects
|
||||||
|
// Claude Code lists, the system file, and an unreadable file that must be
|
||||||
|
// counted rather than read as empty.
|
||||||
|
func TestReadMCPConfigsOverFixtureHomes(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
home := filepath.Join(root, "home", "demo")
|
||||||
|
proj := filepath.Join(root, "work", "proj")
|
||||||
|
write := func(p, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h := mcpHome{Dir: home, ConfigDir: filepath.Join(home, ".config")}
|
||||||
|
projJSON, _ := json.Marshal(proj)
|
||||||
|
write(filepath.Join(home, ".claude.json"), `{"mcpServers": {"u": {"command": "demo-u"}},
|
||||||
|
"projects": {`+string(projJSON)+`: {}}}`)
|
||||||
|
write(filepath.Join(home, ".cursor", "mcp.json"), `{"mcpServers": {"c": {"url": "https://c.example.com/mcp"}}}`)
|
||||||
|
write(filepath.Join(home, ".codex", "config.toml"), "[mcp_servers.x]\ncommand = \"demo-x\"\n")
|
||||||
|
write(filepath.Join(proj, ".mcp.json"), `{"mcpServers": {"p": {"command": "demo-p"}}}`)
|
||||||
|
write(filepath.Join(proj, ".vscode", "mcp.json"), `{"servers": {"v": {"command": "demo-v"},},}`)
|
||||||
|
write(filepath.Join(home, ".config", "zed", "settings.json"), `{"context_servers": {"z": {"command": "demo-z"}`) // broken
|
||||||
|
// A directory where a file should be: present, and unreadable as a file.
|
||||||
|
if err := os.MkdirAll(filepath.Join(home, ".codeium", "windsurf", "mcp_config.json"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sys := filepath.Join(root, "etc", "managed-mcp.json")
|
||||||
|
write(sys, `{"mcpServers": {"s": {"command": "demo-s"}}}`)
|
||||||
|
|
||||||
|
oldH, oldS, oldOS := mcpHomesFn, mcpSystemFn, mcpGOOS
|
||||||
|
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, mcpGOOS = oldH, oldS, oldOS })
|
||||||
|
mcpGOOS = "linux"
|
||||||
|
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{h}, true }
|
||||||
|
mcpSystemFn = func() []mcpSource { return []mcpSource{{mcpClientCode, mcpScopeSystem, sys, mcpFormatServers}} }
|
||||||
|
|
||||||
|
got, scan := readMCPConfigs()
|
||||||
|
where := map[string]string{}
|
||||||
|
for _, o := range got {
|
||||||
|
where[o.Name] = o.Client + "/" + o.Scope
|
||||||
|
}
|
||||||
|
want := map[string]string{"s": "claude-code/system", "u": "claude-code/user", "c": "cursor/user",
|
||||||
|
"x": "codex/user", "p": "claude-code/project", "v": "vscode/project"}
|
||||||
|
if !reflect.DeepEqual(where, want) {
|
||||||
|
t.Fatalf("servers: %v", where)
|
||||||
|
}
|
||||||
|
if scan.Accounts != 1 || scan.Projects != 1 || scan.Unparsed != 1 || scan.Unreadable != 1 || scan.FilesRead != 7 {
|
||||||
|
t.Fatalf("scan: %+v", scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpHomesFn = func() ([]mcpHome, bool) { return nil, false }
|
||||||
|
if got, scan := readMCPConfigs(); got != nil || scan.Supported {
|
||||||
|
t.Fatal("an unsupported platform must say so, not report an empty machine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The path table names each client's file on each platform.
|
||||||
|
func TestMCPConfigSourcesPerPlatform(t *testing.T) {
|
||||||
|
find := func(srcs []mcpSource, client, suffix string) bool {
|
||||||
|
for _, s := range srcs {
|
||||||
|
if s.Client == client && strings.HasSuffix(filepath.ToSlash(s.Path), suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mac := mcpConfigSources("darwin", mcpHome{Dir: "/Users/demo", ConfigDir: "/Users/demo/Library/Application Support"})
|
||||||
|
win := mcpConfigSources("windows", mcpHome{Dir: `C:\Users\demo`, ConfigDir: `C:\Users\demo\AppData\Roaming`})
|
||||||
|
lin := mcpConfigSources("linux", mcpHome{Dir: "/home/demo", ConfigDir: "/home/demo/.config"})
|
||||||
|
for _, c := range []struct {
|
||||||
|
srcs []mcpSource
|
||||||
|
client, suffix string
|
||||||
|
}{
|
||||||
|
{mac, mcpClientDesktop, "Library/Application Support/Claude/claude_desktop_config.json"},
|
||||||
|
{win, mcpClientDesktop, "Roaming/Claude/claude_desktop_config.json"},
|
||||||
|
{lin, mcpClientCode, "/home/demo/.claude.json"},
|
||||||
|
{lin, mcpClientCursor, ".cursor/mcp.json"},
|
||||||
|
{mac, mcpClientVSCode, "Application Support/Code/User/mcp.json"},
|
||||||
|
{lin, mcpClientWindsurf, ".config/devin/mcp_config.json"},
|
||||||
|
{lin, mcpClientWindsurf, ".codeium/windsurf/mcp_config.json"},
|
||||||
|
{lin, mcpClientCodex, ".codex/config.toml"},
|
||||||
|
{mac, mcpClientZed, "/Users/demo/.config/zed/settings.json"},
|
||||||
|
{win, mcpClientZed, "Roaming/Zed/settings.json"},
|
||||||
|
} {
|
||||||
|
if !find(c.srcs, c.client, filepath.ToSlash(c.suffix)) {
|
||||||
|
t.Errorf("%s: no source ending %s", c.client, c.suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := mcpSystemSources("windows", `C:\Program Files`); len(s) != 1 || !strings.HasSuffix(filepath.ToSlash(s[0].Path), "ClaudeCode/managed-mcp.json") {
|
||||||
|
t.Errorf("windows system sources: %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The heartbeat's report: a process walk that failed says so, and every
|
||||||
|
// local server then reads unknown rather than "not running".
|
||||||
|
func TestMCPServersReportWhenTheProcessWalkFails(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(home, ".claude.json"),
|
||||||
|
[]byte(`{"mcpServers": {"u": {"command": "demo-u"}}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldH, oldS, oldP := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn
|
||||||
|
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, scanMCPProcessesFn = oldH, oldS, oldP })
|
||||||
|
resetMCPConfigCache()
|
||||||
|
t.Cleanup(resetMCPConfigCache)
|
||||||
|
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: home, ConfigDir: home}}, true }
|
||||||
|
mcpSystemFn = func() []mcpSource { return nil }
|
||||||
|
|
||||||
|
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, os.ErrPermission }
|
||||||
|
got, scan := mcpServersReport()
|
||||||
|
if len(got) != 1 || got[0].RunningState != mcpStateUnknown || scan.ProcessScan == "ok" {
|
||||||
|
t.Fatalf("failed walk: %+v %+v", got, scan)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanMCPProcessesFn = func(want map[string]bool) ([]mcpProc, error) {
|
||||||
|
if !want["demo-u"] {
|
||||||
|
t.Error("the declared program was not in the set whose command lines are read")
|
||||||
|
}
|
||||||
|
return []mcpProc{{PID: 9, PPID: 1, Argv: []string{"/usr/bin/demo-u"}}}, nil
|
||||||
|
}
|
||||||
|
got, scan = mcpServersReport()
|
||||||
|
if len(got) != 1 || got[0].RunningState != mcpStateRunning || scan.ProcessScan != "ok" {
|
||||||
|
t.Fatalf("walk: %+v %+v", got, scan)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The accounts whose agent clients mcpconfig.go reads on Windows: every
|
||||||
|
// profile directory beside the Public one (normally C:\Users\*), except the
|
||||||
|
// templates and shared profiles Windows keeps there. %USERPROFILE% is not
|
||||||
|
// used to find them: under LocalSystem it is the system profile, not a
|
||||||
|
// person's. The per-user configuration directory is AppData\Roaming, which
|
||||||
|
// is what %APPDATA% names for that account.
|
||||||
|
func mcpHomes() ([]mcpHome, bool) {
|
||||||
|
root := ""
|
||||||
|
if pub := os.Getenv("PUBLIC"); pub != "" {
|
||||||
|
root = filepath.Dir(pub)
|
||||||
|
} else if drive := os.Getenv("SystemDrive"); drive != "" {
|
||||||
|
root = drive + `\Users`
|
||||||
|
}
|
||||||
|
var homes []string
|
||||||
|
if h, err := os.UserHomeDir(); err == nil && h != "" {
|
||||||
|
homes = append(homes, h)
|
||||||
|
}
|
||||||
|
if root != "" {
|
||||||
|
if entries, err := os.ReadDir(root); err == nil {
|
||||||
|
for _, e := range entries {
|
||||||
|
switch strings.ToLower(e.Name()) {
|
||||||
|
case "public", "default", "default user", "all users", "defaultapppool":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.IsDir() {
|
||||||
|
homes = append(homes, filepath.Join(root, e.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []mcpHome
|
||||||
|
for _, h := range dedupeStrings(homes) {
|
||||||
|
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, "AppData", "Roaming")})
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mcpSystemFiles() []mcpSource { return mcpSystemSources("windows", os.Getenv("ProgramFiles")) }
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// THE WINDOWS OPEN, DECIDED WHERE A LINUX TEST CAN SEE IT.
|
||||||
|
//
|
||||||
|
// mcpconfig_open_windows.go opens a config path with CreateFile and asks the
|
||||||
|
// HANDLE what it holds; every decision about the answer is here, with no
|
||||||
|
// build tag, so the Linux test run checks the Windows rules with fake handles
|
||||||
|
// (mcpconfig_winopen_test.go). Nothing here calls Windows.
|
||||||
|
//
|
||||||
|
// The open does not follow a reparse point at the last component
|
||||||
|
// (FILE_FLAG_OPEN_REPARSE_POINT): a symbolic link, junction or other
|
||||||
|
// reparse point at a config path is opened as itself, seen as a reparse
|
||||||
|
// point, and refused. What is checked is the handle that will be read, so
|
||||||
|
// there is no gap between a look and an open for a swap to fall into. A
|
||||||
|
// directory, a device, a pipe or anything else that is not a file on disk is
|
||||||
|
// refused the same way. A reparse point in a directory ABOVE the file is
|
||||||
|
// followed, as for any program opening the path; the file it leads to must
|
||||||
|
// then be owned by the account whose profile it is read for, or by SYSTEM or
|
||||||
|
// the Administrators group (mcpOwnerAllowed), so a link into another
|
||||||
|
// account's profile reads nothing of it.
|
||||||
|
|
||||||
|
// The Windows values the decision reads, from the SDK headers (winbase.h,
|
||||||
|
// winnt.h) as golang.org/x/sys/windows carries them: FILE_TYPE_DISK,
|
||||||
|
// FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_DEVICE, FILE_ATTRIBUTE_REPARSE_POINT.
|
||||||
|
const (
|
||||||
|
mcpWinFileTypeDisk = 0x0001
|
||||||
|
mcpWinAttrDirectory = 0x0010
|
||||||
|
mcpWinAttrDevice = 0x0040
|
||||||
|
mcpWinAttrReparse = 0x0400
|
||||||
|
)
|
||||||
|
|
||||||
|
// mcpWinAdminOwners: the owners a file in any account's profile may have
|
||||||
|
// besides that account. SYSTEM (S-1-5-18), and the Administrators group
|
||||||
|
// (S-1-5-32-544), which is the default owner of what an elevated
|
||||||
|
// administrator creates.
|
||||||
|
var mcpWinAdminOwners = map[string]bool{"S-1-5-18": true, "S-1-5-32-544": true}
|
||||||
|
|
||||||
|
// mcpWinHandleInfo is what the open read from the handle it holds.
|
||||||
|
type mcpWinHandleInfo struct {
|
||||||
|
FileType uint32 // GetFileType
|
||||||
|
Attributes uint32 // GetFileInformationByHandle's FileAttributes
|
||||||
|
Owner string // the owner SID from GetSecurityInfo; "" when unread
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpWinRegular: the handle is a file on disk that is not a directory, a
|
||||||
|
// device or a reparse point.
|
||||||
|
func mcpWinRegular(i mcpWinHandleInfo) bool {
|
||||||
|
return i.FileType == mcpWinFileTypeDisk &&
|
||||||
|
i.Attributes&(mcpWinAttrDirectory|mcpWinAttrDevice|mcpWinAttrReparse) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpWinOps are the Windows calls the open makes, as values so a test can
|
||||||
|
// stand fakes in for them. The field names are not method names anywhere in
|
||||||
|
// the package: the claims test resolves a call through a value by name.
|
||||||
|
type mcpWinOps struct {
|
||||||
|
openPath func(path string) (uintptr, error)
|
||||||
|
handleInfo func(h uintptr) (mcpWinHandleInfo, error)
|
||||||
|
closeHandle func(h uintptr)
|
||||||
|
asFile func(h uintptr, path string) *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpWinOpenWith opens path, checks the handle, and hands back the file and
|
||||||
|
// its owner SID. A handle that is refused is closed before return and never
|
||||||
|
// becomes a file.
|
||||||
|
func mcpWinOpenWith(ops mcpWinOps, path string) (*os.File, string, error) {
|
||||||
|
h, err := ops.openPath(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
info, err := ops.handleInfo(h)
|
||||||
|
if err != nil {
|
||||||
|
ops.closeHandle(h)
|
||||||
|
return nil, "", &os.PathError{Op: "query", Path: path, Err: err}
|
||||||
|
}
|
||||||
|
if !mcpWinRegular(info) {
|
||||||
|
ops.closeHandle(h)
|
||||||
|
return nil, "", errMCPNotRegular
|
||||||
|
}
|
||||||
|
owner := info.Owner
|
||||||
|
if owner == "" {
|
||||||
|
owner = mcpOwnerUnknown
|
||||||
|
}
|
||||||
|
return ops.asFile(h, path), owner, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpWinProfileSID is the SID whose profile directory is dir, from the
|
||||||
|
// ProfileList (SID to ProfileImagePath, already expanded). mcpOwnerUnknown
|
||||||
|
// when no SID or more than one names it; only an administrator's or
|
||||||
|
// SYSTEM's files are then read there.
|
||||||
|
func mcpWinProfileSID(dir string, profiles map[string]string) string {
|
||||||
|
norm := func(p string) string {
|
||||||
|
return strings.ToLower(strings.TrimRight(strings.ReplaceAll(p, "/", `\`), `\`))
|
||||||
|
}
|
||||||
|
want := norm(dir)
|
||||||
|
var hits []string
|
||||||
|
for sid, p := range profiles {
|
||||||
|
if strings.HasPrefix(sid, "S-1-") && !strings.Contains(sid, ".") && norm(p) == want {
|
||||||
|
hits = append(hits, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(hits)
|
||||||
|
if len(hits) != 1 {
|
||||||
|
return mcpOwnerUnknown
|
||||||
|
}
|
||||||
|
return hits[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Windows open's decisions, with fake handles: Windows itself never runs
|
||||||
|
// here, and nothing in CI runs on Windows, so this is what checks the rules.
|
||||||
|
// The SIDs are invented.
|
||||||
|
|
||||||
|
const (
|
||||||
|
testWinSIDOwner = "S-1-5-21-1000000001-1000000002-1000000003-1201"
|
||||||
|
testWinSIDOther = "S-1-5-21-1000000001-1000000002-1000000003-1202"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeWinHandles struct {
|
||||||
|
info mcpWinHandleInfo
|
||||||
|
infoErr error
|
||||||
|
openErr error
|
||||||
|
openCount int
|
||||||
|
closed []uintptr
|
||||||
|
files []uintptr
|
||||||
|
backing string // a real file the "handle" reads from
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWinHandles) ops(t *testing.T) mcpWinOps {
|
||||||
|
return mcpWinOps{
|
||||||
|
openPath: func(string) (uintptr, error) {
|
||||||
|
if f.openErr != nil {
|
||||||
|
return 0, f.openErr
|
||||||
|
}
|
||||||
|
f.openCount++
|
||||||
|
return uintptr(100 + f.openCount), nil
|
||||||
|
},
|
||||||
|
handleInfo: func(uintptr) (mcpWinHandleInfo, error) { return f.info, f.infoErr },
|
||||||
|
closeHandle: func(h uintptr) { f.closed = append(f.closed, h) },
|
||||||
|
asFile: func(h uintptr, path string) *os.File {
|
||||||
|
f.files = append(f.files, h)
|
||||||
|
r, err := os.Open(f.backing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPWinOpenRefusesWhatIsNotARegularFileOnDisk(t *testing.T) {
|
||||||
|
backing := filepath.Join(t.TempDir(), "mcp.json")
|
||||||
|
if err := os.WriteFile(backing, []byte(`{}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
refused := map[string]mcpWinHandleInfo{
|
||||||
|
"symbolic link or junction (reparse point)": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrReparse | 0x20, Owner: testWinSIDOwner},
|
||||||
|
"directory": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrDirectory, Owner: testWinSIDOwner},
|
||||||
|
"device attribute": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrDevice, Owner: testWinSIDOwner},
|
||||||
|
"character device": {FileType: 0x0002, Owner: testWinSIDOwner},
|
||||||
|
"pipe": {FileType: 0x0003, Owner: testWinSIDOwner},
|
||||||
|
"unknown type": {FileType: 0x0000, Owner: testWinSIDOwner},
|
||||||
|
}
|
||||||
|
for name, info := range refused {
|
||||||
|
fk := &fakeWinHandles{info: info, backing: backing}
|
||||||
|
f, _, err := mcpWinOpenWith(fk.ops(t), `C:\Users\demo\.cursor\mcp.json`)
|
||||||
|
if f != nil || !errors.Is(err, errMCPNotRegular) {
|
||||||
|
t.Errorf("%s: opened (err %v)", name, err)
|
||||||
|
}
|
||||||
|
if len(fk.closed) != 1 || len(fk.files) != 0 {
|
||||||
|
t.Errorf("%s: handle not closed, or made into a file: closed %v files %v", name, fk.closed, fk.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fk := &fakeWinHandles{infoErr: errors.New("query failed"), backing: backing}
|
||||||
|
if f, _, err := mcpWinOpenWith(fk.ops(t), `C:\x`); f != nil || err == nil || len(fk.closed) != 1 {
|
||||||
|
t.Errorf("a handle that cannot be queried must be closed and refused: %v %v", err, fk.closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fk = &fakeWinHandles{openErr: &os.PathError{Op: "open", Path: `C:\x`, Err: os.ErrNotExist}, backing: backing}
|
||||||
|
if _, _, err := mcpWinOpenWith(fk.ops(t), `C:\x`); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Errorf("a missing file must stay a missing file (not counted unreadable): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain file (FILE_ATTRIBUTE_ARCHIVE) is opened, and its owner comes back.
|
||||||
|
fk = &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x20, Owner: testWinSIDOwner}, backing: backing}
|
||||||
|
f, owner, err := mcpWinOpenWith(fk.ops(t), `C:\x`)
|
||||||
|
if err != nil || f == nil || owner != testWinSIDOwner || len(fk.closed) != 0 {
|
||||||
|
t.Fatalf("a regular file was refused: %v %q %v", err, owner, fk.closed)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
// An owner that could not be read is never an owner that matches.
|
||||||
|
fk = &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x80}, backing: backing}
|
||||||
|
f, owner, err = mcpWinOpenWith(fk.ops(t), `C:\x`)
|
||||||
|
if err != nil || owner != mcpOwnerUnknown {
|
||||||
|
t.Fatalf("unread owner: %v %q", err, owner)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
if mcpOwnerAllowed(owner, mcpOwnerUnknown, mcpWinAdminOwners) {
|
||||||
|
t.Fatal("an unread owner matched an unknown profile owner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPWinOwnerRules(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
file, want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{testWinSIDOwner, testWinSIDOwner, true},
|
||||||
|
{"S-1-5-18", testWinSIDOwner, true}, // SYSTEM
|
||||||
|
{"S-1-5-32-544", testWinSIDOwner, true}, // Administrators
|
||||||
|
{testWinSIDOther, testWinSIDOwner, false},
|
||||||
|
{"S-1-1-0", testWinSIDOwner, false}, // Everyone
|
||||||
|
{"", testWinSIDOwner, false},
|
||||||
|
{mcpOwnerUnknown, testWinSIDOwner, false},
|
||||||
|
{testWinSIDOther, mcpOwnerUnknown, false},
|
||||||
|
{"S-1-5-18", mcpOwnerUnknown, true},
|
||||||
|
{testWinSIDOther, mcpNoOwnerCheck, true},
|
||||||
|
} {
|
||||||
|
if got := mcpOwnerAllowed(c.file, c.want, mcpWinAdminOwners); got != c.ok {
|
||||||
|
t.Errorf("file owner %q read for %q: allowed %v, want %v", c.file, c.want, got, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPWinProfileSID(t *testing.T) {
|
||||||
|
profiles := map[string]string{
|
||||||
|
"S-1-5-18": `C:\Windows\system32\config\systemprofile`,
|
||||||
|
testWinSIDOwner: `C:\Users\demo`,
|
||||||
|
testWinSIDOwner + ".bak": `C:\Users\demo`,
|
||||||
|
testWinSIDOther: `C:\Users\other`,
|
||||||
|
"S-1-5-21-1000000001-1000000002-9-1": `C:\Users\twice`,
|
||||||
|
"S-1-5-21-1000000001-1000000002-9-2": `C:\Users\twice`,
|
||||||
|
}
|
||||||
|
for dir, want := range map[string]string{
|
||||||
|
`C:\Users\demo`: testWinSIDOwner,
|
||||||
|
`c:\users\DEMO\`: testWinSIDOwner,
|
||||||
|
`C:/Users/demo`: testWinSIDOwner,
|
||||||
|
`C:\Users\other`: testWinSIDOther,
|
||||||
|
`C:\Users\twice`: mcpOwnerUnknown,
|
||||||
|
`C:\Users\nobody`: mcpOwnerUnknown,
|
||||||
|
} {
|
||||||
|
if got := mcpWinProfileSID(dir, profiles); got != want {
|
||||||
|
t.Errorf("%s: %q, want %q", dir, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End to end through mcpReadFileAs with the Windows open's rules: a file in a
|
||||||
|
// profile that another account owns is not read and is counted, one SYSTEM
|
||||||
|
// owns is read.
|
||||||
|
func TestMCPReadFileAsWindowsOwnerRules(t *testing.T) {
|
||||||
|
backing := filepath.Join(t.TempDir(), "mcp.json")
|
||||||
|
if err := os.WriteFile(backing, []byte(`{"mcpServers":{}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldOpen, oldAdmins := mcpOpenRegularFn, mcpAdminOwners
|
||||||
|
t.Cleanup(func() { mcpOpenRegularFn, mcpAdminOwners = oldOpen, oldAdmins })
|
||||||
|
mcpAdminOwners = mcpWinAdminOwners
|
||||||
|
for _, c := range []struct {
|
||||||
|
owner string
|
||||||
|
read bool
|
||||||
|
foreign int
|
||||||
|
}{
|
||||||
|
{testWinSIDOwner, true, 0},
|
||||||
|
{"S-1-5-18", true, 0},
|
||||||
|
{testWinSIDOther, false, 1},
|
||||||
|
{"", false, 1},
|
||||||
|
} {
|
||||||
|
fk := &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x20, Owner: c.owner}, backing: backing}
|
||||||
|
mcpOpenRegularFn = func(p string) (*os.File, string, error) { return mcpWinOpenWith(fk.ops(t), p) }
|
||||||
|
var scan mcpConfigScan
|
||||||
|
_, ok := mcpReadFileAs(`C:\Users\demo\.cursor\mcp.json`, testWinSIDOwner, &scan)
|
||||||
|
if ok != c.read || scan.ForeignOwner != c.foreign {
|
||||||
|
t.Errorf("owner %q: read %v foreign %d, want %v %d", c.owner, ok, scan.ForeignOwner, c.read, c.foreign)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,6 +148,7 @@ func mgmtAudit(self, op, family, verdict, why string, exit int, args []string, a
|
|||||||
line["approval"] = approval
|
line["approval"] = approval
|
||||||
}
|
}
|
||||||
rec, _ := json.Marshal(line)
|
rec, _ := json.Marshal(line)
|
||||||
|
auditToSystemLog(mgmtAuditFile, self, rec)
|
||||||
mgmtAuditMu.Lock()
|
mgmtAuditMu.Lock()
|
||||||
defer mgmtAuditMu.Unlock()
|
defer mgmtAuditMu.Unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -818,6 +818,12 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
|
|||||||
priv := setupBox(t)
|
priv := setupBox(t)
|
||||||
writePairingState(t, tMember, pairingPending)
|
writePairingState(t, tMember, pairingPending)
|
||||||
t.Setenv("OLLAMA_ADDR", "127.0.0.1:1")
|
t.Setenv("OLLAMA_ADDR", "127.0.0.1:1")
|
||||||
|
// The tool-server reader looks in an empty home, not this machine's.
|
||||||
|
oldHomes, oldSys := mcpHomesFn, mcpSystemFn
|
||||||
|
t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldHomes, oldSys })
|
||||||
|
empty := t.TempDir()
|
||||||
|
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: empty, ConfigDir: empty}}, true }
|
||||||
|
mcpSystemFn = func() []mcpSource { return nil }
|
||||||
|
|
||||||
beat := func(self string) map[string]any {
|
beat := func(self string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -846,13 +852,16 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
|
|||||||
// The store root names the operator's OS account; it is path material
|
// The store root names the operator's OS account; it is path material
|
||||||
// like the rest and travels only with `observe`. The holes map travels
|
// like the rest and travels only with `observe`. The holes map travels
|
||||||
// with it: absent must mean "not entitled", and only that.
|
// with it: absent must mean "not entitled", and only that.
|
||||||
"machine_record_root", "machine_record_dropped"} {
|
"machine_record_root", "machine_record_dropped",
|
||||||
|
// The tool servers agent clients declare name files, servers and
|
||||||
|
// command lines: observe material like the agent list.
|
||||||
|
"tool_servers", "tool_server_scan"} {
|
||||||
if _, present := m[k]; present {
|
if _, present := m[k]; present {
|
||||||
t.Fatalf("%q travelled to a pairing that cannot observe — that is the machine's "+
|
t.Fatalf("%q travelled to a pairing that cannot observe — that is the machine's "+
|
||||||
"process list going to a host approved for one model", k)
|
"process list going to a host approved for one model", k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hasCap(m, "agent-observe") || hasCap(m, "scheduler-observe") {
|
if hasCap(m, "agent-observe") || hasCap(m, "scheduler-observe") || hasCap(m, "tool-server-observe") {
|
||||||
t.Fatalf("the observe capabilities were advertised to a pairing outside the family: %v",
|
t.Fatalf("the observe capabilities were advertised to a pairing outside the family: %v",
|
||||||
m["capabilities"])
|
m["capabilities"])
|
||||||
}
|
}
|
||||||
@@ -869,6 +878,9 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
|
|||||||
if _, present := m["schedulers"]; !present {
|
if _, present := m["schedulers"]; !present {
|
||||||
t.Fatal("a pairing granted observe must still get the scheduler inventory")
|
t.Fatal("a pairing granted observe must still get the scheduler inventory")
|
||||||
}
|
}
|
||||||
|
if _, present := m["tool_servers"]; !present || !hasCap(m, "tool-server-observe") {
|
||||||
|
t.Fatal("a pairing granted observe must get the declared tool servers and their capability")
|
||||||
|
}
|
||||||
|
|
||||||
// The anchor holds every family, and an unanchored box gates nothing — the
|
// The anchor holds every family, and an unanchored box gates nothing — the
|
||||||
// two cases that must not change, which between them cover nearly every install.
|
// two cases that must not change, which between them cover nearly every install.
|
||||||
|
|||||||
@@ -210,6 +210,31 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// A networked machine is a server. Left unanchored it refuses every op to
|
||||||
|
// every host (capsAllowIn: unanchoredNetworkedRefusal) until someone runs
|
||||||
|
// claim-anchor, a step people missed. So the first pairing on a networked
|
||||||
|
// box with no anchor anchors it, as `claim-anchor` with no token would, and
|
||||||
|
// from then on every terminal and write waits for the person's approval and
|
||||||
|
// every later pairing for this host's. Only with machine authority, which
|
||||||
|
// claim-anchor needs anyway.
|
||||||
|
// The pairing itself succeeded, so a failed anchor is reported, not
|
||||||
|
// returned: `pair` failing here would read as "could not pair" to a caller
|
||||||
|
// (npx cseries exits on it) when the pairing exists. claim-anchor, which
|
||||||
|
// such a caller runs next, reports the anchor's own failure.
|
||||||
|
anchored, err := anchorFirstNetworkedPairing(cfg, all)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
|
||||||
|
}
|
||||||
|
if anchored {
|
||||||
|
if fresh, lerr := loadPairings(); lerr == nil {
|
||||||
|
if c := pairingFor(fresh, cfg.HostName); c != nil {
|
||||||
|
cfg, all = c, fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("\nThis machine is networked and anchored to %s: every terminal and every write\n", cfg.HostName)
|
||||||
|
fmt.Println("waits for your approval, and any later host waits for this one to approve it.")
|
||||||
|
fmt.Printf("Enrol your second factor now, so you can approve here:\n %s\n\n", stepupEnrollHint())
|
||||||
|
}
|
||||||
if cfg.tcpTransport() && cfg.active() {
|
if cfg.tcpTransport() && cfg.active() {
|
||||||
fmt.Printf("Paired with %s over TCP.\n\n", cfg.HostName)
|
fmt.Printf("Paired with %s over TCP.\n\n", cfg.HostName)
|
||||||
fmt.Printf(" lanes: %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n", cfg.LaneAddr, tcpServerName)
|
fmt.Printf(" lanes: %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n", cfg.LaneAddr, tcpServerName)
|
||||||
@@ -242,6 +267,19 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anchorFirstNetworkedPairing anchors a networked machine that has no anchor
|
||||||
|
// to the pairing just made, when it is the machine's only one and the caller
|
||||||
|
// holds machine authority (see cmdPair). Reports whether it anchored.
|
||||||
|
func anchorFirstNetworkedPairing(cfg *Config, all []*Config) (bool, error) {
|
||||||
|
if boxMode() != modeNetworked || currentAnchor() != nil || len(all) != 1 || !machineAuthorityHeld() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := cmdClaimAnchor("", false, "", cfg.HostName); err != nil {
|
||||||
|
return false, fmt.Errorf("paired with %s, but anchoring this networked machine to it failed: %w\n anchor it with: %s", cfg.HostName, err, anchorCommandHint())
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// redeemURLBase is the platform's enrollment ingress. A variable so a test can
|
// redeemURLBase is the platform's enrollment ingress. A variable so a test can
|
||||||
// serve a redeem response on loopback; nothing outside tests assigns it.
|
// serve a redeem response on loopback; nothing outside tests assigns it.
|
||||||
var redeemURLBase = "https://api.privatae.ai"
|
var redeemURLBase = "https://api.privatae.ai"
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPairAnchorsANetworkedMachineToItsFirstHost: on a networked machine with
|
||||||
|
// no anchor, the first pairing anchors it (as claim-anchor with no token
|
||||||
|
// would), so a server is not left refusing everything until claim-anchor is
|
||||||
|
// run by hand. A local machine,
|
||||||
|
// an already anchored one, a second pairing, or a caller without machine
|
||||||
|
// authority are left as they were.
|
||||||
|
func TestPairAnchorsANetworkedMachineToItsFirstHost(t *testing.T) {
|
||||||
|
held := true
|
||||||
|
old := machineAuthorityHeld
|
||||||
|
machineAuthorityHeld = func() bool { return held }
|
||||||
|
t.Cleanup(func() { machineAuthorityHeld = old })
|
||||||
|
|
||||||
|
box := func(t *testing.T, networked bool) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
||||||
|
if networked {
|
||||||
|
if err := setBoxMode(modeNetworked); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
only := func(t *testing.T, host string) (*Config, []*Config) {
|
||||||
|
t.Helper()
|
||||||
|
all, err := loadPairings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return pairingFor(all, host), all
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("networked, no anchor, first pairing: anchored and active", func(t *testing.T) {
|
||||||
|
held = true
|
||||||
|
box(t, true)
|
||||||
|
writePairingState(t, tMember, pairingPending)
|
||||||
|
cfg, all := only(t, tMember)
|
||||||
|
anchored, err := anchorFirstNetworkedPairing(cfg, all)
|
||||||
|
if err != nil || !anchored {
|
||||||
|
t.Fatalf("the first pairing on a networked box must anchor it: anchored=%v err=%v", anchored, err)
|
||||||
|
}
|
||||||
|
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
|
||||||
|
t.Fatalf("anchor = %+v, want %s", a, tMember)
|
||||||
|
}
|
||||||
|
if got := loadState(t, tMember); got != pairingActive {
|
||||||
|
t.Fatalf("the anchor never waits on itself: state %q", got)
|
||||||
|
}
|
||||||
|
// The installer's claim-anchor after it finds the work done.
|
||||||
|
if err := cmdClaimAnchor("", false, "", ""); err != nil {
|
||||||
|
t.Fatalf("claim-anchor after pair must succeed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("local machine: not anchored", func(t *testing.T) {
|
||||||
|
held = true
|
||||||
|
box(t, false)
|
||||||
|
writePairing(t, tMember)
|
||||||
|
cfg, all := only(t, tMember)
|
||||||
|
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||||
|
t.Fatalf("a local machine must not be anchored: %v %v", anchored, err)
|
||||||
|
}
|
||||||
|
if currentAnchor() != nil {
|
||||||
|
t.Fatal("a local machine has no anchor")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("already anchored: a new pairing stays pending", func(t *testing.T) {
|
||||||
|
held = true
|
||||||
|
box(t, true)
|
||||||
|
writePairing(t, tAnchor)
|
||||||
|
if err := writeAnchor("Acme", tAnchor); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writePairingState(t, tMember, pairingPending)
|
||||||
|
cfg, all := only(t, tMember)
|
||||||
|
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||||
|
t.Fatalf("an anchored machine must not be re-anchored: %v %v", anchored, err)
|
||||||
|
}
|
||||||
|
if a := currentAnchor(); a == nil || a.AnchorHost != tAnchor {
|
||||||
|
t.Fatalf("anchor moved: %+v", a)
|
||||||
|
}
|
||||||
|
if got := loadState(t, tMember); got != pairingPending {
|
||||||
|
t.Fatalf("a later host waits for the anchor's approval: state %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a second pairing on an unanchored machine: left for claim-anchor --host", func(t *testing.T) {
|
||||||
|
held = true
|
||||||
|
box(t, true)
|
||||||
|
writePairing(t, tAnchor)
|
||||||
|
writePairingState(t, tMember, pairingPending)
|
||||||
|
cfg, all := only(t, tMember)
|
||||||
|
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||||
|
t.Fatalf("with two pairings the person picks the anchor: %v %v", anchored, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no machine authority: not anchored", func(t *testing.T) {
|
||||||
|
held = true
|
||||||
|
box(t, true) // set-mode networked itself needs machine authority
|
||||||
|
writePairingState(t, tMember, pairingPending)
|
||||||
|
held = false
|
||||||
|
cfg, all := only(t, tMember)
|
||||||
|
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||||
|
t.Fatalf("without machine authority nothing is anchored: %v %v", anchored, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ func pairingAudit(op, subject, decision string) {
|
|||||||
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
|
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
|
||||||
"subject": subject, "decision": decision,
|
"subject": subject, "decision": decision,
|
||||||
})
|
})
|
||||||
|
auditToSystemLog(auditFilePairing, "", rec)
|
||||||
fh, err := os.OpenFile(filepath.Join(d, "pairing_audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
fh, err := os.OpenFile(filepath.Join(d, "pairing_audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -138,3 +138,14 @@ func restartHintFor(goos string) string {
|
|||||||
}
|
}
|
||||||
return "Restart the service so the change takes: systemctl restart privatae-bastion"
|
return "Restart the service so the change takes: systemctl restart privatae-bastion"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stepupEnrollHint is the stepup-enroll line to print after a networked
|
||||||
|
// machine is anchored: approvals on it need a second factor or a device.
|
||||||
|
func stepupEnrollHint() string { return stepupEnrollHintFor(runtime.GOOS) }
|
||||||
|
|
||||||
|
func stepupEnrollHintFor(goos string) string {
|
||||||
|
if goos == "windows" {
|
||||||
|
return "privatae-bastion stepup-enroll (from this Administrator terminal)"
|
||||||
|
}
|
||||||
|
return "sudo -H privatae-bastion stepup-enroll"
|
||||||
|
}
|
||||||
|
|||||||
@@ -479,6 +479,7 @@ func rootExecAudit(dir string, rec map[string]any) {
|
|||||||
rec["ts"] = time.Now().UTC().Format(time.RFC3339)
|
rec["ts"] = time.Now().UTC().Format(time.RFC3339)
|
||||||
rec["op"] = sessionOpRootExec
|
rec["op"] = sessionOpRootExec
|
||||||
raw, _ := json.Marshal(rec)
|
raw, _ := json.Marshal(rec)
|
||||||
|
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), raw)
|
||||||
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ func sessionAudit(dir, op, sessionID, user, event string, exit int, detail strin
|
|||||||
"ts": time.Now().UTC().Format(time.RFC3339), "op": op, "session_id": sessionID,
|
"ts": time.Now().UTC().Format(time.RFC3339), "op": op, "session_id": sessionID,
|
||||||
"user": user, "event": event, "exit": exit, "decision": detail,
|
"user": user, "event": event, "exit": exit, "decision": detail,
|
||||||
})
|
})
|
||||||
|
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
|
||||||
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Account lookups made outside a scan (sidnames.go): the approvals pipe and the
|
||||||
|
// folder agent's drop. Fake lookups stand in for LookupAccountSid, so these run
|
||||||
|
// on every platform. Every SID and name here is invented.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testSIDDana = "S-1-5-21-1000000001-1000000002-1000000003-1201"
|
||||||
|
|
||||||
|
// hangingLookup blocks until release is closed, then answers with acct.
|
||||||
|
func hangingLookup(calls *int32, release <-chan struct{}, acct sidAccount) func(string) (sidAccount, error) {
|
||||||
|
return func(string) (sidAccount, error) {
|
||||||
|
atomic.AddInt32(calls, 1)
|
||||||
|
<-release
|
||||||
|
return acct, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A directory that does not answer: every caller is told "timed out" within
|
||||||
|
// its budget, only one lookup is ever started on the lane, and the answer that
|
||||||
|
// comes late fills the cache for the next caller.
|
||||||
|
func TestSIDDirectoryUnreachableTimesOutWithinBudget(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
var calls int32
|
||||||
|
dir := newSIDDirectory(hangingLookup(&calls, release,
|
||||||
|
sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}))
|
||||||
|
lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond}
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
start := time.Now()
|
||||||
|
if _, out := dir.resolve(testSIDDana, lane); out != sidTimedOut {
|
||||||
|
t.Fatalf("call %d: outcome %v while the directory does not answer", i, out)
|
||||||
|
}
|
||||||
|
if took := time.Since(start); took > lane.budget+500*time.Millisecond {
|
||||||
|
t.Fatalf("call %d waited %v, budget %v", i, took, lane.budget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||||
|
t.Fatalf("%d lookups started while the first was still blocked", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
a, out := dir.resolve(testSIDDana, lane)
|
||||||
|
if out == sidResolved {
|
||||||
|
if sidAccountLabel(a) != `EXAMPLECORP\dana` {
|
||||||
|
t.Fatalf("late answer = %+v", a)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the late answer never reached the cache")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||||
|
t.Fatalf("the late answer was asked again: %d lookups", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answers are kept for sidDirectoryTTL; a failure is asked again every time.
|
||||||
|
func TestSIDDirectoryCachesAnswersNotFailures(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
dir := newSIDDirectory(func(sid string) (sidAccount, error) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
if sid == testSIDDana {
|
||||||
|
return sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}, nil
|
||||||
|
}
|
||||||
|
return sidAccount{}, errors.New("none mapped")
|
||||||
|
})
|
||||||
|
now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
|
||||||
|
dir.now = func() time.Time { return now }
|
||||||
|
lane := sidLane{slots: make(chan struct{}, 1), budget: time.Second}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, out := dir.resolve(testSIDDana, lane); out != sidResolved {
|
||||||
|
t.Fatalf("outcome %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||||
|
t.Fatalf("%d lookups for one account within the TTL", n)
|
||||||
|
}
|
||||||
|
now = now.Add(sidDirectoryTTL)
|
||||||
|
dir.resolve(testSIDDana, lane)
|
||||||
|
if n := atomic.LoadInt32(&calls); n != 2 {
|
||||||
|
t.Fatalf("an expired answer was not asked again: %d lookups", n)
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if _, out := dir.resolve("S-1-5-21-9-9-9-9", lane); out != sidUnmapped {
|
||||||
|
t.Fatalf("unmapped SID outcome %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt32(&calls); n != 4 {
|
||||||
|
t.Fatalf("a failure was cached: %d lookups", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lookup hung on one lane does not hold another lane.
|
||||||
|
func TestSIDDirectoryLanesAreIndependent(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
defer close(release)
|
||||||
|
dir := newSIDDirectory(func(sid string) (sidAccount, error) {
|
||||||
|
if sid == testSIDDana {
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
return sidAccount{Account: "erin", Type: winSidTypeUser}, nil
|
||||||
|
})
|
||||||
|
stuck := sidLane{slots: make(chan struct{}, 1), budget: 50 * time.Millisecond}
|
||||||
|
other := sidLane{slots: make(chan struct{}, 1), budget: time.Second}
|
||||||
|
if _, out := dir.resolve(testSIDDana, stuck); out != sidTimedOut {
|
||||||
|
t.Fatalf("stuck lane outcome %v", out)
|
||||||
|
}
|
||||||
|
if _, out := dir.resolve("S-1-5-21-1000000001-1000000002-1000000003-1202", other); out != sidResolved {
|
||||||
|
t.Fatalf("the other lane was held by the stuck one: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE APPROVALS PIPE FAILS CLOSED. A client whose account the directory could
|
||||||
|
// not name in time is never approved, even with a right code; it is not
|
||||||
|
// counted as a wrong code; list still answers. Once the directory answers,
|
||||||
|
// the same code approves.
|
||||||
|
func TestPipeApproveRefusedWhenTheAccountLookupTimedOut(t *testing.T) {
|
||||||
|
dir, idA, purposeA, _, _ := pipeBox(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
var calls int32
|
||||||
|
accounts := newSIDDirectory(hangingLookup(&calls, release,
|
||||||
|
sidAccount{Account: "pat", Domain: "EXAMPLE", Type: winSidTypeUser}))
|
||||||
|
lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond}
|
||||||
|
|
||||||
|
c := thePerson
|
||||||
|
c.UserName = ""
|
||||||
|
start := time.Now()
|
||||||
|
pipeClientName(&c, accounts, lane)
|
||||||
|
if took := time.Since(start); took > lane.budget+500*time.Millisecond {
|
||||||
|
t.Fatalf("the client's name lookup took %v, budget %v", took, lane.budget)
|
||||||
|
}
|
||||||
|
if !c.NameTimedOut || c.UserName != "" {
|
||||||
|
t.Fatalf("a lookup that did not answer must be marked: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := pipeCodeNow()
|
||||||
|
approve := pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code}
|
||||||
|
if resp := pipeServe(approve, c, now); resp.OK || !strings.Contains(resp.Error, "did not answer") {
|
||||||
|
t.Fatalf("an approve on a timed-out lookup must be refused: %+v", resp)
|
||||||
|
}
|
||||||
|
if r := stateOf(t, dir, idA); r.State != "pending" {
|
||||||
|
t.Fatalf("a timed-out lookup changed the request: %s", r.State)
|
||||||
|
}
|
||||||
|
if f := pipeThrottleLoad(dir); f.Failures != 0 {
|
||||||
|
t.Fatalf("the refusal was counted as a wrong code: %d", f.Failures)
|
||||||
|
}
|
||||||
|
if resp := pipeServe(pipeRequest{Op: "list"}, c, now); !resp.OK {
|
||||||
|
t.Fatalf("list must still answer by SID: %+v", resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
c2 := thePerson
|
||||||
|
c2.UserName = ""
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
c2.NameTimedOut = false
|
||||||
|
pipeClientName(&c2, accounts, lane)
|
||||||
|
if !c2.NameTimedOut {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the directory's late answer never reached the pipe")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if c2.UserName != `EXAMPLE\pat` {
|
||||||
|
t.Fatalf("name = %q", c2.UserName)
|
||||||
|
}
|
||||||
|
if resp := pipeServe(approve, c2, now); !resp.OK {
|
||||||
|
t.Fatalf("with the account named, the code approves: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An account the directory says does not exist is still served by SID, as
|
||||||
|
// before: that is an answer, not a timeout.
|
||||||
|
func TestPipeClientNameUnmappedIsNotATimeout(t *testing.T) {
|
||||||
|
accounts := newSIDDirectory(func(string) (sidAccount, error) { return sidAccount{}, errors.New("none mapped") })
|
||||||
|
c := thePerson
|
||||||
|
c.UserName = ""
|
||||||
|
pipeClientName(&c, accounts, sidLane{slots: make(chan struct{}, 1), budget: time.Second})
|
||||||
|
if c.NameTimedOut || c.UserName != "" {
|
||||||
|
t.Fatalf("unmapped: %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE DROP FAILS CLOSED. The folder owner's lookup is bounded, and a
|
||||||
|
// directory that does not answer starts no agent; no account starts none.
|
||||||
|
func TestFaWinDropOwnerAccountFailsClosed(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
defer close(release)
|
||||||
|
var calls int32
|
||||||
|
slow := newSIDDirectory(hangingLookup(&calls, release, sidAccount{Account: "dana", Type: winSidTypeUser}))
|
||||||
|
lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond}
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := faWinDropOwnerAccount(testSIDDana, slow, lane); err == nil || !strings.Contains(err.Error(), "did not answer") {
|
||||||
|
t.Fatalf("a directory that does not answer must refuse the start: %v", err)
|
||||||
|
}
|
||||||
|
if took := time.Since(start); took > lane.budget+500*time.Millisecond {
|
||||||
|
t.Fatalf("the drop waited %v, budget %v", took, lane.budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
none := newSIDDirectory(func(string) (sidAccount, error) { return sidAccount{}, errors.New("none mapped") })
|
||||||
|
if _, err := faWinDropOwnerAccount(testSIDDana, none, sidLane{slots: make(chan struct{}, 1), budget: time.Second}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "has no account") {
|
||||||
|
t.Fatalf("no account must refuse the start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ok := newSIDDirectory(func(string) (sidAccount, error) {
|
||||||
|
return sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}, nil
|
||||||
|
})
|
||||||
|
a, err := faWinDropOwnerAccount(testSIDDana, ok, sidLane{slots: make(chan struct{}, 1), budget: time.Second})
|
||||||
|
if err != nil || a.Type != winSidTypeUser || sidAccountLabel(a) != `EXAMPLECORP\dana` {
|
||||||
|
t.Fatalf("answered: %+v %v", a, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ACCOUNT NAMES FOR WINDOWS SIDS, ASKED ONCE PER SCAN AND NEVER FOR LONG.
|
||||||
|
//
|
||||||
|
// The Windows process scan names each agent's account (winTokenUser) and
|
||||||
|
// classifies it (ownerFactsReader), and both turn a SID into an account and a
|
||||||
|
// domain with LookupAccountSid. On a domain-joined machine that call can go
|
||||||
|
// to a domain controller, and when the controller cannot be reached it waits
|
||||||
|
// for the network to give up. Asked per process, with no cache, a scan of a
|
||||||
|
// few dozen processes could then hold the heartbeat for minutes.
|
||||||
|
//
|
||||||
|
// So a scan holds one sidNames: each SID is asked at most once, both callers
|
||||||
|
// share the answer, and the scan waits on lookups for at most a fixed budget
|
||||||
|
// in total. Once a lookup has used up the budget, every SID not already
|
||||||
|
// answered is unresolved for the rest of the scan: the account shows as its
|
||||||
|
// SID string and the owner's class as unknown, which is what the classifier
|
||||||
|
// already says when the domain could not be read.
|
||||||
|
//
|
||||||
|
// LookupAccountSid cannot be cancelled. A lookup the scan stopped waiting
|
||||||
|
// for finishes in its own goroutine and is then dropped. At most one lookup
|
||||||
|
// runs at a time across the process (the slots channel), so a controller
|
||||||
|
// that stays unreachable for many scans holds one blocked thread, not one per
|
||||||
|
// scan.
|
||||||
|
//
|
||||||
|
// The type has no build tag so the Linux test run checks it with a fake
|
||||||
|
// lookup; agents_windows.go supplies the real one.
|
||||||
|
|
||||||
|
// sidLookupBudget is the most one scan waits on account lookups in total. It
|
||||||
|
// is a bound on the harm, not a measured lookup time: a scan asks once per
|
||||||
|
// distinct account, and a domain controller that does not answer costs a
|
||||||
|
// scan this much of its 60-second beat and no more.
|
||||||
|
var sidLookupBudget = 2 * time.Second
|
||||||
|
|
||||||
|
// sidLookupSlots: one lookup in flight at a time, process-wide.
|
||||||
|
var sidLookupSlots = make(chan struct{}, 1)
|
||||||
|
|
||||||
|
// sidName is one SID's answer. OK is false when the lookup failed, timed
|
||||||
|
// out, or was never started because the budget was gone.
|
||||||
|
type sidName struct {
|
||||||
|
Account, Domain string
|
||||||
|
OK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type sidNames struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
lookup func(sid string) (account, domain string, err error)
|
||||||
|
slots chan struct{}
|
||||||
|
left time.Duration
|
||||||
|
names map[string]sidName
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSIDNames(lookup func(string) (string, string, error), budget time.Duration, slots chan struct{}) *sidNames {
|
||||||
|
return &sidNames{lookup: lookup, slots: slots, left: budget, names: map[string]sidName{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve answers one SID, from the scan's cache when it has been asked.
|
||||||
|
func (c *sidNames) resolve(sid string) sidName {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if n, ok := c.names[sid]; ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
n := c.ask(sid)
|
||||||
|
c.names[sid] = n
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask runs one lookup within what is left of the budget. Called with mu held.
|
||||||
|
func (c *sidNames) ask(sid string) sidName {
|
||||||
|
if c.left <= 0 {
|
||||||
|
return sidName{}
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
var n sidName
|
||||||
|
finished := boundedSIDCall(c.slots, c.left, func() {
|
||||||
|
a, d, err := c.lookup(sid)
|
||||||
|
n = sidName{Account: a, Domain: d, OK: err == nil}
|
||||||
|
})
|
||||||
|
if !finished {
|
||||||
|
c.left = 0
|
||||||
|
return sidName{}
|
||||||
|
}
|
||||||
|
c.left -= time.Since(start)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// boundedSIDCall runs call on its own goroutine while holding one of slots,
|
||||||
|
// and waits for it at most budget in all, the wait for a free slot included.
|
||||||
|
// It reports whether call finished in time; only then may the caller read
|
||||||
|
// what call wrote. A call it stopped waiting for keeps its slot until it
|
||||||
|
// returns, so a lane whose lookup never returns has one blocked goroutine,
|
||||||
|
// and every later caller on that lane waits its budget and is told no.
|
||||||
|
func boundedSIDCall(slots chan struct{}, budget time.Duration, call func()) bool {
|
||||||
|
if budget <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(budget)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case slots <- struct{}{}:
|
||||||
|
case <-timer.C:
|
||||||
|
return false // an earlier lookup on this lane still holds the slot
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer func() { <-slots }()
|
||||||
|
call()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return true
|
||||||
|
case <-timer.C:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACCOUNT LOOKUPS OUTSIDE A SCAN: the approvals pipe and the folder agent's drop.
|
||||||
|
//
|
||||||
|
// Both resolve one SID when something happens (a tray connects, a store's
|
||||||
|
// agent is started), not once per scan, so they cannot share a scan's budget.
|
||||||
|
// They share one process-wide cache instead (sidDirectory): an answer is kept
|
||||||
|
// for sidDirectoryTTL, so the tray's polling asks the directory once per
|
||||||
|
// account per ten minutes, and a lookup that answers after its caller stopped
|
||||||
|
// waiting still fills the cache for the next caller. Only an answer is
|
||||||
|
// cached; a failure or a timeout is asked again next time.
|
||||||
|
//
|
||||||
|
// Each caller has its own lane (slot and budget), so a lookup hung on one
|
||||||
|
// lane never blocks the other or the scan's: at most one goroutine per lane is
|
||||||
|
// ever held by a directory that does not answer.
|
||||||
|
//
|
||||||
|
// What a timeout means is the caller's decision, and both fail closed: the
|
||||||
|
// pipe refuses an approve (pipeApprove), the drop refuses to start the agent
|
||||||
|
// (faWinDropOwnerAccount).
|
||||||
|
|
||||||
|
// sidAccount is one SID's answer: the account, its domain and its SID type
|
||||||
|
// (SID_NAME_USE).
|
||||||
|
type sidAccount struct {
|
||||||
|
Account, Domain string
|
||||||
|
Type uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidOutcome is how a lookup outside a scan ended.
|
||||||
|
type sidOutcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
sidResolved sidOutcome = iota // the directory answered with an account
|
||||||
|
sidUnmapped // the directory answered: no such account
|
||||||
|
sidTimedOut // the directory did not answer within the lane's budget
|
||||||
|
)
|
||||||
|
|
||||||
|
// sidLane is one caller's single-flight slot and per-lookup budget.
|
||||||
|
type sidLane struct {
|
||||||
|
slots chan struct{}
|
||||||
|
budget time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
sidDirectoryTTL = 10 * time.Minute
|
||||||
|
sidDirectoryMax = 256 // accounts kept; a machine has far fewer people
|
||||||
|
)
|
||||||
|
|
||||||
|
type sidCached struct {
|
||||||
|
acct sidAccount
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type sidDirectory struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
lookup func(sid string) (sidAccount, error)
|
||||||
|
now func() time.Time
|
||||||
|
names map[string]sidCached
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSIDDirectory(lookup func(string) (sidAccount, error)) *sidDirectory {
|
||||||
|
return &sidDirectory{lookup: lookup, now: time.Now, names: map[string]sidCached{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve answers sid from the cache, or asks the directory on lane.
|
||||||
|
func (d *sidDirectory) resolve(sid string, lane sidLane) (sidAccount, sidOutcome) {
|
||||||
|
if a, ok := d.cached(sid); ok {
|
||||||
|
return a, sidResolved
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
acct sidAccount
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
finished := boundedSIDCall(lane.slots, lane.budget, func() {
|
||||||
|
// Asked again with the slot held: the lookup this caller queued
|
||||||
|
// behind may have just answered for the same SID.
|
||||||
|
if a, ok := d.cached(sid); ok {
|
||||||
|
acct = a
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acct, err = d.lookup(sid)
|
||||||
|
if err == nil {
|
||||||
|
d.store(sid, acct) // also when the caller has stopped waiting
|
||||||
|
}
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case !finished:
|
||||||
|
return sidAccount{}, sidTimedOut
|
||||||
|
case err != nil:
|
||||||
|
return sidAccount{}, sidUnmapped
|
||||||
|
}
|
||||||
|
return acct, sidResolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sidDirectory) cached(sid string) (sidAccount, bool) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if c, ok := d.names[sid]; ok && d.now().Sub(c.at) < sidDirectoryTTL {
|
||||||
|
return c.acct, true
|
||||||
|
}
|
||||||
|
return sidAccount{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *sidDirectory) store(sid string, acct sidAccount) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if len(d.names) >= sidDirectoryMax {
|
||||||
|
for k, c := range d.names {
|
||||||
|
if d.now().Sub(c.at) >= sidDirectoryTTL {
|
||||||
|
delete(d.names, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(d.names) >= sidDirectoryMax {
|
||||||
|
d.names = map[string]sidCached{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.names[sid] = sidCached{acct: acct, at: d.now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidAccountLabel is DOMAIN\account, or the bare account with no domain.
|
||||||
|
func sidAccountLabel(a sidAccount) string {
|
||||||
|
if a.Domain != "" {
|
||||||
|
return a.Domain + `\` + a.Account
|
||||||
|
}
|
||||||
|
return a.Account
|
||||||
|
}
|
||||||
|
|
||||||
|
// winAccountLabel is how the scan names a token's account: DOMAIN\account,
|
||||||
|
// or the SID itself when it could not be resolved.
|
||||||
|
func winAccountLabel(sid string, names *sidNames) string {
|
||||||
|
n := names.resolve(sid)
|
||||||
|
switch {
|
||||||
|
case !n.OK:
|
||||||
|
return sid
|
||||||
|
case n.Domain != "":
|
||||||
|
return n.Domain + `\` + n.Account
|
||||||
|
}
|
||||||
|
return n.Account
|
||||||
|
}
|
||||||
|
|
||||||
|
// winAccountDomain is the account's domain for the owner classifier, "" when
|
||||||
|
// the SID could not be resolved (classifyWinOwner then says unknown).
|
||||||
|
func winAccountDomain(sid string, names *sidNames) string {
|
||||||
|
if n := names.resolve(sid); n.OK {
|
||||||
|
return n.Domain
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Invented SIDs: the domain part is made up and names no real domain.
|
||||||
|
const (
|
||||||
|
testSIDAlice = "S-1-5-21-1000000001-1000000002-1000000003-1101"
|
||||||
|
testSIDBob = "S-1-5-21-1000000001-1000000002-1000000003-1102"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One scan asks each SID once, and the account label (winTokenUser) and the
|
||||||
|
// owner's domain (ownerFactsReader) share the answer.
|
||||||
|
func TestSIDNamesAsksEachSIDOncePerScan(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
lookup := func(sid string) (string, string, error) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
switch sid {
|
||||||
|
case testSIDAlice:
|
||||||
|
return "alice", "EXAMPLECORP", nil
|
||||||
|
case testSIDBob:
|
||||||
|
return "bob", "EXAMPLECORP", nil
|
||||||
|
}
|
||||||
|
return "", "", errors.New("none mapped")
|
||||||
|
}
|
||||||
|
names := newSIDNames(lookup, time.Second, make(chan struct{}, 1))
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
if got := winAccountLabel(testSIDAlice, names); got != `EXAMPLECORP\alice` {
|
||||||
|
t.Fatalf("label = %q", got)
|
||||||
|
}
|
||||||
|
if got := winAccountDomain(testSIDAlice, names); got != "EXAMPLECORP" {
|
||||||
|
t.Fatalf("domain = %q", got)
|
||||||
|
}
|
||||||
|
winAccountLabel(testSIDBob, names)
|
||||||
|
}
|
||||||
|
if got := winAccountLabel("S-1-5-21-9-9-9-9", names); got != "S-1-5-21-9-9-9-9" {
|
||||||
|
t.Fatalf("an unmapped SID must be shown as itself, got %q", got)
|
||||||
|
}
|
||||||
|
winAccountDomain("S-1-5-21-9-9-9-9", names)
|
||||||
|
if n := atomic.LoadInt32(&calls); n != 3 {
|
||||||
|
t.Fatalf("%d lookups for 3 distinct SIDs in one scan", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A domain controller that does not answer: the first lookup hangs. The scan
|
||||||
|
// must stop waiting at its budget, every later SID must come back unresolved
|
||||||
|
// at once (no second lookup started), the owner's class must read unknown,
|
||||||
|
// and the next scan must not stack a second blocked lookup behind the first.
|
||||||
|
func TestSIDNamesUnreachableDirectoryDegradesWithinBudget(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
defer close(release)
|
||||||
|
var calls int32
|
||||||
|
lookup := func(sid string) (string, string, error) {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
<-release
|
||||||
|
return "", "", errors.New("released")
|
||||||
|
}
|
||||||
|
slots := make(chan struct{}, 1)
|
||||||
|
const budget = 150 * time.Millisecond
|
||||||
|
|
||||||
|
for scan := 0; scan < 3; scan++ {
|
||||||
|
names := newSIDNames(lookup, budget, slots)
|
||||||
|
start := time.Now()
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
sid := fmt.Sprintf("S-1-5-21-1000000001-1000000002-1000000003-%d", 2000+i)
|
||||||
|
if got := winAccountLabel(sid, names); got != sid {
|
||||||
|
t.Fatalf("scan %d: unresolved SID shown as %q", scan, got)
|
||||||
|
}
|
||||||
|
o := classifyWinOwner(winOwner{SID: sid, Domain: winAccountDomain(sid, names),
|
||||||
|
Computer: "DESKTOP-TEST", Groups: map[string]bool{}, GroupsKnown: true})
|
||||||
|
if o.Class != ownerUnknown {
|
||||||
|
t.Fatalf("scan %d: class %q for an account whose domain could not be read", scan, o.Class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if took := time.Since(start); took > budget+time.Second {
|
||||||
|
t.Fatalf("scan %d spent %v on lookups, budget %v", scan, took, budget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||||
|
t.Fatalf("%d lookups started while the first was still blocked", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A slow but answering lookup is used, and only what it took counts against
|
||||||
|
// the budget.
|
||||||
|
func TestSIDNamesSlowLookupWithinBudgetIsUsed(t *testing.T) {
|
||||||
|
lookup := func(sid string) (string, string, error) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
return "carol", "EXAMPLECORP", nil
|
||||||
|
}
|
||||||
|
names := newSIDNames(lookup, 2*time.Second, make(chan struct{}, 1))
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
sid := fmt.Sprintf("S-1-5-21-1000000001-1000000002-1000000003-%d", 3000+i)
|
||||||
|
if d := winAccountDomain(sid, names); d != "EXAMPLECORP" {
|
||||||
|
t.Fatalf("lookup %d within budget came back %q", i, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,6 +249,13 @@ func installSystemService() error {
|
|||||||
return fmt.Errorf("the %s service is registered but did not start: %w", winServiceName, err)
|
return fmt.Errorf("the %s service is registered but did not start: %w", winServiceName, err)
|
||||||
}
|
}
|
||||||
fmt.Printf("System service installed: %s, running %s as LocalSystem.\n", winServiceName, self)
|
fmt.Printf("System service installed: %s, running %s as LocalSystem.\n", winServiceName, self)
|
||||||
|
// Audit rows go to the Application event log under their own source as
|
||||||
|
// well as to their files (auditlog.go). A failure costs that copy only.
|
||||||
|
if err := registerAuditEventSource(); err != nil {
|
||||||
|
fmt.Printf(" Audit rows will not reach the event log (%v); they are still written to their files.\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Every audit row is also written to the Application event log, source %s.\n", auditEventSource)
|
||||||
|
}
|
||||||
fmt.Println(" It starts at boot, before anyone signs in, and restarts five seconds after any failure.")
|
fmt.Println(" It starts at boot, before anyone signs in, and restarts five seconds after any failure.")
|
||||||
// The person's tray, at every sign-in (tray.go trayFor). A failure here
|
// The person's tray, at every sign-in (tray.go trayFor). A failure here
|
||||||
// leaves the service serving; approvals still work from this terminal.
|
// leaves the service serving; approvals still work from this terminal.
|
||||||
@@ -294,6 +301,7 @@ func uninstallSystemService() error {
|
|||||||
return fmt.Errorf("cannot remove the %s service: %w", winServiceName, err)
|
return fmt.Errorf("cannot remove the %s service: %w", winServiceName, err)
|
||||||
}
|
}
|
||||||
removeTrayLogonEntry()
|
removeTrayLogonEntry()
|
||||||
|
removeAuditEventSource()
|
||||||
fmt.Printf("System service removed: %s is no longer registered.\n", winServiceName)
|
fmt.Printf("System service removed: %s is no longer registered.\n", winServiceName)
|
||||||
fmt.Printf(" The bastion's state under %s is left in place.\n", windowsSystemAppData(os.Getenv("SystemRoot")))
|
fmt.Printf(" The bastion's state under %s is left in place.\n", windowsSystemAppData(os.Getenv("SystemRoot")))
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.53.2
|
0.54.0
|
||||||
|
|||||||
+70
-14
@@ -1,9 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# privatae-bastion installer (macOS / Linux). Installs the binary AND — on Linux as root —
|
# privatae-bastion installer (macOS / Linux). Run as root (a server), it installs the
|
||||||
# the always-on systemd service, and with --networked on macOS a launchd daemon
|
# NETWORKED bastion as a system service (systemd on Linux, launchd on macOS), and the
|
||||||
# running as root, so onboarding a node is two commands:
|
# first host paired anchors it, so onboarding a server is two commands:
|
||||||
# curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
|
# curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
|
||||||
# sudo -H privatae-bastion pair <token>
|
# sudo -H privatae-bastion pair <token>
|
||||||
|
# Run as your own user (a desktop), it installs the LOCAL bastion for that user.
|
||||||
#
|
#
|
||||||
set -e
|
set -e
|
||||||
# dl.privatae.ai, not privatae.ai/bastion: the download host is served from each
|
# dl.privatae.ai, not privatae.ai/bastion: the download host is served from each
|
||||||
@@ -20,17 +21,57 @@ arch=$(uname -m); case "$arch" in x86_64|amd64) arch=amd64;; aarch64|arm64) arch
|
|||||||
if [ "$(id -u)" = 0 ]; then bindir=/usr/local/bin; root=1; else bindir="${HOME}/.local/bin"; root=0; mkdir -p "$bindir"; fi
|
if [ "$(id -u)" = 0 ]; then bindir=/usr/local/bin; root=1; else bindir="${HOME}/.local/bin"; root=0; mkdir -p "$bindir"; fi
|
||||||
bin="$bindir/privatae-bastion"
|
bin="$bindir/privatae-bastion"
|
||||||
|
|
||||||
# --networked: install the NETWORKED bastion — a root-owned
|
# As root the default is the NETWORKED bastion: a root-owned service, anchored to
|
||||||
# service on a machine several people share, anchored to one host that approves
|
# the host it is first paired with, so every terminal and every write waits for the
|
||||||
# every further pairing. Everything else is the LOCAL bastion: one host, and on a
|
# person's approval and every later host for the anchor's. A server left in local
|
||||||
# home machine it runs as the user. Usage: curl ... | sudo sh -s -- --networked
|
# mode would serve its host with no approval at all. --local keeps a root install
|
||||||
|
# local (one host, the pairing's caps are the authorization); run as your own user
|
||||||
|
# it is always local. --networked is accepted and is the root default.
|
||||||
networked=0
|
networked=0
|
||||||
|
local=0
|
||||||
for a in "$@"; do
|
for a in "$@"; do
|
||||||
case "$a" in
|
case "$a" in
|
||||||
--networked) networked=1 ;;
|
--networked) networked=1 ;;
|
||||||
|
--local) local=1 ;;
|
||||||
*) echo "unknown option: $a" >&2; exit 2 ;;
|
*) echo "unknown option: $a" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
if [ "$networked" = 1 ] && [ "$local" = 1 ]; then
|
||||||
|
echo "ERROR: --networked and --local contradict each other; pass one" >&2; exit 2
|
||||||
|
fi
|
||||||
|
asked_networked=$networked
|
||||||
|
|
||||||
|
# paired_local DIR...: 0 when one of these state folders holds a pairing and
|
||||||
|
# its mode is not networked (no mode.json reads as local, mode.go boxMode).
|
||||||
|
# The service's own folder goes first.
|
||||||
|
paired_local() {
|
||||||
|
for d in "$@"; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
if [ -f "$d/mode.json" ] && grep -q '"networked"' "$d/mode.json"; then return 1; fi
|
||||||
|
[ -f "$d/config.json" ] && return 0
|
||||||
|
for f in "$d"/pairings/*.json; do [ -f "$f" ] && return 0; done
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$root" = 1 ] && [ "$local" != 1 ]; then
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux) if command -v systemctl >/dev/null 2>&1; then networked=1; fi ;;
|
||||||
|
Darwin) networked=1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
# A reinstall (an upgrade) of a machine already paired in local mode keeps
|
||||||
|
# local mode unless --networked is passed. Switched by default, its pairing
|
||||||
|
# would sit on an unanchored networked machine, which refuses every op until
|
||||||
|
# claim-anchor is run, and `pair` would accept a second host.
|
||||||
|
kept_local=0
|
||||||
|
if [ "$networked" = 1 ] && [ "$asked_networked" != 1 ]; then
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) state_dirs="/var/root/Library/Application Support/privatae-bastion|${HOME}/Library/Application Support/privatae-bastion" ;;
|
||||||
|
*) state_dirs="/root/.config/privatae-bastion|${XDG_CONFIG_HOME:-$HOME/.config}/privatae-bastion" ;;
|
||||||
|
esac
|
||||||
|
if paired_local "${state_dirs%%|*}" "${state_dirs#*|}"; then networked=0; kept_local=1; fi
|
||||||
|
fi
|
||||||
if [ "$networked" = 1 ] && [ "$root" != 1 ]; then
|
if [ "$networked" = 1 ] && [ "$root" != 1 ]; then
|
||||||
echo "ERROR: --networked needs root — a networked bastion is a root-owned service (run with sudo)" >&2; exit 1
|
echo "ERROR: --networked needs root — a networked bastion is a root-owned service (run with sudo)" >&2; exit 1
|
||||||
fi
|
fi
|
||||||
@@ -159,10 +200,19 @@ UNIT
|
|||||||
echo "Service installed + enabled (it restarts until you pair, then connects automatically)."
|
echo "Service installed + enabled (it restarts until you pair, then connects automatically)."
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$networked" = 1 ]; then
|
if [ "$networked" = 1 ]; then
|
||||||
echo "Networked bastion. Anchor it to YOUR host first (root, one step):"
|
echo "Networked bastion. Pair it with YOUR host; the first host paired becomes its anchor:"
|
||||||
|
echo " sudo -H privatae-bastion pair <token>"
|
||||||
|
echo "Then enrol your second factor, so you can approve here:"
|
||||||
|
echo " sudo -H privatae-bastion stepup-enroll"
|
||||||
|
echo "Every terminal and write waits for your approval, and every later host for your host's."
|
||||||
|
echo "(Already paired before this install? Anchor with: sudo -H privatae-bastion claim-anchor)"
|
||||||
|
elif [ "$kept_local" = 1 ]; then
|
||||||
|
echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local."
|
||||||
|
echo "To make it a networked server, anchored to the host it serves:"
|
||||||
|
echo " sudo -H privatae-bastion set-mode networked"
|
||||||
echo " sudo -H privatae-bastion claim-anchor"
|
echo " sudo -H privatae-bastion claim-anchor"
|
||||||
echo " (add --host <name> if this machine already serves several Hosts)"
|
echo " sudo -H privatae-bastion stepup-enroll"
|
||||||
echo "Every host paired after that waits, pending, until your host approves it."
|
echo " sudo systemctl restart privatae-bastion"
|
||||||
else
|
else
|
||||||
echo "Now pair: sudo -H privatae-bastion pair <token>"
|
echo "Now pair: sudo -H privatae-bastion pair <token>"
|
||||||
fi
|
fi
|
||||||
@@ -175,18 +225,24 @@ elif [ "$darwin_service" = 1 ]; then
|
|||||||
"$bin" service install || die "could not register the system service"
|
"$bin" service install || die "could not register the system service"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Networked bastion, running as a system service (launchd, as root)."
|
echo "Networked bastion, running as a system service (launchd, as root)."
|
||||||
echo "Anchor it to YOUR host first (root, one step):"
|
echo "Pair it with YOUR host; the first host paired becomes its anchor:"
|
||||||
# The full path, not the name on PATH: /usr/local/bin may belong to the
|
# The full path, not the name on PATH: /usr/local/bin may belong to the
|
||||||
# signed-in user, who could point the link at their own program, and this
|
# signed-in user, who could point the link at their own program, and this
|
||||||
# line is run as root.
|
# line is run as root.
|
||||||
echo " sudo -H '$bin' claim-anchor"
|
echo " sudo -H '$bin' pair <token>"
|
||||||
echo " (add --host <name> if this machine already serves several Hosts)"
|
echo "Then enrol your second factor, so you can approve here:"
|
||||||
echo "Every host paired after that waits, pending, until your host approves it."
|
echo " sudo -H '$bin' stepup-enroll"
|
||||||
|
echo "Every terminal and write waits for your approval, and every later host for your host's."
|
||||||
|
echo "(Already paired before this install? Anchor with: sudo -H '$bin' claim-anchor)"
|
||||||
elif [ "$networked" = 1 ]; then
|
elif [ "$networked" = 1 ]; then
|
||||||
die "--networked needs a system service manager: systemd on Linux, launchd on macOS"
|
die "--networked needs a system service manager: systemd on Linux, launchd on macOS"
|
||||||
else
|
else
|
||||||
case ":$PATH:" in *":$bindir:"*) ;; *) echo "Add to PATH: export PATH=\"\$PATH:$bindir\"" ;; esac
|
case ":$PATH:" in *":$bindir:"*) ;; *) echo "Add to PATH: export PATH=\"\$PATH:$bindir\"" ;; esac
|
||||||
echo ""
|
echo ""
|
||||||
|
if [ "$kept_local" = 1 ]; then
|
||||||
|
echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local."
|
||||||
|
echo "To make it a networked server instead, run the installer again with --networked."
|
||||||
|
fi
|
||||||
echo "Now pair: privatae-bastion pair <token>"
|
echo "Now pair: privatae-bastion pair <token>"
|
||||||
echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)"
|
echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user