CONTENTS
0 why an authorisation layer
1 the manifest directory and its acl
2 the manifest schema
3 the wedr-plugin cli
4 sha-256 via bcrypt cng
5 doctor — drift detection
6 hot reload and auto-launch
──[ 0. Why an Authorisation Layer ]──
Part 7's pipe ACL restricts who can connect at the OS level — only processes
running as the same user as the agent or as `SYSTEM`. That removes the casual
attacker but is not enough by itself. A privileged process is privileged enough
to be an attack vector: a malware that runs as `SYSTEM`, an installer that drops
a hostile binary, a third-party agent with bugs. The plugin server needs a
second gate that lists, by identity, which programs are allowed to push
telemetry through it.
That gate is the **manifest directory**. One JSON file per authorised plugin,
named after its UUID. The agent reads the directory at startup, watches it for
changes, and rejects any `Hello` whose `plugin_id` does not have a corresponding
manifest. `wedr-plugin` is the administrator-side tool that writes into this
directory.
──[ 1. The Manifest Directory and Its ACL ]──
%ProgramData%\WazabiEDR\plugins\
├── 8f3c1d8e-5a8b-4ad0-94d2-cab9b1d0e2a0.json
├── 0a1b2c3d-aaaa-bbbb-cccc-dddddddddddd.json
└── …
The path is **hard-coded** in the agent. Making it configurable by an
unprivileged operator would defeat the trust anchor — anyone able to redirect
the agent to a different directory could write their own "authorised" manifest
into that directory.
`%ProgramData%` expands to `C:ProgramData` on a default installation. The
`WazabiEDRplugins` subdirectory is created at install time with an ACL that
grants write access to the Administrators group only. Other principals can read
the directory (and read individual manifest contents) but cannot add, modify, or
remove files. That single ACL is the security boundary; the agent's `Hello`
verification is an enforcer of what the ACL has already filtered.
──[ 2. The Manifest Schema ]──
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub plugin_id: String, // UUID v4 (also file name)
pub name: String, // human label
pub vendor: String,
pub expected_path: String, // absolute path on this host
#[serde(default)]
pub expected_sha256: Option<String>, // 64 hex chars
#[serde(default)]
pub expected_signer: Option<String>, // Authenticode subject DN
#[serde(default)]
pub revoked: bool,
#[serde(default)]
pub enrolled_at: Option<String>,
#[serde(default)]
pub auto_launch: bool,
}
This struct is **byte-identical** to the one in
`WazabiEDR_Agent::plugin::manifest::PluginManifest`. The two files carry
comments pointing at each other; an integration test round-trips a manifest
through both representations.
The duplication is deliberate. Hoisting the type into a shared crate would force
the agent to take a dependency on that crate, and the CLI takes the same
dependency — but the CLI is intended to remain tiny and lean (no `windows-sys`
features for kernel events, no event-format types, no transitive driver-side
crates). A type duplication with a round-trip test is cheaper than dragging the
agent's dependency surface into the operator CLI.
Three fields make up the identity check:
`expected_path` is matched against the connecting process's image path at
handshake. The agent obtains the process's PID via `GetNamedPipeClientProcessId`
and resolves the path via `QueryFullProcessImageNameW`.
`expected_sha256` is the SHA-256 of the binary as it was at enrolment. The agent
recomputes the hash of the *running* image at handshake (the same binary on disk
could have been replaced; what matters is what's loaded into the process) and
compares. A mismatch yields `Reject{reason: "sha256_mismatch"}`.
`expected_signer` is the optional Authenticode subject distinguished name
(Authenticode: Microsoft's code-signing scheme; the *subject DN* is the
LDAP-style identity of the signing certificate's subject, e.g. `CN=Acme Corp,
O=Acme, C=US`). If present, the agent verifies the running binary's Authenticode
signature against this subject. If absent, the agent relies on the SHA-256 alone
(the `--allow-unsigned` enrolment mode).
──[ 3. The wedr-plugin CLI ]──
The CLI has a small surface: enrol, list, show, doctor, revoke, unrevoke,
auto-launch toggle, remove. The everyday workflow is two commands:
wedr-plugin enroll C:\Path\To\acme-plugin.exe \
--name "Acme Telemetry" \
--vendor "Acme Corp" \
--auto-launch
wedr-plugin doctor
`enroll` generates a UUID (via `BCryptGenRandom`), hashes the binary, writes the
manifest. The UUID is printed to stdout; the operator pipes it into the plugin's
environment as `WEDR_PLUGIN_ID` (Part 8). The `--auto-launch` flag is the
supervisor opt-in covered in Section 6.
`update` re-hashes after a rebuild — without it, an `expected_sha256`-pinned
manifest would reject the new build:
wedr-plugin update <PLUGIN_ID>
wedr-plugin update <PLUGIN_ID> C:\New\Path\acme-plugin.exe
Two operational habits worth surfacing:
`wedr-plugin doctor` after every manifest-touching change. The doctor command
(Section 5) walks every manifest in the directory and reports drift between the
manifest and the on-disk binary. An operator who rebuilt a plugin and forgot to
`update` learns about it from doctor within seconds.
`wedr-plugin revoke` rather than `wedr-plugin remove` for incidents. Revoke
flips the `revoked` field but keeps the manifest; the agent rejects subsequent
handshakes for that plugin (hot reload, Section 6) without losing the audit
trail. Remove deletes the file outright. Revoke is reversible (`wedr-plugin
unrevoke`); remove is not.
The CLI parses arguments manually rather than using `clap` or `structopt`. The
reasoning is the same as elsewhere in the project: keep the binary small, keep
the dependency graph shallow, accept that hand-rolled flag parsing is fifty
lines and a small one-time cost.
──[ 4. SHA-256 via BCrypt CNG ]──
The CLI computes SHA-256 over the binary to populate `expected_sha256`. It does
not depend on the `sha2` crate. Instead it calls into Windows' Cryptography Next
Generation (*CNG, Cryptography Next Generation*: the modern Windows cryptography
API, exposed primarily through the `bcrypt.dll` library; the predecessor was
CryptoAPI / CAPI in `advapi32.dll`):
BCryptOpenAlgorithmProvider(&mut alg, BCRYPT_SHA256_ALGORITHM, ptr::null(), 0);
BCryptCreateHash(alg, &mut hash, ptr::null_mut(), 0, ptr::null(), 0, 0);
let mut buf = [0u8; 64 * 1024];
loop {
ReadFile(h, buf.as_mut_ptr(), buf.len() as u32, &mut read, ptr::null_mut());
if read == 0 { break; }
BCryptHashData(hash, buf.as_ptr(), read, 0);
}
BCryptFinishHash(hash, digest.as_mut_ptr(), 32, 0);
Two reasons to avoid `sha2`:
The agent verifies hashes at handshake using the same CNG primitives. Using a
Rust SHA-256 implementation on the CLI side and CNG on the agent side would
create two distinct implementations that need to remain output-equivalent
forever. Sharing CNG on both sides removes that obligation — they call into the
same OS-provided code.
The CLI binary is small (~200 KiB stripped). Pulling `sha2` and its transitive
bits would multiply that by roughly five. It is not catastrophic, but it shifts
the CLI in a direction we explicitly chose against.
The same reasoning extends to UUID generation: `BCryptGenRandom` instead of the
`uuid` crate. The local `uuid.rs` is roughly one screen of code.
──[ 5. doctor — Drift Detection ]──
$ wedr-plugin doctor
✓ Acme Telemetry 8f3c1d8e-…
✗ Beta Logger 0a1b2c3d-… binary missing at expected_path
✗ Gamma Sentinel 5c5c5c5c-… sha256 mismatch
expected: 9ab1…
actual: f4b9…
✓ Defender Bridge 7e7e7e7e-…
`doctor` walks every manifest in the directory and verifies three properties:
1. binary present at expected_path
2. SHA-256 of binary matches expected_sha256
3. manifest validates against the schema
Exit code is non-zero if any plugin fails any check, so the command composes
into CI and monitoring scripts (`wedr-plugin doctor || alert`). The rendered
table is for the operator; scripts read the exit code.
──[ 6. Hot Reload and Auto-Launch ]──
The agent does not need to be restarted after `enroll`, `revoke`, `update`, or
`remove`. It rescans `%ProgramData%WazabiEDRplugins` every 5 seconds and updates
its in-memory map of authorised plugins. A revocation takes effect on the next
handshake attempt from that plugin.
The 5-second interval is a deliberate trade. A 1-second interval would burn
syscalls for no operational gain; a 30-second interval would leave revocations
hanging too long after an incident response. Five seconds is the compromise.
`auto_launch` is the supervisor opt-in. When set, the agent's supervisor
(briefly mentioned in Part 5 as `spawn_supervisor`) spawns the plugin's process
at agent startup and restarts it on crash with exponential backoff (cap 60
seconds). The supervisor reads `auto_launch` *at agent startup only* — toggling
it via `wedr-plugin auto-launch` does not spawn a new plugin process until the
agent restarts. The CLI's help text says so, because that constraint is easy to
forget and produces "why isn't my plugin starting" confusion.
That closes the plugin authorisation story. Next post: a real plugin built on
top of all of this.