diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 2dc59a05..929d8ec1 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -1,6 +1,8 @@ aab AAFFBB aarch +abcdefghijkmnopqrstuvwxyz +ABCDEFGHJKLMNPQRSTUVWXYZ abe addrpair advapi @@ -12,11 +14,15 @@ autocrlf aya AZUREPUBLICCLOUD azuretools +azureuser backcompat +backdoored +bak bierner binpath binskim bitflag +boofuzz bpf bpftool btf @@ -28,10 +34,16 @@ buildroot buildshell byos cacheline +callouts cbl ccbdee ccbf +cgroups +cgroupv +cgtop +chokepoint cicd +Cim cimv cla closehandle @@ -40,21 +52,27 @@ codeofconduct codeql collectguestlogs commandline +COMPUTERNAME comspec consoleloggerparameters +coredumpctl covrec CPlat cplusplus cpptools crpteste CRYPTOAPI +CSPRNG csum customout customoutput cvd +CVEs czf DABC +DACL daddr +dafbe datacenter davidanson DDCE @@ -64,32 +82,48 @@ DEBHELPER debian Debpf defattr +dentity deploymentid +desync +desyncs +detbox +detlbl +dettitle +detval devcontainer +diffed +diffs distros dllmain +dministrator dnf dockerenv dodce dodce dotnet doxygen +DPAPI dport dtolnay +dumps'd Dvm EAccess +eaeef EAF ebpf ebpfapi EBPFCORE +eef egor ele ent entriesread +esac EStorage etest etestoutputs etestsharedstorage +etl EToken etw EUID @@ -98,9 +132,20 @@ evt exampledatadiskname exampleosdiskname examplevmname +exepath +exfil +EXTCONFIG +extconfig exthandlers +fafbfc +Fapi +Fbar fde +fea +ffcecb +ffebe fff +ffff FFFF FFFFFFFF fffi @@ -115,6 +160,7 @@ fsprogs fstorage fstype ftoken +fuzzer fwlink Fzpeng gaplugin @@ -122,16 +168,22 @@ getifaddrs goalstate gpa gpalinuxdev +gpapen gpawindev guestproxyagentmsis guiddef handleapi +hdr hklm hlist -HMAC +HMACs +homoglyph hostga +hostingenvironmentconfig httpwg +httpx Iaa +ICredentials idstepsrun IEnumerable ieq @@ -145,6 +197,7 @@ immediateruncommandservice intellectualproperty Intelli intellij +INVALIDMETHOD INVM Ioctl iusr @@ -152,14 +205,17 @@ jetbrains jqlang JOBOBJECT jobsjob +journalctl joutvhu JScript keyonly kinvolk +kmemleak kotlin kprobe ktime kusto +lbl lgrui libbpf libbpfcc @@ -174,6 +230,7 @@ lsa ltsc luid macikgo +maxdepth mcr MEMORYSTATUSEX metabuild @@ -181,58 +238,98 @@ MFC microsoftcblmariner microsoftlosangeles microsoftwindowsdesktop +misconfig +MMdd mmm mnt +monomorphization msasn +msc msp msrc multilib +ncurl netapi netcoreapp netebpfext nethook +netns +netsh Newtonsoft -nic +nftables +NICs nifs nmake +nmap nocapture NOCONFIRMATION +nodet NOERRORUI NONINFRINGEMENT +nosuchuser +notcontains notjson +notlike norestart +npidof +nprintf +NSG +nsudo ntdll NTSTATUS +ntimeout +OICI onscreen onebranch openprocess oneshot opencode opensource +osinfo +PAI parseable +passwordless peekable +pcap +PCAP +pcapng +pcaps +PEERCRED +pentest +PENTEST PERCPU pgpkey pgrep pidof +PIDs +pipefail pkgversion +pktmon pls portaddr portpair postinst pprev prandom +prctl predef +prefixer +Prefixer prefmaxlen preprovisioned +Prereqs printk PROCESSINFOCLASS +procdump processthreadsapi proxyagent proxyagentextensionvalidation proxyagentvalidation +pscustomobject ptrace pwstr +radamsa +Razr rcv RDFE redhat @@ -247,6 +344,7 @@ rgr rgs rhel RINGBUF +Roboto rockylinux rolename rootdir @@ -254,6 +352,8 @@ rpmbuild RPMS rstr rul +ruleset +runas runthis Runtimes rustfmt @@ -261,14 +361,20 @@ rustup saddr sandboxing sas +scapy schtasks scm +SDDL secauthz +Segoe serice SETFCAP SETPCAP +sev shd +shellcheck sids +SIEM sigid SIO skc @@ -276,6 +382,7 @@ sku sles sln smp +sourced spellright splitn SRPMS @@ -285,6 +392,7 @@ stackoverflow stdbool stdint stdoutput +stduser subsecond substatus Substatuses @@ -292,12 +400,16 @@ SUIDSGID suse Swatinem SWbem +SYD +SYG sysinfoapi sysinit SYSLIB SYSTEMDRIVE taiki TASKKILL +tcpdump +TCPDUMP telemetrydata tensin testcasesetting @@ -311,11 +423,21 @@ timedout timeup tlsv tmpfs +tnc +tnl +tnlp +tnp +TOCTOU tokio topdir totalentries transitioning +trustlevel trustyuser +tshark +tsv +Tsv +TSV UBR UBRSTRING udev @@ -323,6 +445,7 @@ uers uninstalls unistd unmark +unparseable Unregistering unregisters unspec @@ -355,6 +478,7 @@ wdksetup Werror westus wevtapi +wfp WFP winapi winbase @@ -369,7 +493,9 @@ wireserverandimds WMI workarounds WORKINGSET +workdir WORKDIR +wrk wrongvalue WScript wsf @@ -379,12 +505,17 @@ wstr wsum wyy xamarin +xbb +xbf +xef xcopy XDP xfsprogs xsi xxxx +XXXXXX xxxxxxxx xxxxxxxxxxx zipsas +zureuser zypper \ No newline at end of file diff --git a/.github/workflows/bloat.yml b/.github/workflows/bloat.yml new file mode 100644 index 00000000..3fec61f9 --- /dev/null +++ b/.github/workflows/bloat.yml @@ -0,0 +1,168 @@ +name: Bloat Budget + +# Enforce a hard cargo-bloat budget so silent binary +# growth blocks merge. The musl static builds themselves live in +# .github/workflows/reusable-build.yml (build-linux-amd64 / build-linux-arm64 +# / build-windows-amd64 / build-windows-arm64); this workflow only adds the +# per-(target, role) regression gate on top of them. +# +# Per-target ceilings exist on purpose: a Linux musl binary and a Windows +# MSVC binary (with static_vcruntime + windows-sys) have very different +# baselines. One shared ceiling would either let Windows regress silently +# or false-flag every Linux PR. See ci/README.md for the override path. + +on: + push: + branches: ["main", "dev"] + pull_request: + branches: ["main", "dev"] + +env: + CARGO_TERM_COLOR: always + # Strict default: every non-first-party crate must stay under this share of + # the text section. Per-(target, crate) exceptions live in the matrix below + # as `crate_share_overrides` so they're auditable and narrowly scoped. + MAX_CRATE_SHARE: "0.10" # 10% of text per non-first-party crate + +concurrency: + group: bloat-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + bloat-budget: + name: bloat (${{ matrix.target }} / ${{ matrix.crate }}) + runs-on: ${{ matrix.runs_on }} + strategy: + fail-fast: false + matrix: + include: + # -------- Linux x86_64 musl -------- + - target: x86_64-unknown-linux-musl + runs_on: ubuntu-latest + crate: azure-proxy-agent + max_binary_bytes: "20000000" # ~20 MB + # Vendored OpenSSL (approved crypto) is structurally ~20% of text + # for the agent on musl; HMAC-SHA256 in proxy_agent_shared pulls it. + crate_share_overrides: "openssl_sys=0.25" + apt_packages: musl-tools + - target: x86_64-unknown-linux-musl + runs_on: ubuntu-latest + crate: ProxyAgentExt + max_binary_bytes: "9000000" # ~9 MB + crate_share_overrides: "clap_builder=0.15 regex_automata=0.15" + apt_packages: musl-tools + - target: x86_64-unknown-linux-musl + runs_on: ubuntu-latest + crate: proxy_agent_setup + max_binary_bytes: "6000000" # ~6 MB + # proxy_agent_setup is tiny (~1 MiB text after the openssl gate), + # so a normal-sized clap derive parser is ~30% by share. + crate_share_overrides: "clap_builder=0.35" + apt_packages: musl-tools + + # -------- Linux aarch64 musl (native arm64 runner) -------- + - target: aarch64-unknown-linux-musl + runs_on: ubuntu-24.04-arm + crate: azure-proxy-agent + max_binary_bytes: "20000000" + crate_share_overrides: "openssl_sys=0.15" + apt_packages: musl-tools + - target: aarch64-unknown-linux-musl + runs_on: ubuntu-24.04-arm + crate: ProxyAgentExt + max_binary_bytes: "16000000" + crate_share_overrides: "clap_builder=0.15 regex_automata=0.15" + apt_packages: musl-tools + - target: aarch64-unknown-linux-musl + runs_on: ubuntu-24.04-arm + crate: proxy_agent_setup + max_binary_bytes: "11000000" + crate_share_overrides: "clap_builder=0.35" + apt_packages: musl-tools + + # -------- Windows x86_64 MSVC -------- + - target: x86_64-pc-windows-msvc + runs_on: windows-latest + crate: azure-proxy-agent + max_binary_bytes: "10000000" + - target: x86_64-pc-windows-msvc + runs_on: windows-latest + crate: ProxyAgentExt + max_binary_bytes: "5000000" + crate_share_overrides: "clap_builder=0.20 regex_automata=0.15 regex_syntax=0.15" + - target: x86_64-pc-windows-msvc + runs_on: windows-latest + crate: proxy_agent_setup + max_binary_bytes: "4000000" + crate_share_overrides: "clap_builder=0.35 regex_automata=0.20 regex_syntax=0.15" + + # -------- Windows aarch64 MSVC (cross-compiled on x64 runner) -------- + - target: aarch64-pc-windows-msvc + runs_on: windows-latest + crate: azure-proxy-agent + max_binary_bytes: "8000000" + # No vendored OpenSSL on Windows (BCrypt), but the binary is much + # smaller so tokio's fixed cost crosses 10% by share. + crate_share_overrides: "tokio=0.12" + - target: aarch64-pc-windows-msvc + runs_on: windows-latest + crate: ProxyAgentExt + max_binary_bytes: "5000000" + crate_share_overrides: "clap_builder=0.20 regex_automata=0.20 regex_syntax=0.15" + - target: aarch64-pc-windows-msvc + runs_on: windows-latest + crate: proxy_agent_setup + max_binary_bytes: "4000000" + crate_share_overrides: "clap_builder=0.25 regex_automata=0.20 regex_syntax=0.15" + + steps: + - uses: actions/checkout@v4 + + - name: Install apt packages (Linux only) + if: runner.os == 'Linux' && matrix.apt_packages != '' + run: | + sudo apt-get update + sudo apt-get install -y ${{ matrix.apt_packages }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + key: bloat-${{ matrix.target }}-${{ matrix.crate }} + + - name: Install cargo-bloat + run: cargo install cargo-bloat --locked + + - name: Run cargo-bloat + shell: bash + run: | + cargo bloat --release --crates \ + --target ${{ matrix.target }} \ + -p ${{ matrix.crate }} \ + --message-format json > bloat.json + + - name: Enforce budget + shell: bash + run: | + overrides="" + for kv in ${{ matrix.crate_share_overrides }}; do + overrides="$overrides --crate-share-override $kv" + done + python3 ci/check_bloat.py \ + --bloat-json bloat.json \ + --max-binary-bytes ${{ matrix.max_binary_bytes }} \ + --max-crate-share ${{ env.MAX_CRATE_SHARE }} \ + $overrides \ + | tee bloat-report.txt + + - name: Upload bloat report + if: always() + uses: actions/upload-artifact@v4 + with: + name: bloat-report-${{ matrix.target }}-${{ matrix.crate }} + path: | + bloat.json + bloat-report.txt + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 41e88617..c3dc84e2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ # Visual Studio cache/options directory .vs/ + +# pentest run & results +/pentest/*/results/ +__pycache__/ diff --git a/Cargo.lock b/Cargo.lock index 4f62b972..cb9fdef2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "ProxyAgentExt" -version = "1.0.44" +version = "1.0.45" dependencies = [ "clap", "ctor", @@ -172,7 +172,7 @@ dependencies = [ [[package]] name = "azure-proxy-agent" -version = "1.0.44" +version = "1.0.45" dependencies = [ "aya", "base64", @@ -832,9 +832,9 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", @@ -866,9 +866,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -938,7 +938,7 @@ dependencies = [ [[package]] name = "proxy_agent_setup" -version = "1.0.44" +version = "1.0.45" dependencies = [ "clap", "proxy_agent_shared", @@ -950,7 +950,7 @@ dependencies = [ [[package]] name = "proxy_agent_shared" -version = "1.0.44" +version = "1.0.45" dependencies = [ "chrono", "concurrent-queue", diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 00000000..1e040dd4 --- /dev/null +++ b/ci/README.md @@ -0,0 +1,166 @@ +# CI Helpers — Bloat Budget + +This directory implements a hard +`cargo-bloat` budget enforced on every PR. + +The musl static binaries themselves (both `x86_64-unknown-linux-musl` and +`aarch64-unknown-linux-musl`) are already produced by +[`.github/workflows/reusable-build.yml`](../.github/workflows/reusable-build.yml) +via `build-linux.sh`. This directory only adds the regression gate. + +## Why this matters + +The guest proxy agent ships on **every** Azure Linux VM. Every byte the +binary grows is paid millions of times over: bigger images, slower VM +provisioning, larger memory-resident `.text`, slower cold start, more +surface area to attest in the SBOM (3.4) and supply-chain pipeline. + +Binary size is the kind of thing that rots silently. A typical PR will +**not** mention size in its title, and reviewers can't eyeball it from a +diff. The usual culprits are invisible at the source-code level: + +- enabling an extra Cargo **feature** on a transitive dep (e.g. flipping + `tokio = { features = ["full"] }`) pulls in megabytes; +- a small generic helper used from many call sites causes **monomorphization + blowup**; +- a "harmless" new dependency drags in `openssl-sys`, `chrono` with all + locales, a full `regex` engine, or a duplicated async runtime; +- a `cargo update` bumps a transitive crate to a version that vendors more + data tables. + +Any of those can add **multiple megabytes** to a release binary without a +single line of our code changing. Without a gate, the only way we find out +is when a customer complains months later. + +## What "regression gate" means here + +A *regression gate* is a CI check that fails the PR when a numeric metric +crosses a threshold, independent of whether the code compiles or tests +pass. We already have several of those (clippy `-D warnings`, code-coverage +≥ 70 %, `cargo-audit`). The bloat budget is the same idea applied to the +release binary: + +> If this PR would make the agent larger than 20 MB stripped, or would let +> any single third-party crate own more than 10 % of `.text`, **block the +> merge** until either the cause is fixed or the budget change is +> explicitly reviewed (see "Override path" below). + +The gate has two ceilings on purpose: + +- **Absolute ceiling** catches "total growth" no matter where it came from. +- **Per-crate share ceiling** catches "one bad dependency dominates the + binary" even when total size is still under the absolute ceiling. This + is what makes the gate point a finger — the failure message names the + offending crate. + +## What `cargo-bloat` actually does + +[`cargo-bloat`](https://github.com/RazrFalcon/cargo-bloat) is a small tool +that compiles the workspace, then inspects the resulting binary's symbol +table and groups every function in `.text` by the crate that produced it. +Run with `--crates --message-format json` it emits a structured report +like: + +```json +{ + "file-size": 17234048, + "text-section-size": 9123456, + "crates": [ + { "name": "azure-proxy-agent", "size": 2_100_000 }, + { "name": "tokio", "size": 870_000 }, + { "name": "regex", "size": 640_000 }, + ... + ] +} +``` + +We feed that JSON into `check_bloat.py`, which: + +1. checks total binary size against `--max-binary-bytes`; +2. checks every non-first-party crate's share of `.text` against + `--max-crate-share`; +3. prints the top contributors so a failing PR comes with an actionable + report ("crate `foo` is 17.3 % of text — did this PR add a feature?"). + +Concretely, `cargo-bloat` gives us **attribution**: instead of "the binary +grew 4 MB", we get "the binary grew 4 MB and 3.6 MB of it landed in +`some-crate`". That attribution is the whole point — it turns a vague size +problem into a specific code-review conversation. + +## Files + +| File | Purpose | +| ----------------- | ----------------------------------------------------------------------- | +| `check_bloat.py` | Reads `cargo bloat --message-format json` and fails if a budget is hit. | + +The workflow that runs it lives at +[`.github/workflows/bloat.yml`](../.github/workflows/bloat.yml). + +## Budgets (default) + +The gate runs as a matrix over `(target, role binary)`. The absolute +ceiling is per matrix entry — a Windows MSVC binary with `static_vcruntime` +and `windows-sys` has a different baseline than a Linux musl binary, and +the setup tool has a different baseline than the main agent. The +per-crate share is a ratio and is kept global. + +| Target | Role binary | Max stripped size | +| ------------------------------- | --------------------- | ----------------- | +| `x86_64-unknown-linux-musl` | `azure-proxy-agent` | 20 MB | +| `x86_64-unknown-linux-musl` | `ProxyAgentExt` | 15 MB | +| `x86_64-unknown-linux-musl` | `proxy_agent_setup` | 10 MB | +| `aarch64-unknown-linux-musl` | `azure-proxy-agent` | 22 MB | +| `aarch64-unknown-linux-musl` | `ProxyAgentExt` | 16 MB | +| `aarch64-unknown-linux-musl` | `proxy_agent_setup` | 11 MB | +| `x86_64-pc-windows-msvc` | `azure-proxy-agent` | 28 MB | +| `x86_64-pc-windows-msvc` | `ProxyAgentExt` | 22 MB | +| `x86_64-pc-windows-msvc` | `proxy_agent_setup` | 15 MB | +| `aarch64-pc-windows-msvc` | `azure-proxy-agent` | 30 MB | +| `aarch64-pc-windows-msvc` | `ProxyAgentExt` | 23 MB | +| `aarch64-pc-windows-msvc` | `proxy_agent_setup` | 16 MB | + +Per-crate share ceiling (`--max-crate-share`): **0.10** (10 % of `.text`), +applied to every matrix entry. + +First-party workspace crates (`azure-proxy-agent`, `ProxyAgentExt`, +`proxy_agent_setup`, `proxy_agent_shared`) are exempt from the per-crate +share gate; the absolute size ceiling still applies to them. + +## Running locally + +```bash +rustup target add x86_64-unknown-linux-musl +sudo apt-get install -y musl-tools +cargo install cargo-bloat --locked + +cargo bloat --release --crates \ + --target x86_64-unknown-linux-musl \ + -p azure-proxy-agent \ + --message-format json > bloat.json + +python3 ci/check_bloat.py \ + --bloat-json bloat.json \ + --max-binary-bytes 20000000 \ + --max-crate-share 0.10 +``` + +The script exits `0` when within budget, `1` when the budget is exceeded +(prints a report of top contributors and which ceiling was tripped), and +`2` on invalid input. + +## Override path + +Bloat regressions are intentional sometimes (new feature, security-driven +dependency upgrade). When that happens: + +1. Run the commands above locally and copy the report into the PR. +2. Bump the relevant `max_binary_bytes` entry (or `MAX_CRATE_SHARE`) in + the matrix in `.github/workflows/bloat.yml` **and** update the table + above in the same PR. Only change the row(s) that actually regressed + — do not raise unrelated platforms. +3. Get **two reviewer approvals** specifically acknowledging the budget + change (a `LGTM-bloat` review tag in the PR body is the convention). +4. After merge, the new ceiling becomes the baseline for subsequent PRs. + +Unauthorized bypasses (e.g. `--no-verify`, removing the workflow) are not +permitted. diff --git a/ci/check_bloat.py b/ci/check_bloat.py new file mode 100644 index 00000000..0cf12dcd --- /dev/null +++ b/ci/check_bloat.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation +# SPDX-License-Identifier: MIT +""" +Enforce the binary-bloat budget for the GuestProxyAgent workspace. + +Implements Innovation 7.3 (crate consolidation / bloat budget): + + * Hard ceiling on total stripped binary size. + * Per-crate ceiling expressed as a share of the total text section. + +Input is a JSON document produced by `cargo bloat --message-format json`. +The script exits non-zero (and prints a human-readable report) when the +budget is exceeded so it can gate CI. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Iterable + + +# Workspace-first-party crates are exempt from the per-crate share ceiling. +# Bloating one of our own crates is a code-review problem, not a dependency +# problem; the total-size ceiling still applies. +FIRST_PARTY_CRATES = { + "azure_proxy_agent", + "azure-proxy-agent", + "ProxyAgentExt", + "proxy_agent", + "proxy_agent_extension", + "proxy_agent_setup", + "proxy_agent_shared", +} + +# Synthetic buckets emitted by cargo-bloat that don't correspond to a tunable +# third-party dependency. The total-size ceiling still bounds them. +SYNTHETIC_BUCKETS = { + "std", + "[Unknown]", + "?", +} + +# Per-(target, crate, dependency) ceiling overrides are passed in via +# --crate-share-override on the command line; see main() below. +# Use this instead of a global allowlist so the policy stays narrow: +# raising the ceiling for clap in proxy_agent_setup must not also raise it +# for every other dependency in every other binary. + + +def _iter_crate_sizes(report: dict) -> Iterable[tuple[str, int]]: + """Yield (crate_name, size_bytes) pairs from a cargo-bloat JSON report.""" + crates = report.get("crates") + if crates is None: + # cargo-bloat emits a "functions" array when run without --crates; + # try to fall back gracefully so the script is still useful locally. + for fn in report.get("functions", []): + crate = fn.get("crate") or "?" + size = int(fn.get("size", 0)) + yield crate, size + return + + for entry in crates: + name = entry.get("name") or "?" + size = int(entry.get("size", 0)) + yield name, size + + +def _aggregate(report: dict) -> tuple[int, list[tuple[str, int]]]: + """Return (total_text_size, sorted_crate_sizes_desc).""" + totals: dict[str, int] = {} + for crate, size in _iter_crate_sizes(report): + totals[crate] = totals.get(crate, 0) + size + + total_text = int(report.get("text-section-size", sum(totals.values()))) + ranked = sorted(totals.items(), key=lambda kv: kv[1], reverse=True) + return total_text, ranked + + +def _format_bytes(n: int) -> str: + size = float(n) + for unit in ("B", "KiB", "MiB", "GiB"): + if size < 1024 or unit == "GiB": + if unit == "B": + return f"{int(size):,d} {unit}" + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} GiB" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--bloat-json", + type=Path, + default=Path("bloat.json"), + help="Path to cargo-bloat JSON output (default: bloat.json).", + ) + parser.add_argument( + "--max-binary-bytes", + type=int, + required=True, + help="Hard ceiling on the stripped binary text section, in bytes.", + ) + parser.add_argument( + "--max-crate-share", + type=float, + required=True, + help="Maximum fraction of text section any non-first-party crate may consume (0..1).", + ) + parser.add_argument( + "--crate-share-override", + action="append", + default=[], + metavar="NAME=SHARE", + help=( + "Raise the share ceiling for a single crate (repeatable). " + "Example: --crate-share-override clap_builder=0.35. " + "Only the named crate is affected; all others stay at --max-crate-share." + ), + ) + parser.add_argument( + "--top", + type=int, + default=10, + help="How many top contributors to print in the report (default: 10).", + ) + args = parser.parse_args(argv) + + if not args.bloat_json.exists(): + print(f"error: {args.bloat_json} not found", file=sys.stderr) + return 2 + + try: + report = json.loads(args.bloat_json.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"error: {args.bloat_json} is not valid JSON: {exc}", file=sys.stderr) + return 2 + + if not 0 < args.max_crate_share <= 1: + print("error: --max-crate-share must be in (0, 1]", file=sys.stderr) + return 2 + + # Parse --crate-share-override into a {crate -> ceiling} map. We index by + # both the dashed and underscored crate name because cargo-bloat reports + # use underscored forms while Cargo.toml entries use dashes. + overrides: dict[str, float] = {} + for spec in args.crate_share_override: + name, sep, value = spec.partition("=") + if not sep or not name: + print( + f"error: bad --crate-share-override {spec!r} (expected NAME=SHARE)", + file=sys.stderr, + ) + return 2 + try: + share = float(value) + except ValueError: + print( + f"error: bad share value in --crate-share-override {spec!r}", + file=sys.stderr, + ) + return 2 + if not 0 < share <= 1: + print( + f"error: share in --crate-share-override {spec!r} must be in (0, 1]", + file=sys.stderr, + ) + return 2 + overrides[name] = share + overrides[name.replace("-", "_")] = share + + total_text, ranked = _aggregate(report) + file_size = int(report.get("file-size", 0)) or total_text + + failures: list[str] = [] + + if file_size > args.max_binary_bytes: + failures.append( + f"binary size {_format_bytes(file_size)} exceeds ceiling " + f"{_format_bytes(args.max_binary_bytes)}" + ) + + if total_text > 0: + for crate, size in ranked: + share = size / total_text + normalized = crate.replace("-", "_") + # First-party crates can grow without tripping the share gate; + # the absolute size ceiling still bounds them. Synthetic buckets + # (std, [Unknown], ?) aren't tunable dependencies. + if ( + crate in FIRST_PARTY_CRATES + or normalized in FIRST_PARTY_CRATES + or crate in SYNTHETIC_BUCKETS + ): + continue + # Per-crate override (if any) wins over the global ceiling. + ceiling = overrides.get(crate, overrides.get(normalized, args.max_crate_share)) + if share <= ceiling: + continue + tag = "" if ceiling == args.max_crate_share else " [override]" + failures.append( + f"crate '{crate}' is {share * 100:.1f}% of text " + f"(> {ceiling * 100:.1f}% ceiling{tag})" + ) + + print("== bloat budget report ==") + print(f"binary file size : {_format_bytes(file_size)}") + print(f"text section size: {_format_bytes(total_text)}") + if overrides: + # Deduplicate the dashed/underscored aliases for display. + shown: dict[str, float] = {} + for name, share in overrides.items(): + shown.setdefault(name.replace("-", "_"), share) + print("per-crate overrides:") + for name, share in sorted(shown.items()): + print(f" {name}: {share * 100:.1f}%") + print(f"top {args.top} contributors:") + for crate, size in ranked[: args.top]: + share = (size / total_text * 100) if total_text else 0.0 + print(f" {share:5.1f}% {_format_bytes(size):>10} {crate}") + + if failures: + print() + print("FAIL: bloat budget exceeded") + for f in failures: + print(f" - {f}") + return 1 + + print() + print("OK: within bloat budget") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pentest/README.md b/pentest/README.md new file mode 100644 index 00000000..00ff92b7 --- /dev/null +++ b/pentest/README.md @@ -0,0 +1,33 @@ +# GPA Pen-Test Harness + +Two parallel harnesses — one per OS — exercising the same scenario set +(`A1` / `B1b` / `C7` / `IMDS-S6` / …) against the Guest Proxy Agent. Each +folder is self-contained: its own scripts, its own design doc, its own +report generator, its own `results/` directory. + +| Folder | Language | Report generator | Run from | +|---|---|---|---| +| [linux/](linux/) | Bash + Python | `linux/generate_report.py` | Bash on the GPA Linux VM (`bash linux/run_all.sh`) | +| [windows/](windows/) | PowerShell | `windows/Generate-Report.ps1` | Elevated PowerShell on the GPA Windows VM (`.\windows\Run-AllPenTests.ps1`) | + +Findings on either platform are written as TSV rows with the same schema: + +``` +\t\t\t +``` + +so a Linux `findings.tsv` and a Windows `findings.tsv` can be diffed +test-by-test. + +## Pick a side + +- **Linux harness** — see [linux/README.md](linux/README.md) and + [linux/DESIGN.md](linux/DESIGN.md). +- **Windows harness** — see [windows/README.md](windows/README.md) and + [windows/DESIGN.md](windows/DESIGN.md). + +The Windows DESIGN doc has a cross-platform mapping table at the bottom +([windows/DESIGN.md §5](windows/DESIGN.md)) that lists each Linux concept +(eBPF redirect, `bpftool`, `tcpdump`, POSIX modes, `unshare -Cr`, …) next +to its Windows equivalent (WFP, `netsh wfp show filters`, `pktmon`, ACLs, +Containers feature, …). diff --git a/pentest/linux/DESIGN.md b/pentest/linux/DESIGN.md new file mode 100644 index 00000000..85d6dcb5 --- /dev/null +++ b/pentest/linux/DESIGN.md @@ -0,0 +1,247 @@ +# GPA Pen-Test Design + +Target: Guest Proxy Agent (GPA) running on this Azure VM +(kernel 6.11, listener `127.0.0.1:3080`, eBPF redirecting IMDS +`169.254.169.254:80`, WireServer `168.63.129.16:80`, HostGAPlugin +`168.63.129.16:32526`). + +--- + +## 1. Scope & Trust Model + +### In scope (the GPA enforcement boundary) +- TCP listener `127.0.0.1:3080` (the proxy itself). +- eBPF redirect program attached at the cgroup root. +- The HMAC signing path (`x-ms-azure-signature`) injected by GPA on authorized requests. +- AuthZ engine in [proxy_authorizer.rs](../../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../../proxy_agent/src/proxy/authorization_rules.rs). +- Key-keeper / latch state in `/var/lib/azure-proxy-agent/keys` and config in [GuestProxyAgent.linux.json](../../proxy_agent/config/GuestProxyAgent.linux.json). +- Provisioning, status, and log surfaces under `/var/log/azure-proxy-agent`. +- The handler/service binaries from the extension ([proxy_agent_extension/](../../proxy_agent_extension/), [proxy_agent_setup/](../../proxy_agent_setup/)). + +### Out of scope +- WireServer/IMDS server-side responses. +- The host fabric. +- Kernel CVEs unrelated to GPA. +- Azure control plane. + +### Attacker models to test against +1. **Unprivileged guest user** (no sudo) — primary threat model: confused-deputy / SSRF. +2. **Privileged guest user** (root in a container, root on host) — GPA still must not be a privilege oracle for WireServer if not allowed. +3. **Adjacent container / cgroup-namespaced workload** — can it bypass cgroup-attached eBPF? +4. **Network-adjacent attacker** (other VMs on same vNet / DHCP) — must not authenticate. +5. **Local malicious binary with bind-mount / path tricks** — can it forge `processFullPath`? + +--- + +## 2. Pen-Test Scenarios (mapped to GPA invariants) + +### A. Network exposure / listener hardening +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| A1 | Port-scan localhost and all NICs (`nmap -sT -p- 127.0.0.1` and ``). | Only `127.0.0.1:3080` open; nothing on external IFs. | The proxy must be reachable **only** from in-guest processes. Any binding to an external interface would let a network-adjacent attacker (vNet peer, lateral mover) speak to GPA directly and turn it into a fabric oracle. This is the most basic perimeter test — if it fails, every other AuthN/AuthZ control is moot. | +| A2 | Connect to `3080` from another VM in the vNet. | TCP RST / unreachable. | Cross-checks A1 from the *attacker's* viewpoint: even if a misconfig binds to `0.0.0.0`, NSG/host firewall must still drop it. Verifies defense-in-depth between the listener config and the cloud network filter. | +| A3 | Send malformed HTTP, oversized headers, slow-loris, chunked-encoding desync to `:3080`. | Graceful close, no crash, no memory growth. | Detects parser bugs (panic, OOM, integer overflow) in the hyper-based listener. A crash is an availability finding (fabric calls then fail-open or fail-closed depending on H4-style behavior); a desync would let one client smuggle requests under another client's identity — the worst-case AuthZ bypass. | +| A4 | TLS / HTTP/2 upgrade attempts, `CONNECT` method, Host-header smuggling. Automated probe is `curl -X CONNECT --request-target 'example.com:443' http://127.0.0.1:3080` (replaces an earlier `printf | nc` pipeline that raced the server's `Connection: close` FIN). | **HTTP/1.1 405 Method Not Allowed** with `Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH`; no proxying to arbitrary hosts. Implemented in [proxy_server.rs](../proxy_agent/src/proxy/proxy_server.rs) via an explicit `is_method_allowed` allow-list returned **before** any forwarding, plus `.with_upgrades()` on `serve_connection` so hyper actually delivers `CONNECT` to our handler instead of intercepting it as an upgrade. Unit test: `unsupported_methods_return_405`. | The proxy is HTTP/1.1-only and must not be a generic forward proxy. `CONNECT` or a smuggled `Host:` header could turn GPA into an SSRF gadget that talks to any internal IP from the *signed*, *authorized* GPA process — a privilege escalation against the host fabric. | +| A5 | Open-proxy test: `curl -x 127.0.0.1:3080 http://example.com`. | Refused — only IMDS/WireServer/HostGA destinations reachable. | Confirms the destination allow-list is enforced regardless of how the request is framed. Prevents using GPA as an exfiltration / pivot proxy and ensures the `x-ms-azure-signature` is never minted for a non-fabric URL. | + +### B. AuthN bypass — forging the signature +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| B1 | Direct `curl http://168.63.129.16/...` and `curl http://169.254.169.254/...` from guest, observe whether GPA inserts a valid `x-ms-azure-signature`. Then try the **same request bypassing 3080** (raw-socket / scapy with crafted SYN to the real IP, dropping the cgroup hook). | Without GPA injection, WireServer rejects; the signature must not be forgeable offline. | Validates the **fundamental authentication invariant**: only requests that traverse GPA receive a valid signature, and the fabric refuses any request that arrives without one. If a raw-socket call ever succeeds, an attacker can talk to WireServer/HostGAPlugin without going through any of GPA's authorization rules — effectively bypassing the entire product. | +| B2 | Replay attack: capture an authorized request from `/var/log/azure-proxy-agent/ProxyAgent.Connection.log` correlations + tcpdump on lo, replay it from another process. | Replay rejected (timestamp / nonce / per-connection binding). | A signature that is valid forever / for any caller would let any local process steal a previously-observed authorization. Confirms the signing scheme binds to time + connection context, not just URL+key. | +| B3 | Steal the key file: as non-root, attempt to read `/var/lib/azure-proxy-agent/keys/*`. As root, exfiltrate and try to sign from another VM. | Non-root: EACCES. Root-exfil: signature still rejected by WireServer for a different VM identity (vTPM/HostGAPlugin binding). | Two-layer test: (1) least-privilege access to key material on disk; (2) even with the key, the fabric should bind it to *this* VM's identity (provisioned via HostGAPlugin), so a stolen key is useless on another VM. Failure of (2) would mean a single host compromise yields cross-VM fabric access. | +| B1b | Open a TCP socket to the IMDS IP from the test shell and snapshot `ss -tnp` for the FD. The harness keeps the FD open in the *current* shell (an earlier subshell-scoped `( exec 3<> ... )` closed the FD before `ss` ran and produced empty results). | Kernel reports the connection's remote endpoint as `127.0.0.1:3080` (or the local GPA listener), proving the cgroup-attached eBPF redirect is in effect for this cgroup. PASS on `:3080`/`127.0.0.1`, FAIL if the kernel shows the raw IMDS IP. | Automated proof that the eBPF redirect is doing its job for the calling process — without this, B1's bypass attempt is meaningless because we can't tell whether the request was forced through GPA or simply happened to be answered by IMDS. | +| B4 | Submit a request with attacker-supplied `x-ms-azure-signature`/`x-ms-azure-time-tick` headers through the proxy. | GPA strips/overwrites client-supplied auth headers (verify in code path). | Prevents header smuggling: a malicious in-guest caller must not be able to *inject* a forged signature that GPA then forwards verbatim. GPA must always overwrite/normalize these headers before forwarding so the fabric only ever sees GPA's own signature. | + +### C. AuthZ bypass — cgroup / eBPF audit spoofing +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| C1 | Run a non-elevated process; attempt WireServer call (must be denied per `WireServer::authorize` requiring `runAsElevated`). | 403 Forbidden, audit entry written. | Baseline AuthZ check: WireServer is privileged-by-default at GPA. If a non-root caller could reach goal-state, *any* container or unprivileged guest user could read VM secrets/extension config. Also verifies the audit-log path actually fires on deny (operators rely on it for incident response). | +| C2 | `setuid` / capability dropping mid-process to confuse the `userId/runAsElevated` decision. | GPA reads creds at connect time from socket peer → still correct uid. | Confirms identity is captured from the kernel **at connect-time** via `SO_PEERCRED`, not re-read from `/proc` later. A TOCTOU here would let a process raise privileges *after* GPA cached its identity, or drop them to confuse audit attribution. | +| C3 | Bind-mount `/proc/self/exe` over a path matching an allowed binary; check the `processFullPath` reported in the log. | Identity comes from kernel-side eBPF/audit, not `/proc//exe` readlink. Decision unchanged. | Ensures `exePath`-based identity rules can't be spoofed by a mount-namespace attacker pretending to be `/usr/bin/curl` (or any other allow-listed binary). The matcher must use a kernel source of truth, not a user-controllable filesystem view. | +| C4 | `ptrace`-attach to an allowed process and inject syscall to `connect()` to IMDS. | Still attributed to that PID's cgroup/uid — this is by-design "in-process" trust; **document expected behavior**. | Documents the trust boundary: GPA cannot distinguish a victim process from a parasitic injector inside the same PID. The point of this test is to confirm the *boundary is documented*, not to claim it as a vuln — it sets reviewer expectations and prevents the threat model from drifting. | +| C5 | New cgroup that is **not** a descendant of `/sys/fs/cgroup` root (e.g. unshare / `cgroupv1` mount in container). Try to evade the cgroup-attached eBPF and reach `169.254.169.254:80` directly. Harness probes `unshare -Cr -- true` first; auto-prepends `sudo -n` when not root and passwordless sudo is available. If the kernel disallows `unshare -Cr` for the caller (no `CAP_SYS_ADMIN`), record **INFO** ("environment limitation") instead of a misleading FAIL. | Connection still redirected to GPA (HTTP 200 from `169.254.169.254` via the redirect). If a `000` is observed despite a successful `unshare`, that's a real eBPF/cgroup gap — investigate `bpftool cgroup tree` and the attach point. | The cgroup-root-attached eBPF is the **single chokepoint** that forces all 169.254/168.63 traffic through GPA. If a container or `unshare` workload can move outside that cgroup hierarchy, it can talk to IMDS/WireServer with no AuthN at all. This is one of the highest-impact bypasses in the entire suite. | +| C6 | Inside an unprivileged user namespace + new net namespace, route 169.254/16 directly. | Should fail (no key/no signature). Verify. | Even if eBPF is bypassed in a netns, the request still has no signature and the fabric should refuse it. Combined with C5, this gives a *layered* assurance: either the redirect catches it, or the fabric does — never both failing simultaneously. | +| C7 | IPv6 / link-local / alternate forms (`http://[::ffff:169.254.169.254]/...`, `http://0xa9fea9fe/...`, `http://2852039166/...`). Harness now captures the canonical response (status + sha256 of body) at B1a and **automatically diffs** each alt form against it. | **PASS** when alt status code AND body sha256 match the canonical (proves eBPF treated them identically). **PASS** also when alt is refused with a 4xx / `000` (no bypass). **INFO** when status matches but body sha differs (per-request token noise — manual diff). **FAIL** when alt returns a different non-4xx status than the canonical — possible eBPF/SSRF-filter bypass. | Ensures the eBPF redirect matches the **destination IP**, not a string form, so address-encoding tricks (hex, decimal, IPv4-mapped IPv6) can't reach the fabric while skipping GPA. Classic SSRF-filter bypass technique applied at the network layer. | + +### D. AuthZ rule engine fuzzing +Target: [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs) + +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| D1 | URL parsing differentials: `%2F` vs `/`, `%2f`, double-encoding, `;param`, `..`, trailing dot in host, mixed case scheme. | Rule match parity with server-side; no bypass of deny rule via encoding. | The classic AuthZ-bypass family: rule engine and upstream server disagree on what a URL means, so a deny rule for `/foo/bar` is evaded by `/foo%2Fbar`. Phase 4b's S6 scenarios are the automated form of this; D1 is the broader fuzz that exercises every encoding edge case the matcher must canonicalize. | +| D2 | Path-traversal in `processFullPath` matcher (symlink to allowed binary). | Deny based on resolved path / inode. | Companion to C3 from the rule-engine side: ensures `exePath` matching is on a canonical kernel-resolved path, not a string the caller can manufacture via symlinks/bind-mounts. | +| D3 | Identity case-sensitivity, Unicode normalization in user names / process names. | Stable match. | A rule for identity `"root"` must not be bypassed by `"Root"` or a Unicode-homoglyph variant, and conversely an allow for `"alice"` must not accidentally match `"ALICE"`. Ensures the matcher's normalization (lowercasing, NFC) is applied consistently and documented. | +| D4 | Property-based fuzz of the rule loader with malformed `AuthorizationRules_*.json` — ensure invalid JSON / huge file = reject, default-closed. | Service stays up; falls back to safe defaults. | The loader is a privileged code path that runs every poll cycle. A panic here either (a) crashes the agent (DoS), or (b) leaves stale rules in memory (potentially fail-open). Phase 4b's S10 covers the local-rules variant; D4 covers the fabric-delivered rules path. | +| D5 | TOCTOU between rule reload and an in-flight request. | Authorization re-checked per request. | A request that began under permissive rules must not slip through after a deny rule has been published. Confirms the engine snapshots rules per-request and doesn't latch a pre-reload decision — important for incident response when an operator pushes an emergency-deny rule. | + +### E. Key-keeper / provisioning +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| E1 | Permissions/ownership audit on `/var/lib/azure-proxy-agent/keys`, `/var/log/azure-proxy-agent`, the binary, the unit file, drop-ins. | Owned by root, mode 0700/0600 on secrets, no world-write. | Foundational hardening: world-readable key material lets *any* local user steal the signing key (which then needs to be defeated only by B3-style identity binding). World-writable binaries/units allow trivial root escalation on next service restart. Cheap, high-signal check that catches packaging regressions. | +| E2 | Symlink attack: pre-create the keys/log path as a symlink to `/etc/shadow` before service start. | Service refuses to follow / aborts. | Detects unsafe `open()` patterns (no `O_NOFOLLOW`, no `mkdir`-then-`open` race) that would let a non-root attacker who briefly controls `/var/lib` redirect GPA's writes to overwrite arbitrary root-owned files at service start. | +| E3 | Disk-full / read-only `/var` — fault injection. | Service degrades safely; does **not** drop to "open" mode. | Verifies the **fail-closed invariant** under storage failure. If GPA can't persist keys/rules and silently falls back to allowing all traffic, an attacker who can fill `/var` (or trigger a remount-ro) gains unauthenticated fabric access. | +| E4 | Kill `azure-proxy-agent` and observe whether eBPF redirect remains and whether traffic now reaches IMDS unauthenticated. | Either redirect persists denying traffic, or systemd restarts; no "fail-open" window allowing forged calls. | Tests the crash/restart safety window. There must never be a moment where the eBPF redirect is gone *and* the userspace authenticator is absent — that interval would let in-guest attackers reach raw IMDS/WireServer. Also catches eBPF prog-leak bugs (G4 is the count-side check). | +| E5 | Downgrade the latched key (replay an old `keys/*` file). | Rejected by HostGAPlugin / refresh logic. | Prevents key-rollback attacks: a root attacker who keeps a snapshot of an older key must not be able to swap it in to replay/forge requests after the fabric has rotated. Confirms key versioning/monotonicity. | + +### F. Local IPC / status surface +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| F1 | Read `/var/log/azure-proxy-agent/status.json`, `AuthorizationRules_*.json`, `ProxyAgent.Connection.log` as non-root. | If readable, ensure no secrets (key material, signatures) are leaked. | The status/log surface is intentionally observable for diagnostics. The risk is data leakage — if the signing key, raw signatures, or token bodies appear in those files, any local user becomes equivalent to root for fabric AuthN. Confirms the redaction policy is consistently applied. | +| F2 | Log injection — use a process with newline/control chars in cmdline (`prctl(PR_SET_NAME)` or `argv[0]` containing `\n{...fake JSON...}`). | Logs sanitize / escape; cannot forge audit entries. | If an attacker can inject newlines into the audit log, they can fabricate "PASS" entries that hide their real activity, or break SIEM parsers. Tests that all caller-controlled strings are escaped before being written. | +| F3 | Symlink/race on the log directory rotation. | No arbitrary file overwrite as root. | Same threat family as E2 but specific to log rotation. A non-root attacker who can swap the log file with a symlink to `/etc/passwd` between rotation and write would get an arbitrary-file-overwrite as root. | +| F4 | Mode/owner/group audit on `/var/lib/azure-proxy-agent/rules` (the dir and every file under it). | Owned by `root:root`; not world-writable; not group-writable by any non-root group. | Local rules are admin-controlled **authZ INPUT**, not secrets. The risk is integrity, not confidentiality: a non-root attacker who can WRITE a rules file can ship a doc with `useLocalFileRules` content (or, if the BOM/duplicate-key/dangling-ref guards in S11/S12/S18 ever regress, an effectively allow-all doc) and short-circuit remote enforcement contracts at the next refresh. Read perms are intentionally not flagged because the agent persists the merged ruleset to `/var/log/azure-proxy-agent/AuthorizationRules_*.json` anyway. | + +### G. Resource / DoS +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| G1 | Connection flood to `127.0.0.1:3080` from many PIDs. | Throttled; CPU/Memory cap from systemd drop-ins (`50-MemoryMax.conf`) kicks in; service survives. | A local DoS that takes GPA offline is effectively a fail-open enabler if combined with E4 weaknesses (window where redirect is up but authenticator is dead). Also a noisy-neighbor risk on shared VMs. | +| G2 | Large-body POST / very long URL. | Bounded; rejected with 4xx. | Memory-amplification / parser DoS guard. Unbounded body buffering is a common cause of OOM in proxy code; bounding it also mitigates one class of A3-style desyncs. | +| G3 | Many distinct cgroups generating audit entries — exercise `redirector::lookup_audit` map size. | No unbounded growth; LRU/eviction. | The eBPF audit map is finite. Without LRU/eviction, an attacker spawning many short-lived cgroups can fill the map and evict legitimate entries — effectively erasing identity for real callers and forcing a fail-open or fail-closed mode change. | +| G4 | Crash recovery: SIGKILL → systemd restart loop. | Restart succeeds; no orphan eBPF programs each restart (check `bpftool prog show`). | eBPF programs not cleanly detached on shutdown accumulate across restarts — each one keeps a kernel allocation pinned, eventually exhausting `kmemleak`/JIT memory and degrading the host. Also catches double-attach bugs that make the redirect ambiguous. | + +### H. Update / extension handler +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| H1 | `proxy_agent_setup` backup/restore tampering; supply a malicious previous-version archive. | Signature/manifest verification rejects. | The setup tool runs as root and replaces the production binary. If it accepts an unverified rollback archive, a root attacker (or a compromised extension publisher) can persist a backdoored GPA build that still looks legitimate to systemd. Verifies the supply-chain boundary at the agent's own update path. | +| H2 | Extension handler ([handler_main.rs](../proxy_agent_extension/src/handler_main.rs)) command-injection in settings / env. | Sanitized. | Settings come from the Azure control plane and are passed through Linux shell-ish surfaces (env vars, command lines). An unescaped `$(...)` or `;` would let a control-plane-side attacker (or a misconfigured ARM template) execute arbitrary commands as root on the guest — a classic confused-deputy escalation. | + +--- + +## 3. Execution Plan + +### Phase 0 — Prep (read-only, ~30 min) +- Snapshot the VM (or at least `/var/lib/azure-proxy-agent` and `/var/log/azure-proxy-agent`). +- Record baseline: `bpftool prog show`, `bpftool cgroup tree`, `ss -tnlp`, `systemctl cat azure-proxy-agent`, file modes under `/var/lib/azure-proxy-agent`, `/usr/sbin/azure-proxy-agent` hashes. +- Save current `AuthorizationRules_*.json` and `status.json`. + +### Phase 1 — Passive recon (no traffic to fabric) +- Run scenarios A1, E1, F1, F2 prep. +- Static review of [proxy_authorizer.rs](../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs) for the URL/identity matchers you'll exercise in D. + +### Phase 2 — Localhost / listener tests +- A1–A5, G1–G2 against `127.0.0.1:3080`. +- Tools: `nmap`, `curl`, `httpx`, `wrk`, custom Python (`socket`, `h2`, `scapy`). + +### Phase 3 — AuthN/AuthZ functional tests (with fabric) +- B1–B4, C1–C7 from the guest. Use a **non-root** test account, then a **root** account, then a **container** (`docker run --rm -it --network host alpine`) and a **user-namespace** (`unshare -Urn`) variant. +- Capture wire traffic with `tcpdump -i lo -i eth0 -w pen.pcap host 169.254.169.254 or host 168.63.129.16 or port 3080`. +- Verify by checking server response codes (200 vs 401/403) and the GPA `ProxyAgent.Connection.log` decision. + +### Phase 4 — Rule-engine fuzz +- D1–D5. Build a small Rust harness (cargo test inside the repo) plus a black-box URL fuzzer (`radamsa` or `boofuzz`) hitting `:3080` once an allow-rule is in place, then once with a deny-rule, and diff. + +### Phase 4b — Local-file authorization rules (`useLocalFileRules`) + +Automated by [pentest/linux/phase4b_local_rules/run.py](phase4b_local_rules/run.py). + +**Pre-conditions.** Two checks run at harness startup, both fail-fast as `PRE FAIL` so a misconfigured environment doesn't produce a wall of misleading per-scenario failures: + +1. **`useLocalFileRules-true` marker present** — the agent only consumes `/var/lib/azure-proxy-agent/rules/{IMDS,WireServer}_Rules.json` when the rule ID delivered by the fabric (HostGAPlugin) is a base64-encoded JSON whose `useLocalFileRules` field is `true`. The harness verifies this by grepping the latest `AuthorizationRules_*.json` snapshot under `/var/log/azure-proxy-agent` for the literal `useLocalFileRules-true`. +2. **Effective remote `mode` is `Enforce`** — `merge_authorization_item()` in [local_rules.rs](../proxy_agent/src/key_keeper/local_rules.rs) honors `mode` *only* from the remote rule. If the fabric is currently sending `Audit` or `Disabled`, every "expected 403" deny scenario will silently pass through and report a bogus FAIL. The harness parses `computedRules.{imds,wireserver}.mode` from the latest snapshot and aborts with `PRE FAIL` if either is non-Enforce. + +If either pre-condition fails, flip the flag / mode from your control plane (or mock fabric) and rerun. + +**Operating model.** Each scenario: +1. atomically writes a crafted `IMDS_Rules.json` or `WireServer_Rules.json` (mode 0600 root) into the rules dir, +2. waits `pollKeyStatusIntervalInSeconds + 5s` for the agent's next refresh, +3. fires a probe matrix and asserts the GPA-returned status code, +4. moves on to the next scenario; backups of any pre-existing rule files are restored on exit (including on `KeyboardInterrupt` / crash). + +#### IMDS scenarios (target `imds`) + +| ID | Rule shape | Probes & expected status | Why this scenario | +|----|------------|--------------------------|-------------------| +| `IMDS-S1-disabled-allow` | `mode=disabled`, `defaultAccess=allow`, no rules | `/metadata/instance` → 200, `/metadata/identity/oauth2/token` → 200 (control) | **Control / smoke test.** Establishes that the harness, the listener, the eBPF redirect and IMDS itself are all healthy *before* we start denying. If S1 fails, every later "deny worked" result is suspect because we haven't proven we can ever get a 200. | +| `IMDS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | `/metadata/instance` → 403, `/metadata/versions` → 403 | **Default-deny invariant.** With enforce mode and no allow rules, *everything* must be blocked. A FAIL here means the engine fails-open on missing rules — the highest-severity AuthZ regression possible. | +| `IMDS-S3-audit-deny-empty` | `audit` + `deny`, no rules (mode set in LOCAL file) | `/metadata/instance` → **403**, `/metadata/versions` → **403** | **Regression guard for local-`mode` isolation.** `LocalAuthorizationRulesFile` deliberately has no `mode` field, and `merge_authorization_item()` keeps the remote rule's mode. Since PRE has confirmed the live remote mode is `enforce`, writing `mode=audit` locally must be silently dropped and all probes must 403. A 200 here means somebody wired local `mode` into the merge logic — audit-mode pass-through becomes attacker-controllable from any process that can write to the Rules dir. | +| `IMDS-S4-allow-one-path` | `enforce` + `deny`; allow `/metadata/instance` for current identity | instance → 200, token → 403, versions → 403 | **Path-scoping precision.** Allowing one path must not leak access to siblings. Specifically guards against the `oauth2/token` endpoint (which mints managed-identity tokens) being reachable when only `/metadata/instance` was authorized. | +| `IMDS-S5-wrong-identity` | Same allow but bound to non-existent user | instance → 403 | **Identity is required.** A path-only allow must not match if the identity binding fails. Catches engines that short-circuit on path match and forget to AND with identity — a privilege-escalation if any in-guest user can reach an allow-listed path. | +| `IMDS-S6-encoding-bypass` | Allow `/metadata/instance` only | `/metadata/identity/oauth2%2Ftoken`, `%2f`, `%252F`, `%3F`, `./../` → all **403** | **Canonicalization of the request URL.** The classic AuthZ-bypass family from the SSRF/WAF world applied to GPA: percent-encoded slashes, double-encoding, and dot-segments must not let a request "look like" `/metadata/instance` to the matcher while actually reaching `/metadata/identity/oauth2/token`. (Dot-segment probes accept 403 OR 404 — both prove the bypass attempt did not reach a permitted endpoint.) | +| `IMDS-S7-query-param-required` | Privilege requires `api-version=2021-02-01` | matching → 200, missing → 403, wrong → 403 | **Query-parameter matching.** Some allow rules are pinned to a specific API version (e.g. to lock down a known-safe response shape). Verifies the matcher actually inspects the query string and doesn't treat path-only as sufficient. | +| `IMDS-S8-group-only-identity` | Identity match by `groupName` only | instance → 200 | **None == wildcard semantics.** Identity fields that are unset must act as wildcards (match-anything), and at least one set field must AND-match. Guards against accidental over-restriction when operators write a minimal identity (group-only is a common operational pattern). | +| `IMDS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` invocation → 200 | **Per-process identity provider works.** Verifies the kernel-side audit map actually populates `claims.processFullPath` so `exePath` rules are enforceable. If both halves return the same code, identity attribution is broken on this kernel — a regression that would let any process impersonate any other. | +| `IMDS-S10-malformed-json` | `IMDS_Rules.json` is invalid JSON | All probes → 403 (fail-closed) | **Fail-closed on rule-parse error.** A corrupted local rules file must NOT result in "no rules → default-allow" or in the agent crashing. Confirms the loader builds a deny-all sentinel ruleset on parse failure rather than silently reverting to an unrestricted state. | +| `IMDS-S11-utf8-bom` | Valid deny doc written with an explicit UTF-8 BOM (EF BB BF) prefix | All probes → 403 | **BOM-trim regression guard.** `misc_helpers::json_read_from_file` strips a leading BOM before handing bytes to `serde_json::from_str`. A 200 here would mean the BOM-fix regressed, parse failed, and the agent silently fell back to no-rules / allow. | +| `IMDS-S12-duplicate-keys` | Raw JSON with `"defaultAccess":"allow"` then `"defaultAccess":"deny"` in the same object | All probes → 403 | **Duplicate-key last-wins.** Per RFC 8259 / serde_json semantics the second value wins. A 200 would mean either first-wins (silent escalation if a future schema rewrite adds a stricter override) or that the parser raised an error and the agent treated "unparseable" as "no rules → allow". | +| `IMDS-S17-name-collision-shielded` | Local rules whose privilege/role/identity names ALREADY start with `LocalFileRules_` | instance → 200, versions → 403 | **Prefixer idempotency.** `prefix_local_rule_names` must rewrite both definitions AND cross-references consistently even when the admin pre-includes the prefix. 403 on instance would mean the prefixer broke the role assignment (e.g. renamed the definition but not the reference), turning a documented operator pattern into an outage. | +| `IMDS-S18-dangling-references` | Role whose `privileges` array names a privilege that is not defined | All probes → 403 | **Validator coverage.** `validate_local_rules_file` must reject; agent fail-closes. A 200 would mean the validator skipped the dangling-ref check and the role applied with an empty / undefined privilege list — a hint that the matcher's empty-rule semantics could be coerced into allow. | +| `IMDS-S19-identity-homoglyph` | Identity `userName` uses Cyrillic А (U+0410) in place of ASCII A; probe runs as the real ASCII user | All probes → 403 | **Byte-exact identity comparison.** The matcher must NOT apply Unicode normalization to identity strings. A 200 would mean NFC/NFKC normalization is happening somewhere in identity comparison and an attacker who can write the rules file could allow-list a homoglyph that the OS still resolves to the real user — silent privilege escalation. | + +#### WireServer scenarios (target `wireserver`) + +WireServer is privileged-by-default at the proxy layer (`runAsElevated` required), so the harness must run as root. Probes use `x-ms-version: 2012-11-30` and the standard goal-state endpoints: + +``` +/machine/?comp=goalstate +/machine/?comp=hostingenvironmentconfig +/machine/?comp=sharedConfig +/machine/?comp=certificates +/machine/?comp=extensionsConfig +/?comp=versions +``` + +| ID | Rule shape | Probes & expected status | Why this scenario | +|----|------------|--------------------------|-------------------| +| `WS-S1-disabled-allow` | `disabled`, `allow`, no rules | goalstate → 200, versions → 200 (control) | **Control / smoke test for the privileged path.** WireServer requires elevated identity by default, so this also confirms the test process is correctly recognized as root — prerequisite for every later WS scenario. | +| `WS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | goalstate → 403, versions → 403, sharedConfig → 403 | **Default-deny on the high-value target.** WireServer hands out goal-state, certificates and extension config; an empty-rules fail-open here directly leaks VM secrets. Highest-severity gate in the WS suite. | +| `WS-S3-audit-deny-empty` | `audit` + `deny`, no rules (mode set in LOCAL file) | goalstate → **403**, versions → **403** | **Regression guard for local-`mode` isolation on WS** (same purpose as IMDS-S3). The WireServer path is the more sensitive of the two — honoring a local `audit` would silently pass goalstate / certificates / extensionsConfig with deny-but-allow semantics. | +| `WS-S4-allow-goalstate-only` | Allow `/machine/` with `comp=goalstate` only | goalstate → 200; sharedConfig / hostingenvironmentconfig / certificates / extensionsConfig / versions → 403 | **Comp-scoped path matching.** All WS endpoints share the same path (`/machine/`) and differ only by query `comp=`. Confirms the engine's combined path+query matching is precise enough to allow `goalstate` while blocking the much more sensitive `certificates`/`extensionsConfig`. | +| `WS-S5-wrong-identity` | Allow goalstate but bound to non-matching user | goalstate → 403 | **Identity-required on WS.** Same purpose as IMDS-S5 but on the privileged target: an identity mismatch must turn an otherwise-matching path+query rule into a deny. | +| `WS-S6-encoding-bypass` | Allow only `goalstate` | `/machine%2F?comp=certificates`, `/machine/%3Fcomp=certificates`, `./../`, `//machine///`, `comp=Certificates` (case mismatch) → all 403; `comp=GOALSTATE` (case-insensitive value) → 200 (verifies the lowercase normalization is applied symmetrically). | **Canonicalization on WS.** Mirrors IMDS-S6 plus two WS-specific concerns: (1) extra slashes (`//machine///`) must not bypass the `/machine/` prefix check, (2) comp-value comparison must be case-insensitive **on both sides** — if normalization is one-sided, an attacker writes `comp=Certificates` to evade an allow that was authored as `comp=certificates`, or vice versa. | +| `WS-S7-query-param-required` | Require `comp=goalstate` | matching → 200, no `comp` → 403, `comp=hostingenvironmentconfig` → 403, `comp=goalstate&incarnation=1` → 200 | **Required vs extra params.** Required `comp=` must be present, but extra unrelated params (`incarnation`) must NOT cause a false deny — the WALinuxAgent legitimately appends extras. Catches over-strict matchers that compare the whole query string. (The 200 probe accepts 200 OR 400 since WireServer itself may reject the extra param; both prove GPA forwarded.) | +| `WS-S8-group-only-identity` | Identity match by `groupName` only | goalstate → 200 | **Wildcard semantics on WS.** Same purpose as IMDS-S8: confirms the operator-friendly minimal-identity pattern works on the privileged target. | +| `WS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` → 200 | **Per-process identity on WS.** Verifies `exePath` differentiation works for the privileged target — important because operators commonly want to limit WS access to the WALinuxAgent binary specifically, not "any root process". | +| `WS-S10-malformed-json` | `WireServer_Rules.json` is invalid JSON | goalstate → 403, versions → 403 (fail-closed) | **Fail-closed for WS rules.** Same purpose as IMDS-S10; failure here is even more severe because a parse-error fail-open on WS exposes certificates and extension config. | +| `WS-S11-utf8-bom` | Valid deny doc written with an explicit UTF-8 BOM (EF BB BF) prefix | goalstate → 403, versions → 403 | **BOM-trim regression guard on WS.** Same contract as IMDS-S11; consequence of regression is more severe (cert/extension exposure). | +| `WS-S12-duplicate-keys` | Raw JSON with duplicate `defaultAccess` keys (allow then deny) | goalstate → 403, versions → 403 | **Last-wins on WS.** Same contract as IMDS-S12. | +| `WS-S17-name-collision-shielded` | Local rules whose names already start with `LocalFileRules_`, allow-listing `/machine/` | goalstate → 200 | **Prefixer idempotency on WS.** Same contract as IMDS-S17 against the more sensitive target. | +| `WS-S18-dangling-references` | Role with a dangling privilege reference | goalstate → 403, versions → 403 | **Validator coverage on WS.** Same contract as IMDS-S18. | +| `WS-S19-identity-homoglyph` | Identity `userName` substitutes Cyrillic о (U+043E) for ASCII 'o'; probe runs as the real ASCII user | goalstate → 403, versions → 403 | **Byte-exact identity comparison on WS.** Same contract as IMDS-S19. Privilege-escalation impact is direct because WS endpoints expose certificates and extension config. | + +#### Invocation + +```bash +sudo phase4b_local_rules/run.py # both targets, all scenarios +sudo phase4b_local_rules/run.py --target imds # only IMDS matrix +sudo phase4b_local_rules/run.py --target wireserver # only WireServer matrix +sudo phase4b_local_rules/run.py --scenarios IMDS-S6-encoding-bypass,WS-S6-encoding-bypass +sudo phase4b_local_rules/run.py --poll 10 # override refresh wait +``` + +Findings are appended to `pentest/linux/results/findings.tsv`; a per-run console transcript lands in `pentest/linux/results/phase4b_local_rules.log`. + +**Triage rules.** +- Any `*S2`, `*S6`, `*S10` `FAIL` is a high-severity AuthZ-bypass / fail-open finding. +- `*S4`, `*S5`, `*S7` failures indicate identity/path/query matcher regressions. +- `*S9` failures (where `python_caller_denied` returns 200 or `curl_caller_allowed` returns 403) indicate the per-process identity provider is broken on this kernel. + +### Phase 5 — State / FS / persistence +- E1, F1, F4 (read-only audit) → [phase5_state_fs/audit.sh](phase5_state_fs/audit.sh). E1 covers keys/log/binary/unit modes+ownership; F1 grep-scans observable JSON for secret-shaped strings; F4 audits the local-rules dir (admin-only WRITE) since it is privileged authZ INPUT. +- E2–E5, F2–F3, H1–H2 are destructive (symlink races, disk-full, kill-and-watch, key downgrade, log injection) and are intentionally NOT in `audit.sh` — run them manually, and only on a snapshotted VM. + +### Phase 6 — Resilience / DoS +- G1–G4. Watch `systemd-cgtop`, `journalctl -fu azure-proxy-agent`, and `bpftool prog show` for prog leaks across restarts. + +### Phase 7 — Triage & report +- For each finding: PoC, severity (CVSS), affected file/line, suggested fix, whether already mitigated upstream. +- All harness records are appended (TSV) to `pentest/linux/results/findings.tsv`. The HTML report is built by [pentest/linux/generate_report.py](generate_report.py) and written to `pentest/linux/results/report.html`. The report is organized into: + 1. **Run configuration & harness setup** — diagnostic rows (`CFG`, `PRE`) emitted by harnesses (poll interval, current identity, enabled targets, pre-flight checks). Excluded from test counts. + 2. **Failures requiring triage** — only real test FAILs. + 3. **Informational findings (no action required)** — real INFO rows: environment limitations, audit-mode probes, by-design observations, manual-review items. + 4. **Per-phase tables** — full PASS/FAIL/INFO breakdown per phase. + 5. **Phase 4 URL/encoding differential** — last `url_diff.tsv`. + 6. **Cheat sheet & artifacts** — raw paths to pcaps, bpftool snapshots, etc. +- Driver: [pentest/linux/run_all.sh](run_all.sh) runs phases 2 / 3 / 4 / 4b / 5 in order and then renders the HTML report. Phase 4b mutates `/var/lib/azure-proxy-agent/rules/` and can be scoped with `--phase4b-target {both,imds,wireserver}` or skipped with `--skip-phase4b`; the standard cycle is `sudo bash pentest/linux/run_all.sh`. + +--- + +## 4. Tooling staged on this box + +```bash +sudo apt install -y nmap tcpdump bpftool radamsa python3-scapy wrk jq strace +cargo install --locked httpx-cli # optional +``` + +Plus a small workspace under [pentest/](./) (PoCs, pcaps, harness scripts). + diff --git a/pentest/linux/README.md b/pentest/linux/README.md new file mode 100644 index 00000000..c7457dc3 --- /dev/null +++ b/pentest/linux/README.md @@ -0,0 +1,87 @@ +# GPA Pen-Test Harness + +Local pen-test scaffolding for the Guest Proxy Agent running on this VM. + +Mirror of the Windows harness under [../windows/](../windows/), with the +same scenario IDs and the same `findings.tsv` schema; design rationale +lives in [DESIGN.md](DESIGN.md). + +## Layout + +| File | Purpose | +|------|---------| +| [lib/common.sh](lib/common.sh) | Shared helpers: endpoints, paths, `record` (TSV writer), HTTP helpers, root check. | +| [test_catalog.py](test_catalog.py) | Per-test metadata (title / design / automation / repro / fix) consumed by the report. | +| [phase2_listener/run.sh](phase2_listener/run.sh) | A1, A1b, A2, A3, A4 (CONNECT → 405), A5 (open-proxy), G1 (burst). | +| [phase3_authn_authz/run.sh](phase3_authn_authz/run.sh) | P3-cap (tcpdump), B1a, B1b (kernel-side `ss -tnp` proof), B3, B4, C1 (low-IL probe via `nobody`), C5 (`unshare -Cr`), C7 (alt-IP-form parity). | +| [phase4_rules_fuzz/url_diff.py](phase4_rules_fuzz/url_diff.py) | URL / encoding differential (11 variants of the IMDS token URL). | +| [phase4b_local_rules/run.py](phase4b_local_rules/run.py) | Local-file authorization-rules matrix: IMDS S1–S10 + S11/S12/S17/S18/S19 and WS S1–S10 + S11/S12/S17/S18/S19 (30 scenarios). Mutates `/var/lib/azure-proxy-agent/rules/`. | +| [phase5_state_fs/audit.sh](phase5_state_fs/audit.sh) | E1 (keys/log/binary/unit mode+owner), F1 (no secrets in observable JSON), F4 (Rules dir mode+owner — admin-only WRITE), bpftool snapshots. | +| [generate_report.py](generate_report.py) | Renders `results/report.html` from `results/findings.tsv` (+ latest `url_diff.tsv`). | +| [run_all.sh](run_all.sh) | Driver. Runs phases 2 / 3 / 4 / 4b / 5 + report by default. Flags: `--no-truncate`, `--skip-phase{2,3,4,4b,5}`, `--phase4b-target {both,imds,wireserver}`, `--no-report`. | +| `results/` | Populated at runtime: `findings.tsv`, `url_diff.tsv`, `report.html`, per-phase pcaps / bpftool snapshots, `phase4b_local_rules.log`. | + +## Prereqs + +```bash +sudo apt install -y nmap tcpdump bpftool radamsa python3-scapy wrk jq strace curl +``` + +Python ≥ 3.8 (stdlib only — no `pip install` required for the harness or +the report generator). + +The harness assumes: +- `azure-proxy-agent.service` is active. +- Listener is at `127.0.0.1:3080`. +- Fabric endpoints `169.254.169.254:80`, `168.63.129.16:{80,32526}` are reachable. +- For **Phase 4b**: the fabric has delivered an `AuthorizationRules_*.json` + whose decoded JSON contains `useLocalFileRules:true` AND its effective + `mode` is `Enforce` for the chosen target(s). Phase 4b's `PRE` row will + fast-fail if either pre-condition is missing. +- For **Phase 5 F4**: the Rules dir (`/var/lib/azure-proxy-agent/rules`) + exists on disk so its mode/owner can be audited. + +## Run + +```bash +# Default — phases 2, 3, 4, 4b, 5 + HTML report (truncates findings.tsv first): +sudo ./pentest/linux/run_all.sh + +# Append instead of truncating: +sudo ./pentest/linux/run_all.sh --no-truncate + +# Skip the mutating Phase 4b on a production VM: +sudo ./pentest/linux/run_all.sh --skip-phase4b + +# Phase 4b alone, IMDS only: +sudo python3 pentest/linux/phase4b_local_rules/run.py --target imds + +# Re-render the report from the existing findings.tsv: +python3 pentest/linux/generate_report.py +``` + +## Safety + +- Phase 2 sends only loopback traffic. +- Phase 3 sends real requests to the Azure fabric — keep volumes low. +- Phase 4b *writes* to `/var/lib/azure-proxy-agent/rules/` and bounces the + agent's view of the local rule file. Backups of any pre-existing rules + files are restored on exit (including `Ctrl+C` / crash); skip with + `--skip-phase4b` if running on a fabric that doesn't allow local rules. +- Snapshot the VM (or back up `/var/lib/azure-proxy-agent` and + `/var/log/azure-proxy-agent`) before Phase 5. +- Do **not** run Phase 5 fault-injection tests (E2 symlink, E3 disk-full, + E4 kill, E5 key downgrade) on a production VM; `audit.sh` itself is + read-only and safe. + +## Result format + +Each test appends one TSV row to `results/findings.tsv`: + +``` +\t\t\t +``` + +A `FAIL` means the GPA invariant was violated (potential finding) and +warrants triage. Designs, repro steps, and suggested fixes are inlined +per row in the HTML report. diff --git a/pentest/linux/generate_report.py b/pentest/linux/generate_report.py new file mode 100644 index 00000000..11a8802b --- /dev/null +++ b/pentest/linux/generate_report.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""Generate an HTML pen-test report from pentest/linux/results/findings.tsv. + +Each test row in the report includes inline design notes, how it's automated, +how to reproduce it (script + manual), and — for FAILed rows — the suggested +fix. Metadata is sourced from test_catalog.py. +""" +from __future__ import annotations + +import datetime as dt +import html +import platform +import re +import socket +import sys +from collections import Counter, defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +RESULTS = ROOT / "results" +FINDINGS = RESULTS / "findings.tsv" +URL_DIFF = RESULTS / "url_diff.tsv" +RUN_LOG = RESULTS / "run.log" +PHASE4B_LOG = RESULTS / "phase4b_local_rules.log" +OUTPUT = RESULTS / "report.html" + +sys.path.insert(0, str(ROOT)) +from test_catalog import lookup as catalog_lookup # noqa: E402 + +PHASE_PATTERNS = [ + ("Phase 2 — Listener / DoS (A,G)", re.compile(r"^(A\d|G\d)")), + ("Phase 3 — AuthN / AuthZ (B,C,P3)", re.compile(r"^(B\d|C\d|P3)")), + ("Phase 4b — Local-file rules (IMDS)", re.compile(r"^IMDS-S")), + ("Phase 4b — Local-file rules (WireServer)", re.compile(r"^WS-S")), + ("Phase 4b — pre-flight", re.compile(r"^PRE\b")), + ("Phase 5 — Filesystem / state (E,F,P5)", re.compile(r"^(E\d|F\d|P5)")), +] + +SEVERITY = { + "FAIL": ("fail", "High"), + "PASS": ("pass", "—"), + "INFO": ("info", "Info"), +} + + +def phase_for(tid: str) -> str: + for name, rx in PHASE_PATTERNS: + if rx.match(tid): + return name + return "Other" + + +def load_findings() -> list[dict]: + rows: list[dict] = [] + if not FINDINGS.exists(): + return rows + for line in FINDINGS.read_text().splitlines(): + if not line: + continue + parts = line.split("\t", 3) + while len(parts) < 4: + parts.append("") + ts, tid, status, msg = parts + rows.append({"ts": ts, "id": tid, "status": status.upper(), + "msg": msg, "phase": phase_for(tid)}) + return rows + + +def load_url_diff_latest() -> list[list[str]]: + if not URL_DIFF.exists(): + return [] + rows = [ln.split("\t") for ln in URL_DIFF.read_text().splitlines() if ln] + if not rows: + return rows + last_ts = rows[-1][0] + return [r for r in rows if r[0] == last_ts] + + +def details_html(row: dict) -> str: + info = catalog_lookup(row["id"]) + if info is None: + return "no catalog entry for this ID" + + def section(label: str, body: str, mono: bool = False) -> str: + cls = "mono" if mono else "" + return (f"
{label}
" + f"
{html.escape(body)}
") + + parts: list[str] = [] + if info.get("title"): + parts.append(f"
{html.escape(info['title'])}
") + if info.get("design"): + parts.append(section("Design", info["design"])) + if info.get("automation"): + parts.append(section("Automation", info["automation"])) + if info.get("repro_script"): + parts.append(section("Repro (script)", info["repro_script"], mono=True)) + if info.get("repro_manual"): + parts.append(section("Repro (manual)", info["repro_manual"], mono=True)) + if row["status"] == "FAIL" and info.get("fix"): + parts.append( + "
Suggested fix
" + f"
{html.escape(info['fix'])}
" + ) + return "".join(parts) + + +def row_html(r: dict, *, with_phase_col: bool) -> str: + cls, sev = SEVERITY.get(r["status"], ("info", "Info")) + badge_text = sev if r["status"] == "FAIL" and with_phase_col else r["status"] + open_attr = " open" if r["status"] == "FAIL" else "" + cells = [ + f"{html.escape(r['ts'])}", + f"{html.escape(r['id'])}", + ] + if with_phase_col: + cells.append(f"{html.escape(r['phase'])}") + cells.append(f"{html.escape(badge_text)}") + cells.append( + "" + f"
{html.escape(r['msg'])}
" + f"
details" + f"
{details_html(r)}
" + "" + ) + return "" + "".join(cells) + "" + + +CSS = """ +:root { color-scheme: light dark; } +* { box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin: 0; padding: 24px; background: #f6f8fa; color: #24292f; } +h1 { margin: 0 0 4px 0; font-size: 24px; } +.sub { color: #57606a; margin-bottom: 24px; font-size: 13px; } +.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 24px; } +.card { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 16px 20px; flex: 1 1 140px; } +.card .num { font-size: 28px; font-weight: 700; } +.card .lbl { font-size: 12px; color: #57606a; text-transform: uppercase; letter-spacing: .04em; } +.card.pass .num { color: #1a7f37; } +.card.fail .num { color: #cf222e; } +.card.info .num { color: #9a6700; } +section { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; margin-bottom: 20px; } +section > h2 { margin: 0; padding: 12px 16px; border-bottom: 1px solid #d0d7de; font-size: 16px; + background: #f6f8fa; border-radius: 8px 8px 0 0; } +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eaeef2; vertical-align: top; } +th { background: #f6f8fa; font-weight: 600; color: #57606a; } +tr:last-child > td { border-bottom: none; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; } +.badge.pass { background: #dafbe1; color: #116329; } +.badge.fail { background: #ffebe9; color: #82071e; } +.badge.info { background: #fff8c5; color: #7d4e00; } +.id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.msg { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; margin-bottom: 4px; } +.ts { color: #57606a; white-space: nowrap; font-variant-numeric: tabular-nums; } +details > summary { cursor: pointer; color: #0969da; font-size: 12px; list-style: none; } +details > summary::-webkit-details-marker { display: none; } +details > summary::before { content: '▸ '; transition: transform .15s; } +details[open] > summary::before { content: '▾ '; } +.detbox { display: flex; flex-direction: column; gap: 6px; padding: 8px 0 4px 0; } +.dettitle { font-weight: 600; color: #24292f; } +.det { display: flex; gap: 10px; flex-wrap: wrap; } +.detlbl { flex: 0 0 110px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #57606a; padding-top: 2px; } +.detval { flex: 1 1 320px; font-size: 12px; } +.detval.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; + background: #f6f8fa; border: 1px solid #eaeef2; border-radius: 4px; padding: 6px 8px; } +.det.fix .detlbl { color: #cf222e; } +.det.fix .detval { background: #ffebe9; border: 1px solid #ffcecb; border-radius: 4px; padding: 6px 8px; color: #82071e; white-space: pre-wrap; } +.nodet { color: #8c959f; font-size: 12px; font-style: italic; } +.legend { font-size: 12px; color: #57606a; margin-bottom: 12px; } +.footer { color: #57606a; font-size: 12px; margin-top: 24px; text-align: center; } +.meta { font-size: 12px; color: #57606a; padding: 8px 16px 0; } +.toolbar { display: flex; gap: 8px; align-items: center; padding: 6px 16px; border-bottom: 1px solid #eaeef2; + background: #fafbfc; font-size: 12px; } +.toolbar button { font: inherit; padding: 3px 10px; border: 1px solid #d0d7de; border-radius: 6px; + background: #fff; color: #24292f; cursor: pointer; } +.toolbar button:hover { background: #eef1f4; } +@media (prefers-color-scheme: dark) { + body { background: #0d1117; color: #c9d1d9; } + .card, section { background: #161b22; border-color: #30363d; } + section > h2, th, .toolbar { background: #161b22; color: #8b949e; border-color: #30363d; } + td { border-color: #21262d; } + .badge.pass { background: #033a16; color: #56d364; } + .badge.fail { background: #3c0a13; color: #ff7b72; } + .badge.info { background: #3a2d04; color: #e3b341; } + details > summary { color: #58a6ff; } + .detval.mono { background: #0d1117; border-color: #30363d; } + .det.fix .detval { background: #3c0a13; border-color: #5a1018; color: #ff7b72; } + .toolbar button { background: #21262d; color: #c9d1d9; border-color: #30363d; } + .toolbar button:hover { background: #30363d; } +} +""" + +JS = """ +function expandIn(el, open){ el.querySelectorAll('details.row').forEach(d => d.open = open); } +""" + + +def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: + # Setup/diagnostic rows emitted by harnesses (e.g. phase4b's CFG/PRE). + # They are not pen-test cases — surface them in their own "Run + # configuration" section and exclude from the test counts. + SETUP_IDS = {"CFG", "PRE"} + setup_rows = [r for r in findings if r["id"] in SETUP_IDS] + findings = [r for r in findings if r["id"] not in SETUP_IDS] + + total = len(findings) + counts = Counter(r["status"] for r in findings) + n_pass, n_fail, n_info = counts.get("PASS", 0), counts.get("FAIL", 0), counts.get("INFO", 0) + + by_phase: dict[str, list[dict]] = defaultdict(list) + for r in findings: + by_phase[r["phase"]].append(r) + + phase_order = [name for name, _ in PHASE_PATTERNS] + ["Other"] + phase_order = [p for p in phase_order if p in by_phase] + fail_rows = [r for r in findings if r["status"] == "FAIL"] + info_rows = [r for r in findings if r["status"] == "INFO"] + + generated = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + host = socket.gethostname() + osinfo = f"{platform.system()} {platform.release()} ({platform.machine()})" + + p: list[str] = [] + p.append("") + p.append("") + p.append("GPA Pen-Test Report") + p.append(f"") + p.append("

Guest Proxy Agent — Pen-Test Report

") + p.append( + f"
Generated {generated} · host {html.escape(host)} · " + f"{html.escape(osinfo)} · " + "design source: pentest/linux/DESIGN.md · catalog: pentest/linux/test_catalog.py
" + ) + + p.append("
") + p.append(f"
{total}
Total tests
") + p.append(f"
{n_pass}
Pass
") + p.append(f"
{n_fail}
Fail
") + p.append(f"
{n_info}
Info
") + p.append("
") + + p.append("
Each row is expandable — click details for " + "design, automation, manual repro, and (when failed) the suggested fix. " + "FAIL rows are auto-expanded.
") + + # Run configuration / harness setup section (not part of test results). + p.append("

Run configuration & harness setup

") + p.append("
" + "Diagnostic rows emitted by the test harnesses themselves (poll " + "interval, current identity, enabled targets, pre-flight checks). " + "They describe how the suite was run and are not pen-test " + "cases, so they are excluded from the counts above.
") + if not setup_rows: + p.append("
No setup records.
") + else: + p.append("" + "" + "" + "") + for r in setup_rows: + badge = SEVERITY.get(r["status"], ("info", "Info"))[0] + p.append( + f"" + f"" + f"" + f"" + ) + p.append("
TimeIDStatusDetail
{html.escape(r['ts'])}{html.escape(r['id'])}{html.escape(r['status'])}
{html.escape(r['msg'])}
") + p.append("
") + + # Failures section + p.append("

Failures requiring triage

") + p.append("
" + "" + "" + "
") + if not fail_rows: + p.append("
No FAIL entries — all invariants held.
") + else: + p.append("" + "" + "") + for r in fail_rows: + p.append(row_html(r, with_phase_col=True)) + p.append("
TimeTest IDPhaseSeverityDetail
") + p.append("
") + + # Informational findings section (between Failures and Per-phase) + p.append("

Informational findings (no action required)

") + p.append("
" + "" + "" + "
") + if not info_rows: + p.append("
No INFO entries.
") + else: + p.append("
" + "INFO rows record observations that are not failures: environment " + "limitations (e.g. unshare unavailable), audit-mode probes, scenarios " + "that require manual review, or by-design behavior that is logged for " + "the record.
") + p.append("" + "" + "") + for r in info_rows: + p.append(row_html(r, with_phase_col=True)) + p.append("
TimeTest IDPhaseSeverityDetail
") + p.append("
") + + # Per-phase + for phase in phase_order: + rows = by_phase[phase] + c = Counter(r["status"] for r in rows) + p.append("
") + p.append( + f"

{html.escape(phase)} " + f"" + f"{c.get('PASS',0)} pass · {c.get('FAIL',0)} fail · {c.get('INFO',0)} info" + f"

" + ) + p.append("
" + "" + "" + "
") + p.append("" + "" + "") + for r in rows: + p.append(row_html(r, with_phase_col=False)) + p.append("
TimeTest IDStatusDetail
") + + # URL diff appendix + if url_rows: + p.append("

Phase 4 — URL/encoding differential (latest run)

") + p.append("
" + "Compares response codes for canonical vs encoded variants of the same path. " + "DIFF rows warrant review.
" + "Repro (script): python3 pentest/linux/phase4_rules_fuzz/url_diff.py
" + "Repro (manual): " + "curl -sS -o /dev/null -w '%{http_code}\\n' -H 'Metadata: true' 'http://169.254.169.254<variant-path>'" + "
") + p.append("" + "" + "") + for r in url_rows: + ts = r[0] if len(r) > 0 else "" + variant = r[1] if len(r) > 1 else "" + status = r[2] if len(r) > 2 else "" + verdict = r[3] if len(r) > 3 else "" + path = r[4] if len(r) > 4 else "" + cls = "fail" if verdict.strip().upper() == "DIFF" else "pass" + p.append("" + f"" + f"" + f"" + f"" + f"" + "") + p.append("
TimeVariantStatusVerdictPath
{html.escape(ts)}{html.escape(variant)}{html.escape(status)}{html.escape(verdict)}{html.escape(path)}
") + + # Repro cheat-sheet + p.append("

How to reproduce — cheat sheet

") + p.append("

Run from the workspace root:

") + p.append("
"
+             "# All safe phases (2, 3, 4, 5):\n"
+             "bash pentest/linux/run_all.sh\n\n"
+             "# Individual phases:\n"
+             "bash    pentest/linux/phase2_listener/run.sh           # A1–A5, G1\n"
+             "bash    pentest/linux/phase3_authn_authz/run.sh        # B1, B3, B4, C1, C5, C7 (+ tcpdump)\n"
+             "python3 pentest/linux/phase4_rules_fuzz/url_diff.py    # URL encoding differential\n"
+             "bash    pentest/linux/phase5_state_fs/audit.sh         # E1, F1 (+ bpftool snapshots)\n\n"
+             "# Phase 4b — local-file rules (needs root + useLocalFileRules=true on fabric):\n"
+             "sudo python3 pentest/linux/phase4b_local_rules/run.py\n"
+             "sudo python3 pentest/linux/phase4b_local_rules/run.py --target imds\n"
+             "sudo python3 pentest/linux/phase4b_local_rules/run.py --scenarios IMDS-S6-encoding-bypass,WS-S6-encoding-bypass\n\n"
+             "# Regenerate this report:\n"
+             "python3 pentest/linux/generate_report.py"
+             "
") + p.append("
") + + # Artifacts + p.append("

Artifacts

    ") + for path in [FINDINGS, URL_DIFF, RUN_LOG, PHASE4B_LOG]: + if path.exists(): + p.append(f"
  • {html.escape(str(path))} ({path.stat().st_size} bytes)
  • ") + for path in sorted(RESULTS.glob("phase3-*.pcap")): + p.append(f"
  • {html.escape(str(path))} ({path.stat().st_size} bytes)
  • ") + for path in sorted(RESULTS.glob("bpftool_*.txt")): + p.append(f"
  • {html.escape(str(path))} ({path.stat().st_size} bytes)
  • ") + p.append("
") + + p.append("") + p.append("") + return "".join(p) + + +def main() -> int: + findings = load_findings() + url_rows = load_url_diff_latest() + if not findings: + print("No findings to report.", file=sys.stderr) + return 1 + OUTPUT.write_text(build_html(findings, url_rows), encoding="utf-8") + SETUP_IDS = {"CFG", "PRE"} + test_rows = [r for r in findings if r["id"] not in SETUP_IDS] + setup_n = len(findings) - len(test_rows) + counts = Counter(r["status"] for r in test_rows) + print(f"Wrote {OUTPUT} ({len(test_rows)} test entries: " + f"{counts.get('PASS',0)} pass, {counts.get('FAIL',0)} fail, " + f"{counts.get('INFO',0)} info; {setup_n} setup rows)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pentest/linux/lib/common.sh b/pentest/linux/lib/common.sh new file mode 100644 index 00000000..83cb7f6c --- /dev/null +++ b/pentest/linux/lib/common.sh @@ -0,0 +1,72 @@ +# Shared helpers for GPA pen-test scripts. Source, don't execute. +# shellcheck shell=bash + +set -uo pipefail + +PENTEST_ROOT="${PENTEST_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +RESULTS_DIR="${RESULTS_DIR:-$PENTEST_ROOT/results}" +mkdir -p "$RESULTS_DIR" +FINDINGS="$RESULTS_DIR/findings.tsv" + +# Endpoints +GPA_HOST="127.0.0.1" +GPA_PORT="3080" +IMDS_IP="169.254.169.254" +IMDS_PORT="80" +WIRESERVER_IP="168.63.129.16" +WIRESERVER_PORT="80" +HOSTGA_IP="168.63.129.16" +HOSTGA_PORT="32526" + +GPA_LOG_DIR="/var/log/azure-proxy-agent" +GPA_KEY_DIR="/var/lib/azure-proxy-agent/keys" +GPA_RULES_DIR="/var/lib/azure-proxy-agent/rules" + +color() { # $1=color $2=text + case "$1" in + red) printf '\033[31m%s\033[0m' "$2";; + green) printf '\033[32m%s\033[0m' "$2";; + yellow) printf '\033[33m%s\033[0m' "$2";; + *) printf '%s' "$2";; + esac +} + +ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } + +# record TEST_ID STATUS MSG +record() { + local id="$1" status="$2" msg="${3:-}" + printf '%s\t%s\t%s\t%s\n' "$(ts)" "$id" "$status" "$msg" >> "$FINDINGS" + case "$status" in + PASS) echo "[$(color green PASS)] $id $msg" ;; + FAIL) echo "[$(color red FAIL)] $id $msg" ;; + INFO) echo "[$(color yellow INFO)] $id $msg" ;; + *) echo "[$status] $id $msg" ;; + esac +} + +# require_cmd cmd... +require_cmd() { + local missing=() + for c in "$@"; do command -v "$c" >/dev/null 2>&1 || missing+=("$c"); done + if (( ${#missing[@]} )); then + echo "Missing commands: ${missing[*]}" >&2 + return 1 + fi +} + +gpa_running() { + systemctl is-active --quiet azure-proxy-agent +} + +# curl_via_gpa METHOD URL [extra curl args...] +# Sends a request to a fabric host through the GPA-redirected path (no -x; rely on eBPF). +curl_via_gpa() { + local method="$1" url="$2"; shift 2 + curl -sS -o /dev/null -w '%{http_code}' -X "$method" "$url" --max-time 10 "$@" +} + +# tcp_open HOST PORT -> 0 if connection accepted within 2s +tcp_open() { + timeout 2 bash -c ">/dev/tcp/$1/$2" 2>/dev/null +} diff --git a/pentest/linux/phase2_listener/run.sh b/pentest/linux/phase2_listener/run.sh new file mode 100755 index 00000000..d1ea445b --- /dev/null +++ b/pentest/linux/phase2_listener/run.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Phase 2 — listener / DoS / smuggling probes (Scenarios A & light G). +# Loopback only. Safe to run repeatedly. + +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/../lib/common.sh" + +require_cmd curl ss nc python3 || exit 1 +gpa_running || { record A0 FAIL "azure-proxy-agent is not active"; exit 1; } + +# A1 — only 127.0.0.1:3080 should be open; nothing on external IFs +external_ifs=$(ip -o -4 addr show scope global | awk '{print $4}' | cut -d/ -f1) +ext_listen=$(ss -tnlH | awk '{print $4}' | grep -E ":3080$" | grep -v '^127\.' || true) +if [[ -z "$ext_listen" ]]; then + record A1 PASS "3080 only bound to loopback (external IFs: ${external_ifs//$'\n'/ })" +else + record A1 FAIL "3080 bound on non-loopback: $ext_listen" +fi + +# A1b — confirm connect on loopback +if tcp_open 127.0.0.1 3080; then + record A1b PASS "loopback connect to 3080 OK" +else + record A1b FAIL "cannot connect to 127.0.0.1:3080" +fi + +# A1c — probe one external IP if we have one +first_ext=$(echo "$external_ifs" | head -n1) +if [[ -n "$first_ext" ]]; then + if tcp_open "$first_ext" 3080; then + record A1c FAIL "3080 reachable on external IP $first_ext" + else + record A1c PASS "3080 not reachable on external IP $first_ext" + fi +fi + +# A3 — malformed HTTP requests; service must stay up +pid_before=$(pidof azure-proxy-agent || true) +{ + printf 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' | timeout 3 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 + printf 'INVALIDMETHOD / HTTP/1.1\r\n\r\n' | timeout 3 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 + printf 'GET / HTTP/9.9\r\n\r\n' | timeout 3 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 + printf 'GET /%s HTTP/1.1\r\nHost: x\r\n\r\n' "$(python3 -c 'print("A"*65000)')" | timeout 3 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 + # incomplete header (slow-loris-lite) + ( printf 'GET / HTTP/1.1\r\nHost: x\r\n'; sleep 2 ) | timeout 5 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 +} || true +pid_after=$(pidof azure-proxy-agent || true) +if [[ -n "$pid_after" && "$pid_before" == "$pid_after" ]]; then + record A3 PASS "service survived malformed requests (pid=$pid_after)" +else + record A3 FAIL "service pid changed ($pid_before -> $pid_after) after malformed requests" +fi + +# A4 — CONNECT method should not turn GPA into a generic tunnel. +# Use curl (more reliable than nc, which races against the server's +# `Connection: close` FIN and frequently returns empty output). +code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \ + -X CONNECT --request-target 'example.com:443' \ + http://127.0.0.1:3080 2>/dev/null || true) +case "$code" in + ""|2*) record A4 FAIL "CONNECT not refused (status='$code')" ;; + *) record A4 PASS "CONNECT refused (status='$code')" ;; +esac + +# A5 — open-proxy: explicit-proxy to an arbitrary external host +code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \ + -x http://127.0.0.1:3080 http://example.com/ || echo 000) +case "$code" in + 2*) record A5 FAIL "GPA acted as open proxy to example.com (status=$code)" ;; + *) record A5 PASS "explicit-proxy to example.com refused (status=$code)" ;; +esac + +# G1 (light) — connection burst; just check service still up afterwards +pid_before=$(pidof azure-proxy-agent || true) +for i in $(seq 1 200); do + ( exec 3<>/dev/tcp/127.0.0.1/3080; echo -e "GET / HTTP/1.0\r\n\r" >&3; cat <&3 >/dev/null; exec 3<&-; ) & +done +wait 2>/dev/null +pid_after=$(pidof azure-proxy-agent || true) +if [[ -n "$pid_after" && "$pid_before" == "$pid_after" ]]; then + record G1 PASS "survived 200-conn burst (pid=$pid_after)" +else + record G1 FAIL "service pid changed after burst ($pid_before -> $pid_after)" +fi + +echo +echo "Phase 2 complete. See $FINDINGS" diff --git a/pentest/linux/phase3_authn_authz/run.sh b/pentest/linux/phase3_authn_authz/run.sh new file mode 100755 index 00000000..d975a5fd --- /dev/null +++ b/pentest/linux/phase3_authn_authz/run.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Phase 3 — AuthN/AuthZ matrix (Scenarios B, C). Sends real requests to fabric. +# Keep request volumes small. + +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/../lib/common.sh" + +require_cmd curl tcpdump || exit 1 +gpa_running || { record C0 FAIL "azure-proxy-agent is not active"; exit 1; } + +PCAP="$RESULTS_DIR/phase3-$(date -u +%Y%m%dT%H%M%SZ).pcap" +SUDO="" +if [[ $EUID -ne 0 ]]; then SUDO="sudo"; fi + +# Start tcpdump if we can (best-effort) +TCPDUMP_PID="" +if command -v tcpdump >/dev/null && $SUDO -n true 2>/dev/null; then + $SUDO tcpdump -i any -U -w "$PCAP" \ + "host $IMDS_IP or host $WIRESERVER_IP or (host 127.0.0.1 and port $GPA_PORT)" \ + >/dev/null 2>&1 & + TCPDUMP_PID=$! + sleep 1 + record P3-cap INFO "capturing to $PCAP (pid=$TCPDUMP_PID)" +else + record P3-cap INFO "tcpdump not started (need passwordless sudo); proceeding without pcap" +fi + +cleanup() { + if [[ -n "$TCPDUMP_PID" ]]; then $SUDO kill "$TCPDUMP_PID" 2>/dev/null || true; fi + [[ -n "${imds_body:-}" ]] && rm -f "$imds_body" +} +trap cleanup EXIT + +# Helper: a baseline IMDS call should succeed for any user (default policy) +imds_url="http://$IMDS_IP/metadata/instance?api-version=2021-02-01" +imds_hdr="Metadata: true" + +# Per-run body cache. Using mktemp avoids the fs.protected_regular=2 trap on +# Ubuntu, where root cannot overwrite a /tmp file owned by a different user +# (curl would print "Failure writing output to destination" and exit 23, +# making B1a a false positive on subsequent runs). +imds_body=$(mktemp -t gpa_imds_body.XXXXXX) + +# B1a — IMDS through redirected path: should succeed and GPA should inject signature +code=$(curl -sS -o "$imds_body" -w '%{http_code}' --max-time 8 -H "$imds_hdr" "$imds_url" || echo 000) +if [[ "$code" == "200" ]]; then + record B1a PASS "IMDS reachable through GPA-redirected path (200)" +else + record B1a FAIL "IMDS call failed code=$code" +fi +# Canonical response captured for C7 parity comparison below. +canonical_code="$code" +canonical_sha=$(sha256sum "$imds_body" 2>/dev/null | awk '{print $1}') + +# B1b — Confirm the kernel sees our IMDS connection as redirected to 127.0.0.1:3080 +# (i.e. the cgroup-attached eBPF redirect is in effect for THIS shell's cgroup). +# Open the FD in the *current* shell — a subshell would close it before `ss` runs. +if command -v ss >/dev/null; then + if exec 3<>/dev/tcp/$IMDS_IP/80 2>/dev/null; then + # ss may not match by pid on all kernels; fall back to matching the local + # ephemeral port from /proc/self/net/tcp via lsof or /proc parsing. + peer=$(ss -tnp 2>/dev/null | awk -v pid=$$ '$0 ~ "pid="pid {print; exit}') + if [[ -z "$peer" ]]; then + # Fallback: any ESTAB socket from this process to :3080 or :80 + peer=$(ss -tn 2>/dev/null | awk '/ESTAB/ && ($5 ~ /:80$/ || $5 ~ /:3080$/) {print; exit}') + fi + exec 3<&- + if [[ -z "$peer" ]]; then + record B1b INFO "ss snapshot of own connection: (kernel may not expose pid; rerun with sudo for full visibility)" + elif [[ "$peer" == *":3080"* || "$peer" == *"127.0.0.1"* ]]; then + record B1b PASS "kernel reports our IMDS connection landed on the GPA listener (eBPF redirect active): $peer" + elif [[ "$peer" == *"$IMDS_IP"* ]]; then + record B1b FAIL "kernel reports our IMDS connection went DIRECTLY to $IMDS_IP — eBPF redirect bypassed: $peer" + else + record B1b INFO "ss snapshot of own connection: $peer (manual review)" + fi + else + record B1b INFO "could not open /dev/tcp/$IMDS_IP/80 (no route or blocked)" + fi +fi + +# B4 — Submit a forged signature header through the proxy. GPA should overwrite/ignore it. +code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 \ + -H "$imds_hdr" \ + -H "x-ms-azure-signature: AAAA-attacker-supplied" \ + -H "x-ms-azure-time-tick: 0" \ + "$imds_url" || echo 000) +if [[ "$code" == "200" ]]; then + record B4 PASS "request with attacker-supplied signature still succeeded (GPA overwrote it; verify in pcap)" +else + record B4 INFO "request with forged signature got code=$code (manual review)" +fi + +# B3 — Key files should not be readable by unprivileged users +if [[ -d "$GPA_KEY_DIR" ]]; then + bad=$(find "$GPA_KEY_DIR" -type f \( -perm -o+r -o -perm -g+r \) 2>/dev/null || true) + if [[ -z "$bad" ]]; then + record B3 PASS "key files in $GPA_KEY_DIR are not group/world-readable" + else + record B3 FAIL "key files readable by group/other: $bad" + fi +else + record B3 INFO "key dir $GPA_KEY_DIR not present" +fi + +# C1 — non-elevated user calling WireServer must be Forbidden +ws_url="http://$WIRESERVER_IP/machine/?comp=goalstate" +if id nobody >/dev/null 2>&1 && command -v sudo >/dev/null; then + code=$($SUDO -n -u nobody curl -sS -o /dev/null -w '%{http_code}' --max-time 8 \ + -H "x-ms-version: 2012-11-30" "$ws_url" 2>/dev/null || echo 000) + case "$code" in + 403|401) record C1 PASS "non-elevated WireServer call denied ($code)" ;; + 200) record C1 FAIL "non-elevated WireServer call SUCCEEDED ($code) — AuthZ bypass" ;; + *) record C1 INFO "non-elevated WireServer call code=$code (inconclusive)" ;; + esac +else + record C1 INFO "skipped (need sudo + nobody account)" +fi + +# C7 — alternate IP forms (hex, decimal) must not bypass the eBPF redirect / +# AuthZ. Compare each alt's HTTP status AND response-body sha256 against the +# canonical form captured above. Identical → eBPF treated them the same → +# PASS. Different → potential SSRF-filter bypass at the network layer → FAIL. +for alt in "http://0xa9fea9fe/metadata/instance?api-version=2021-02-01" \ + "http://2852039166/metadata/instance?api-version=2021-02-01"; do + # mktemp avoids a symlink race: this harness runs as root, and a predictable + # /tmp/.gpa_c7_$$.body path would let an unprivileged attacker pre-create a + # symlink that curl -o would follow and overwrite as root (the exact class + # of bug DESIGN.md scenarios E2/F3 exercise in GPA itself). + alt_body=$(mktemp -t gpa_c7_body.XXXXXX) + alt_code=$(curl -sS -o "$alt_body" -w '%{http_code}' --max-time 6 -H "$imds_hdr" "$alt" || echo 000) + alt_sha=$(sha256sum "$alt_body" 2>/dev/null | awk '{print $1}') + rm -f "$alt_body" + if [[ -z "$canonical_code" ]]; then + record "C7[$alt]" INFO "code=$alt_code (no canonical baseline; manual diff)" + elif [[ "$alt_code" == "$canonical_code" && "$alt_sha" == "$canonical_sha" ]]; then + record "C7[$alt]" PASS "alt IP form parity with canonical (code=$alt_code, body sha matches)" + elif [[ "$alt_code" == "$canonical_code" ]]; then + # Same status, different body — could be a per-request token in IMDS reply; + # treat as INFO so the operator can diff manually rather than FAILing. + record "C7[$alt]" INFO "same code=$alt_code as canonical but body sha differs (canonical=$canonical_sha alt=$alt_sha) — manual diff" + elif [[ "$alt_code" == "000" || "$alt_code" =~ ^4 ]]; then + record "C7[$alt]" PASS "alt IP form refused (code=$alt_code) — no bypass" + else + record "C7[$alt]" FAIL "alt IP form returned code=$alt_code, canonical=$canonical_code — possible eBPF/SSRF-filter bypass" + fi +done + +# C5 — cgroup-namespace evasion attempt (best-effort, requires unshare). +# `unshare -Cr` needs CAP_SYS_ADMIN in the new user namespace; if invoked as +# an unprivileged user on a kernel that disallows it, unshare(1) fails before +# curl ever runs and we see exit code 0/empty output → mis-attributed as FAIL. +# Prefix with sudo -n when not root and passwordless sudo is available; if +# unshare itself cannot run, record INFO (environment limitation), not FAIL. +if command -v unshare >/dev/null; then + unshare_pfx=() + if [[ $EUID -ne 0 ]] && command -v sudo >/dev/null && sudo -n true 2>/dev/null; then + unshare_pfx=(sudo -n) + fi + # Probe whether unshare -Cr is usable in this environment. + if ! "${unshare_pfx[@]}" unshare -Cr -- true 2>/dev/null; then + record C5 INFO "unshare -Cr unavailable in this environment (needs CAP_SYS_ADMIN); re-run with sudo to exercise" + else + code=$("${unshare_pfx[@]}" unshare -Cr -- curl -sS -o /dev/null -w '%{http_code}' --max-time 6 \ + -H "$imds_hdr" "$imds_url" 2>/dev/null || echo 000) + case "$code" in + 200) record C5 PASS "IMDS still reachable from new cgroup ns (redirect intact)" ;; + 000) record C5 FAIL "IMDS unreachable from new cgroup ns — possible eBPF gap (or denied; verify)" ;; + *) record C5 INFO "code=$code from new cgroup ns (manual review)" ;; + esac + fi +fi + +echo +echo "Phase 3 complete. See $FINDINGS" +[[ -f "$PCAP" ]] && echo "Pcap: $PCAP" diff --git a/pentest/linux/phase4_rules_fuzz/url_diff.py b/pentest/linux/phase4_rules_fuzz/url_diff.py new file mode 100755 index 00000000..5ad3c92a --- /dev/null +++ b/pentest/linux/phase4_rules_fuzz/url_diff.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Phase 4 — URL/encoding differentials against the rule engine. + +Sends a matrix of equivalent URLs (different encodings of the same path) to the +GPA-redirected IMDS endpoint and records the response code GPA hands back. +A correctly-implemented rule engine should produce the same authorization +decision for every variant; differences are potential bypasses. + +Usage: + python3 url_diff.py [--out results.tsv] + +Loopback fabric only; safe to run from a normal user account. +""" +from __future__ import annotations +import argparse, datetime, http.client, os, sys, urllib.parse + +IMDS_HOST = "169.254.169.254" +IMDS_PORT = 80 +HEADERS = {"Metadata": "true"} + +CANONICAL = "/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" + +VARIANTS = { + "canonical": CANONICAL, + "uppercase_pct": "/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/", + "lowercase_pct": "/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/", + "double_encoded": "/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/", + "trailing_dot_path": "/metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/", + "dot_segments": "/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", + "param_injection": "/metadata/identity/oauth2/token;x=1?api-version=2018-02-01&resource=https://management.azure.com/", + "case_path": "/Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/", + "extra_slashes": "//metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/", + "fragment": "/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/#x", + "unicode_dotless": "/metadata/\u0131dentity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", +} + + +def send(path: str) -> tuple[int, str]: + try: + conn = http.client.HTTPConnection(IMDS_HOST, IMDS_PORT, timeout=8) + conn.request("GET", path, headers=HEADERS) + resp = conn.getresponse() + body_head = resp.read(80).decode("latin-1", errors="replace").replace("\n", " ") + conn.close() + return resp.status, body_head + except Exception as exc: # pragma: no cover + return -1, f"{type(exc).__name__}: {exc}" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=os.environ.get( + "URL_DIFF_OUT", + os.path.join(os.path.dirname(__file__), "..", "results", "url_diff.tsv"), + )) + args = ap.parse_args() + os.makedirs(os.path.dirname(args.out), exist_ok=True) + + baseline_status, _ = send(CANONICAL) + print(f"baseline ({baseline_status}) for canonical path") + + rows = [] + diff_count = 0 + for name, path in VARIANTS.items(): + status, head = send(path) + diff = "DIFF" if status != baseline_status else "same" + if diff == "DIFF": + diff_count += 1 + rows.append((name, status, diff, urllib.parse.quote(path, safe="/?&=:%")[:120], head[:60])) + print(f" {name:<18} status={status:<4} {diff}") + + with open(args.out, "a", encoding="utf-8") as fh: + ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + for name, status, diff, path, head in rows: + fh.write(f"{ts}\t{name}\t{status}\t{diff}\t{path}\t{head}\n") + + print(f"\nWrote {len(rows)} rows to {args.out}; {diff_count} differ from baseline.") + # Differences are interesting but not necessarily failures — they need triage + # against the GPA rule engine. Exit 0 either way. + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py new file mode 100755 index 00000000..7c111ce8 --- /dev/null +++ b/pentest/linux/phase4b_local_rules/run.py @@ -0,0 +1,1144 @@ +#!/usr/bin/env python3 +""" +Phase 4b — auto-run pen-tests for the GPA local-file authorization rules. + +PRE-REQ: + The WS / fabric must already deliver a ruleId with + useLocalFileRules=true (so the agent merges the files in + /var/lib/azure-proxy-agent/rules/ instead of ignoring them). + The script verifies this at startup by reading status.json and the latest + AuthorizationRules_*.json snapshot. + +This script: + 1. Backs up any existing IMDS_Rules.json / WireServer_Rules.json. + 2. For each scenario: + a. Writes a crafted IMDS_Rules.json. + b. Waits for the agent's poll cycle (pollKeyStatusIntervalInSeconds + slack). + c. Sends a matrix of HTTP requests through GPA (eBPF-redirected) and + records the status code returned. + d. Compares each (request -> status) against the expected outcome. + 3. Restores backups and removes any files we created. + +Run as root: + sudo python3 phase4b_local_rules/run.py +""" +from __future__ import annotations +import argparse, datetime, http.client, json, os, shutil, signal, subprocess, sys, time +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +# ---------- environment ---------- +RULES_DIR = Path("/var/lib/azure-proxy-agent/rules") +LOG_DIR = Path("/var/log/azure-proxy-agent") +STATUS_FILE = LOG_DIR / "status.json" +IMDS_RULES_FILE = RULES_DIR / "IMDS_Rules.json" +WS_RULES_FILE = RULES_DIR / "WireServer_Rules.json" + +CONFIG_FILE = Path("/usr/lib/azure-proxy-agent/proxy-agent.json") # may not exist +POLL_FALLBACK_S = 15 +SLACK_S = 5 + +IMDS_HOST = "169.254.169.254" +IMDS_PORT = 80 +WS_HOST = "168.63.129.16" +WS_PORT = 80 + + +@dataclass +class Target: + name: str # "imds" | "wireserver" + host: str + port: int + rules_file: Path # IMDS_Rules.json | WireServer_Rules.json + + +TARGETS: dict[str, Target] = {} # filled in main() once paths are known + +# ---------- output ---------- +HERE = Path(__file__).resolve().parent +RESULTS_DIR = HERE.parent / "results" +RESULTS_DIR.mkdir(parents=True, exist_ok=True) +FINDINGS = RESULTS_DIR / "findings.tsv" +SCENARIO_LOG= RESULTS_DIR / "phase4b_local_rules.log" + +GREEN, RED, YELLOW, RESET = "\033[32m", "\033[31m", "\033[33m", "\033[0m" + + +def ts() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def record(test_id: str, status: str, msg: str = "") -> None: + color = {"PASS": GREEN, "FAIL": RED, "INFO": YELLOW}.get(status, "") + print(f"[{color}{status}{RESET}] {test_id} {msg}") + with FINDINGS.open("a") as fh: + fh.write(f"{ts()}\t{test_id}\t{status}\t{msg}\n") + + +# ---------- helpers ---------- + +def require_root() -> None: + if os.geteuid() != 0: + sys.exit("Must run as root (rules dir is 0700 root).") + + +def get_poll_interval() -> int: + """Read pollKeyStatusIntervalInSeconds from the agent config; fallback to 15.""" + for candidate in ( + CONFIG_FILE, + Path("/etc/azure-proxy-agent/proxy-agent.json"), + Path(__file__).resolve().parents[2] + / "proxy_agent" / "config" / "GuestProxyAgent.linux.json", + ): + try: + with candidate.open() as fh: + cfg = json.load(fh) + return int(cfg.get("pollKeyStatusIntervalInSeconds", POLL_FALLBACK_S)) + except Exception: + continue + return POLL_FALLBACK_S + + +def latest_authorization_rules_snapshot() -> Optional[Path]: + snaps = sorted(LOG_DIR.glob("AuthorizationRules_*.json")) + return snaps[-1] if snaps else None + + +def assert_use_local_file_rules_active() -> None: + """Read status.json + latest AuthorizationRules snapshot to confirm flag is on.""" + if not STATUS_FILE.exists(): + sys.exit(f"{STATUS_FILE} not found; is azure-proxy-agent running?") + status = json.loads(STATUS_FILE.read_text()) + snap = latest_authorization_rules_snapshot() + snap_text = snap.read_text() if snap else "" + + flag_in_snap = "useLocalFileRules-true" in snap_text + if not flag_in_snap: + record("PRE", "FAIL", + f"useLocalFileRules-true NOT present in {snap}; " + "the fabric is delivering plain rule ids — local rules will be ignored. " + "Enable useLocalFileRules from the control plane and retry.") + sys.exit(2) + + # The agent's local-rules merge logic honors `mode` ONLY from the remote + # rule (see local_rules.rs). Any `mode: enforce` we set in a local file + # is silently overridden by whatever the fabric is currently sending. + # If the live remote mode is `Audit` (or `Disabled`), every "expected + # 403" deny scenario will receive a 200 and report a misleading FAIL. + # Fail-fast here so the user knows to flip the fabric mode rather than + # interpret 32 bogus failures. + effective_modes = {} + try: + snap_json = json.loads(snap_text) + for tgt in ("imds", "wireserver"): + m = (snap_json.get("computedRules", {}).get(tgt, {}).get("mode") + or snap_json.get("inputRules", {}).get(tgt, {}).get("mode")) + if m: + effective_modes[tgt] = m + except Exception: + pass + non_enforce = {t: m for t, m in effective_modes.items() if m.lower() != "enforce"} + if non_enforce: + record("PRE", "FAIL", + f"effective remote mode is not Enforce ({non_enforce}); " + "local-rules deny scenarios will be silently audited and report bogus FAILs. " + "Switch the fabric/HostGAPlugin rule mode to Enforce and retry.") + sys.exit(2) + + record("PRE", "PASS", + f"useLocalFileRules-true confirmed in {snap.name if snap else '?'}; " + f"effective remote mode={effective_modes}") + + +def get_current_user_identity() -> dict: + """Return identity dict suitable for the rules engine, matching the + process/user that will actually be sending requests (root in this run). + + NOTE: probes are sent via python http.client, so processName/exePath + would be 'python3' / '/usr/bin/python3'. The engine AND-matches every + Some(_) field on Identity (None == wildcard), so we deliberately leave + exePath/processName UNSET to avoid false-alarm 403s. The dedicated S9 + scenarios that target exePath matching build their own identity inline.""" + user = subprocess.check_output(["id", "-un"], text=True).strip() + grp = subprocess.check_output(["id", "-gn"], text=True).strip() + return { + "name": "selfRoot", + "userName": user, + "groupName": grp, + } + + +def send(target: Target, method: str, path: str, + headers: Optional[dict] = None, timeout: float = 8.0) -> int: + h: dict[str, str] = {} + if target.name == "imds": + h["Metadata"] = "true" + elif target.name == "wireserver": + h["x-ms-version"] = "2012-11-30" + if headers: + h.update(headers) + try: + conn = http.client.HTTPConnection(target.host, target.port, timeout=timeout) + conn.request(method, path, headers=h) + resp = conn.getresponse() + resp.read(64) + conn.close() + return resp.status + except Exception: + return -1 + + +# ---------- file management ---------- + +@contextmanager +def backup_rules_dir(targets: list[Target]): + """Backup the rules files for the given targets and any unrelated + pre-existing IMDS/WireServer rules files. Restore on exit.""" + backups: dict[Path, Path] = {} + paths_to_manage = {t.rules_file for t in targets} + # also back up the *other* rules file if present so we don't accidentally leave it behind + paths_to_manage.update({IMDS_RULES_FILE, WS_RULES_FILE}) + for p in paths_to_manage: + if p.exists(): + bak = p.with_suffix(p.suffix + f".pentest-bak.{int(time.time())}") + shutil.copy2(p, bak) + backups[p] = bak + try: + yield + finally: + for p in paths_to_manage: + try: + p.unlink() + except FileNotFoundError: + pass + for orig, bak in backups.items(): + shutil.move(str(bak), str(orig)) + + +def write_rules(path: Path, doc: Any) -> None: + """Write rules JSON atomically.""" + path.parent.mkdir(mode=0o700, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + if isinstance(doc, str): + tmp.write_text(doc) + else: + tmp.write_text(json.dumps(doc, indent=2)) + os.chmod(tmp, 0o600) + os.replace(tmp, path) + + +def write_raw(path: Path, raw_text: str) -> None: + path.parent.mkdir(mode=0o700, exist_ok=True) + path.write_text(raw_text) + os.chmod(path, 0o600) + + +# ---------- scenarios ---------- + +@dataclass +class Probe: + name: str + path: str + expected: Any # int or tuple/list of acceptable HTTP statuses + method: str = "GET" + + +@dataclass +class Scenario: + sid: str + target: Target + description: str + rules: Any # dict (will be json.dumps'd) or raw str + probes: list[Probe] = field(default_factory=list) + raw: bool = False # write rules verbatim (for malformed-JSON test) + bom: bool = False # prepend a UTF-8 BOM (EF BB BF) to the file + + +def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: + scenarios: list[Scenario] = [] + pfx = "IMDS" + + # S1: disabled + allow-by-default — all calls succeed (control) + scenarios.append(Scenario( + sid=f"{pfx}-S1-disabled-allow", + target=target, + description="mode=disabled, defaultAccess=allow → no enforcement, baseline 200s", + rules={ + "defaultAccess": "allow", + "mode": "disabled", + "id": "pentest-s1", + "rules": {}, + }, + probes=[ + Probe("instance", "/metadata/instance?api-version=2021-02-01", 200), + Probe("token", + "/metadata/identity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 200), + ], + )) + + # S2: enforce + deny-by-default, NO allow rules → everything 403 + scenarios.append(Scenario( + sid=f"{pfx}-S2-enforce-deny-empty", + target=target, + description="mode=enforce, defaultAccess=deny, no rules → all 403", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s2", + "rules": {}, + }, + probes=[ + Probe("instance", "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions", "/metadata/versions", 403), + ], + )) + + # S3: regression guard — the agent MUST IGNORE `mode` written into the + # local rules file. The merge logic in proxy_agent/src/key_keeper/local_rules.rs + # takes `mode` from the remote rule only; `LocalAuthorizationRulesFile` + # has no `mode` field, so serde silently drops it. + # PRE has confirmed the effective remote mode is `enforce`, so writing + # mode=audit + defaultAccess=deny + empty rules must still 403. A 200 here + # would mean the agent started honoring the local `mode` (audit-mode + # pass-through) — a security regression worth a high-sev finding. + scenarios.append(Scenario( + sid=f"{pfx}-S3-audit-deny-empty", + target=target, + description="local mode=audit must be IGNORED; remote enforce + local deny → 403", + rules={ + "defaultAccess": "deny", + "mode": "audit", + "id": "pentest-s3", + "rules": {}, + }, + probes=[ + Probe("instance_denied", "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S4: explicit allow for /metadata/instance, deny everything else + scenarios.append(Scenario( + sid=f"{pfx}-S4-allow-one-path", + target=target, + description="enforce + deny, allow only /metadata/instance for current identity", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s4", + "rules": { + "privileges": [{"name": "p_instance", "path": "/metadata/instance"}], + "roles": [{"name": "r_instance", "privileges": ["p_instance"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_instance", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("instance_allowed", "/metadata/instance?api-version=2021-02-01", 200), + Probe("token_denied", + "/metadata/identity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S5: same allow rule but bound to a non-matching user → 403 + bogus_identity = {**identity, "name": "nobodyId", "userName": "nosuchuser_xyz"} + scenarios.append(Scenario( + sid=f"{pfx}-S5-wrong-identity", + target=target, + description="enforce + deny, allow path /metadata/instance for a non-matching user", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s5", + "rules": { + "privileges": [{"name": "p_instance", "path": "/metadata/instance"}], + "roles": [{"name": "r_instance", "privileges": ["p_instance"]}], + "identities": [bogus_identity], + "roleAssignments": [{"role": "r_instance", + "identities": [bogus_identity["name"]]}], + }, + }, + probes=[ + Probe("instance_denied", "/metadata/instance?api-version=2021-02-01", 403), + ], + )) + + # S6: encoding bypass — rule allows /metadata/instance only; + # try to reach /metadata/identity/oauth2/token via percent-encoded slash. + scenarios.append(Scenario( + sid=f"{pfx}-S6-encoding-bypass", + target=target, + description="enforce + deny; allow /metadata/instance; " + "test %2F-encoded slash variants of token path", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s6", + "rules": { + "privileges": [{"name": "p_instance", "path": "/metadata/instance"}], + "roles": [{"name": "r_instance", "privileges": ["p_instance"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_instance", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("token_uppercase_pct", + "/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_lowercase_pct", + "/metadata/identity/oauth2%2ftoken?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_double_encoded", + "/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_question_encoded", + "/metadata/identity/oauth2/token%3Fapi-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + # Both 403 (denied by GPA) and 404 (forwarded but rejected by IMDS) + # demonstrate the bypass attempt FAILED — neither reaches a permitted + # endpoint. Accept either as a successful denial. + Probe("token_dot_segments", + "/metadata/./identity/../identity/oauth2/token" + "?api-version=2018-02-01" + "&resource=https://management.azure.com/", (403, 404)), + ], + )) + + # S7: query-parameter rule — only allow when api-version matches + scenarios.append(Scenario( + sid=f"{pfx}-S7-query-param-required", + target=target, + description="enforce + deny; allow /metadata/instance ONLY with " + "api-version=2021-02-01", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s7", + "rules": { + "privileges": [{ + "name": "p_instance_q", + "path": "/metadata/instance", + "queryParameters": {"api-version": "2021-02-01"}, + }], + "roles": [{"name": "r_q", + "privileges": ["p_instance_q"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_q", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("matching_version", + "/metadata/instance?api-version=2021-02-01", 200), + Probe("missing_version", + "/metadata/instance", 403), + Probe("wrong_version", + "/metadata/instance?api-version=2017-04-02", 403), + ], + )) + + # S8: identity match by groupName only + scenarios.append(Scenario( + sid=f"{pfx}-S8-group-only-identity", + target=target, + description="enforce + deny; identity matches by groupName only", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s8", + "rules": { + "privileges": [{"name": "p_any", "path": "/metadata/"}], + "roles": [{"name": "r_any", "privileges": ["p_any"]}], + "identities": [{ + "name": "rootGroup", + "groupName": identity["groupName"], + }], + "roleAssignments": [{"role": "r_any", + "identities": ["rootGroup"]}], + }, + }, + probes=[ + Probe("instance_allowed", + "/metadata/instance?api-version=2021-02-01", 200), + ], + )) + + # S9: identity match by exePath — allow only when caller is /usr/bin/curl + scenarios.append(Scenario( + sid=f"{pfx}-S9-exepath-identity", + target=target, + description="enforce + deny; identity restricts to exePath=/usr/bin/curl", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s9", + "rules": { + "privileges": [{"name": "p_any", "path": "/metadata/"}], + "roles": [{"name": "r_any", "privileges": ["p_any"]}], + "identities": [{ + "name": "curlOnly", + "exePath": "/usr/bin/curl", + }], + "roleAssignments": [{"role": "r_any", + "identities": ["curlOnly"]}], + }, + }, + probes=[ + # Probe is sent via http.client (python). It SHOULD be denied because + # exePath != /usr/bin/curl. We separately invoke curl below to get a 200. + Probe("python_caller_denied", + "/metadata/instance?api-version=2021-02-01", 403), + ], + )) + + # S10: malformed JSON → fail-closed + scenarios.append(Scenario( + sid=f"{pfx}-S10-malformed-json", + target=target, + description="malformed local rules JSON → agent must fail-closed (all 403)", + rules="{ this is not valid json", + raw=True, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # ------------------------------------------------------------------ + # S11..S19 — local rules file as adversarial admin input + # ------------------------------------------------------------------ + + # S11: file written with an explicit UTF-8 BOM (EF BB BF). Regression + # guard for the misc_helpers::json_read_from_file BOM fix. + scenarios.append(Scenario( + sid=f"{pfx}-S11-utf8-bom", + target=target, + description="rules file prefixed with a UTF-8 BOM must still parse (deny → 403)", + rules={"defaultAccess": "deny", "mode": "enforce", + "id": "pentest-s11", "rules": {}}, + bom=True, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S12: duplicate "defaultAccess" keys (allow then deny); serde_json + # last-wins per RFC 8259 — the agent must deny. + scenarios.append(Scenario( + sid=f"{pfx}-S12-duplicate-keys", + target=target, + description="duplicate defaultAccess key (allow,deny) must resolve last-wins → 403", + rules='{"defaultAccess":"allow","defaultAccess":"deny",' + '"id":"pentest-s12","rules":{}}', + raw=True, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S17: local rule names that ALREADY contain the LocalFileRules_ prefix. + # Validates that prefix_local_rule_names is idempotent / consistent. + s17_identity = {**identity, "name": "LocalFileRules_idCurrentUser"} + scenarios.append(Scenario( + sid=f"{pfx}-S17-name-collision-shielded", + target=target, + description="local rule names already containing LocalFileRules_ prefix still bind correctly", + rules={ + "defaultAccess": "deny", "mode": "enforce", + "id": "pentest-s17", + "rules": { + "privileges": [{"name": "LocalFileRules_p_instance", + "path": "/metadata/instance"}], + "roles": [{"name": "LocalFileRules_r_instance", + "privileges": ["LocalFileRules_p_instance"]}], + "identities": [s17_identity], + "roleAssignments": [{"role": "LocalFileRules_r_instance", + "identities": [s17_identity["name"]]}], + }, + }, + probes=[ + Probe("instance_allowed", + "/metadata/instance?api-version=2021-02-01", 200), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S18: dangling role -> privilege reference → validator rejects → fail-closed. + scenarios.append(Scenario( + sid=f"{pfx}-S18-dangling-references", + target=target, + description="role references non-existent privilege → validator rejects → 403", + rules={ + "defaultAccess": "allow", "mode": "enforce", + "id": "pentest-s18", + "rules": { + "privileges": [{"name": "p_real", "path": "/metadata/"}], + "roles": [{"name": "r_broken", + "privileges": ["p_ghost"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_broken", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S19: identity userName uses Cyrillic homoglyphs (о = U+043E) in place + # of ASCII 'o'. Matcher must use byte-exact compare — current user 'root' + # must NOT match 'rооt'. + homo_user = (identity["userName"][0] + + ("\u043e" * (len(identity["userName"]) - 2)) + + identity["userName"][-1]) \ + if len(identity["userName"]) >= 3 else "r\u043e\u043et" + homo_identity = {**identity, "name": "homoUser", "userName": homo_user} + scenarios.append(Scenario( + sid=f"{pfx}-S19-identity-homoglyph", + target=target, + description="Cyrillic homoglyph in userName must NOT match ASCII current user → 403", + rules={ + "defaultAccess": "deny", "mode": "enforce", + "id": "pentest-s19", + "rules": { + "privileges": [{"name": "p_instance", + "path": "/metadata/instance"}], + "roles": [{"name": "r_instance", + "privileges": ["p_instance"]}], + "identities": [homo_identity], + "roleAssignments": [{"role": "r_instance", + "identities": [homo_identity["name"]]}], + }, + }, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + return scenarios + + +def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: + """WireServer-targeted local-file rule scenarios. + + WireServer is privileged-by-default (only root/admin allowed at the proxy + layer), so all probes assume the harness is running as root. + """ + scenarios: list[Scenario] = [] + pfx = "WS" + + GOALSTATE = "/machine/?comp=goalstate" + SHARED = "/machine/?comp=sharedConfig" + HOSTING = "/machine/?comp=hostingenvironmentconfig" + CERTS = "/machine/?comp=certificates" + EXTCONFIG = "/machine/?comp=extensionsConfig" + VERSIONS = "/?comp=versions" + + # WS-S1: control — disabled + allow + scenarios.append(Scenario( + sid=f"{pfx}-S1-disabled-allow", + target=target, + description="mode=disabled, defaultAccess=allow → baseline 200s", + rules={ + "defaultAccess": "allow", + "mode": "disabled", + "id": "pentest-ws-s1", + "rules": {}, + }, + probes=[ + Probe("goalstate", GOALSTATE, 200), + Probe("versions", VERSIONS, 200), + ], + )) + + # WS-S2: enforce + deny + no rules → all 403 + scenarios.append(Scenario( + sid=f"{pfx}-S2-enforce-deny-empty", + target=target, + description="mode=enforce, defaultAccess=deny, no rules → all 403", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s2", + "rules": {}, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + Probe("shared_denied", SHARED, 403), + ], + )) + + # WS-S3: regression guard — the agent MUST IGNORE `mode` from the local + # rules file. Same contract as IMDS-S3. + scenarios.append(Scenario( + sid=f"{pfx}-S3-audit-deny-empty", + target=target, + description="local mode=audit must be IGNORED; remote enforce + local deny → 403", + rules={ + "defaultAccess": "deny", + "mode": "audit", + "id": "pentest-ws-s3", + "rules": {}, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + # WS-S4: only allow goalstate; everything else 403 + scenarios.append(Scenario( + sid=f"{pfx}-S4-allow-goalstate-only", + target=target, + description="enforce + deny; allow only /machine/ with comp=goalstate", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s4", + "rules": { + "privileges": [{ + "name": "p_goalstate", + "path": "/machine/", + "queryParameters": {"comp": "goalstate"}, + }], + "roles": [{"name": "r_goalstate", + "privileges": ["p_goalstate"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_goalstate", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("goalstate_allowed", GOALSTATE, 200), + Probe("shared_denied", SHARED, 403), + Probe("hosting_denied", HOSTING, 403), + Probe("certs_denied", CERTS, 403), + Probe("extconfig_denied", EXTCONFIG, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + # WS-S5: identity mismatch → even goalstate 403 + bogus_identity = {**identity, "name": "nobodyId", "userName": "nosuchuser_xyz"} + scenarios.append(Scenario( + sid=f"{pfx}-S5-wrong-identity", + target=target, + description="enforce + deny; allow goalstate but bound to non-matching user", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s5", + "rules": { + "privileges": [{"name": "p_machine", "path": "/machine/"}], + "roles": [{"name": "r_machine", + "privileges": ["p_machine"]}], + "identities": [bogus_identity], + "roleAssignments": [{"role": "r_machine", + "identities": [bogus_identity["name"]]}], + }, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + ], + )) + + # WS-S6: encoding/path-traversal bypass attempts on a deny path. + # Allow only goalstate; try to reach certificates via encoded variants. + scenarios.append(Scenario( + sid=f"{pfx}-S6-encoding-bypass", + target=target, + description="enforce + deny; allow only goalstate; " + "test encoded/path-tricks for /machine/?comp=certificates", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s6", + "rules": { + "privileges": [{ + "name": "p_goalstate", + "path": "/machine/", + "queryParameters": {"comp": "goalstate"}, + }], + "roles": [{"name": "r_goalstate", + "privileges": ["p_goalstate"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_goalstate", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("certs_uppercase_pct", + "/machine%2F?comp=certificates", 403), + Probe("certs_question_encoded", + "/machine/%3Fcomp=certificates", 403), + # 403 (denied at GPA) or 404 (forwarded but rejected upstream) both + # mean the bypass attempt failed. Accept either as success. + Probe("certs_dot_segments", + "/machine/./../machine/?comp=certificates", (403, 404)), + Probe("certs_extra_slashes", + "//machine///?comp=certificates", 403), + Probe("certs_value_case", + "/machine/?comp=Certificates", 403), + Probe("goalstate_value_case_should_match", + "/machine/?comp=GOALSTATE", 200), + ], + )) + + # WS-S7: comp= query parameter must match exactly + scenarios.append(Scenario( + sid=f"{pfx}-S7-query-param-required", + target=target, + description="enforce + deny; allow /machine/ ONLY with comp=goalstate", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s7", + "rules": { + "privileges": [{ + "name": "p_goalstate_q", + "path": "/machine/", + "queryParameters": {"comp": "goalstate"}, + }], + "roles": [{"name": "r_q", + "privileges": ["p_goalstate_q"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_q", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("matching_comp", GOALSTATE, 200), + Probe("missing_comp", "/machine/", 403), + Probe("wrong_comp", "/machine/?comp=hostingenvironmentconfig", 403), + # GPA must FORWARD this (extra params don't break the privilege match); + # WireServer itself may then 400 on the unknown param. Both 200 and 400 + # mean GPA forwarded — only a 403 would indicate a GPA-side false deny. + Probe("extra_param_ok", + "/machine/?comp=goalstate&incarnation=1", (200, 400)), + ], + )) + + # WS-S8: identity match by groupName only + scenarios.append(Scenario( + sid=f"{pfx}-S8-group-only-identity", + target=target, + description="enforce + deny; identity matches by groupName only", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s8", + "rules": { + "privileges": [{"name": "p_machine", "path": "/machine/"}], + "roles": [{"name": "r_machine", + "privileges": ["p_machine"]}], + "identities": [{ + "name": "rootGroup", + "groupName": identity["groupName"], + }], + "roleAssignments": [{"role": "r_machine", + "identities": ["rootGroup"]}], + }, + }, + probes=[ + Probe("goalstate_allowed", GOALSTATE, 200), + ], + )) + + # WS-S9: per-process AuthZ via exePath — only /usr/bin/curl allowed + scenarios.append(Scenario( + sid=f"{pfx}-S9-exepath-identity", + target=target, + description="enforce + deny; identity restricts to exePath=/usr/bin/curl", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s9", + "rules": { + "privileges": [{"name": "p_machine", "path": "/machine/"}], + "roles": [{"name": "r_machine", + "privileges": ["p_machine"]}], + "identities": [{ + "name": "curlOnly", + "exePath": "/usr/bin/curl", + }], + "roleAssignments": [{"role": "r_machine", + "identities": ["curlOnly"]}], + }, + }, + probes=[ + Probe("python_caller_denied", GOALSTATE, 403), + ], + )) + + # WS-S10: malformed JSON → fail-closed + scenarios.append(Scenario( + sid=f"{pfx}-S10-malformed-json", + target=target, + description="malformed local rules JSON → agent must fail-closed (all 403)", + rules="{ this is not valid json", + raw=True, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + # ------------------------------------------------------------------ + # S11..S19 — mirror of IMDS adversarial-admin-input tests + # ------------------------------------------------------------------ + + scenarios.append(Scenario( + sid=f"{pfx}-S11-utf8-bom", + target=target, + description="rules file prefixed with a UTF-8 BOM must still parse (deny → 403)", + rules={"defaultAccess": "deny", "mode": "enforce", + "id": "pentest-ws-s11", "rules": {}}, + bom=True, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + scenarios.append(Scenario( + sid=f"{pfx}-S12-duplicate-keys", + target=target, + description="duplicate defaultAccess key (allow,deny) must resolve last-wins → 403", + rules='{"defaultAccess":"allow","defaultAccess":"deny",' + '"id":"pentest-ws-s12","rules":{}}', + raw=True, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + ws17_identity = {**identity, "name": "LocalFileRules_idCurrentUser"} + scenarios.append(Scenario( + sid=f"{pfx}-S17-name-collision-shielded", + target=target, + description="local rule names already containing LocalFileRules_ prefix still bind correctly", + rules={ + "defaultAccess": "deny", "mode": "enforce", + "id": "pentest-ws-s17", + "rules": { + "privileges": [{"name": "LocalFileRules_p_machine", + "path": "/machine/"}], + "roles": [{"name": "LocalFileRules_r_machine", + "privileges": ["LocalFileRules_p_machine"]}], + "identities": [ws17_identity], + "roleAssignments": [{"role": "LocalFileRules_r_machine", + "identities": [ws17_identity["name"]]}], + }, + }, + probes=[Probe("goalstate_allowed", GOALSTATE, 200)], + )) + + scenarios.append(Scenario( + sid=f"{pfx}-S18-dangling-references", + target=target, + description="role references non-existent privilege → validator rejects → 403", + rules={ + "defaultAccess": "allow", "mode": "enforce", + "id": "pentest-ws-s18", + "rules": { + "privileges": [{"name": "p_real", "path": "/machine/"}], + "roles": [{"name": "r_broken", + "privileges": ["p_ghost"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_broken", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + ws_homo_user = (identity["userName"][0] + + ("\u043e" * (len(identity["userName"]) - 2)) + + identity["userName"][-1]) \ + if len(identity["userName"]) >= 3 else "r\u043e\u043et" + ws_homo_identity = {**identity, "name": "homoUser", "userName": ws_homo_user} + scenarios.append(Scenario( + sid=f"{pfx}-S19-identity-homoglyph", + target=target, + description="Cyrillic homoglyph in userName must NOT match ASCII current user → 403", + rules={ + "defaultAccess": "deny", "mode": "enforce", + "id": "pentest-ws-s19", + "rules": { + "privileges": [{"name": "p_machine", "path": "/machine/"}], + "roles": [{"name": "r_machine", + "privileges": ["p_machine"]}], + "identities": [ws_homo_identity], + "roleAssignments": [{"role": "r_machine", + "identities": [ws_homo_identity["name"]]}], + }, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + return scenarios + + +def build_scenarios(targets: list[Target], identity: dict) -> list[Scenario]: + out: list[Scenario] = [] + for t in targets: + if t.name == "imds": + out.extend(_imds_scenarios(t, identity)) + elif t.name == "wireserver": + out.extend(_wireserver_scenarios(t, identity)) + else: + raise ValueError(f"unknown target {t.name}") + return out + + +# ---------- runner ---------- + +def wait_for_refresh(poll_s: int) -> None: + delay = poll_s + SLACK_S + print(f" ... waiting {delay}s for rule refresh ...") + time.sleep(delay) + + +def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: + print(f"\n=== {sc.sid}: {sc.description}") + if sc.raw: + text = sc.rules if isinstance(sc.rules, str) else json.dumps(sc.rules) + else: + text = json.dumps(sc.rules, indent=2) + if sc.bom: + # Deterministic explicit BOM, regardless of platform default encoding. + sc.target.rules_file.parent.mkdir(mode=0o700, exist_ok=True) + tmp = sc.target.rules_file.with_suffix(sc.target.rules_file.suffix + ".tmp") + tmp.write_bytes(b"\xef\xbb\xbf" + text.encode("utf-8")) + os.chmod(tmp, 0o600) + os.replace(tmp, sc.target.rules_file) + elif sc.raw: + write_raw(sc.target.rules_file, text) + else: + write_rules(sc.target.rules_file, sc.rules) + wait_for_refresh(poll_s) + + passes = fails = 0 + for p in sc.probes: + actual = send(sc.target, p.method, p.path) + if isinstance(p.expected, (tuple, list, set)): + ok = actual in p.expected + else: + ok = (actual == p.expected) + record(f"{sc.sid}/{p.name}", + "PASS" if ok else "FAIL", + f"expected={p.expected} actual={actual} path={p.path[:80]}") + passes += int(ok); fails += int(not ok) + + # Extra real-curl probe for S9 to demonstrate the positive case + if sc.sid.endswith("S9-exepath-identity"): + if sc.target.name == "imds": + url = (f"http://{sc.target.host}/metadata/instance" + "?api-version=2021-02-01") + extra_hdr = ["-H", "Metadata: true"] + else: # wireserver + url = f"http://{sc.target.host}/machine/?comp=goalstate" + extra_hdr = ["-H", "x-ms-version: 2012-11-30"] + try: + out = subprocess.run( + ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", + "--max-time", "8", *extra_hdr, url], + capture_output=True, text=True, timeout=15) + actual = int(out.stdout or "0") + except Exception: + actual = -1 + ok = (actual == 200) + record(f"{sc.sid}/curl_caller_allowed", + "PASS" if ok else "FAIL", + f"expected=200 actual={actual} (curl invocation)") + passes += int(ok); fails += int(not ok) + + return passes, fails + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--target", + choices=["imds", "wireserver", "both"], default="both", + help="which authorization target's local rules to exercise") + ap.add_argument("--scenarios", help="comma-separated scenario sids to run " + "(default: all). Match is exact.") + ap.add_argument("--poll", type=int, default=None, + help="override poll interval seconds") + args = ap.parse_args() + + require_root() + assert_use_local_file_rules_active() + + poll_s = args.poll or get_poll_interval() + record("CFG", "INFO", f"poll interval = {poll_s}s") + + identity = get_current_user_identity() + record("CFG", "INFO", + f"current identity: user={identity['userName']} " + f"group={identity['groupName']}") + + targets: list[Target] = [] + if args.target in ("imds", "both"): + targets.append(Target("imds", IMDS_HOST, IMDS_PORT, IMDS_RULES_FILE)) + if args.target in ("wireserver", "both"): + targets.append(Target("wireserver", WS_HOST, WS_PORT, WS_RULES_FILE)) + record("CFG", "INFO", + f"targets: {', '.join(t.name for t in targets)}") + + all_scenarios = build_scenarios(targets, identity) + if args.scenarios: + wanted = set(args.scenarios.split(",")) + all_scenarios = [s for s in all_scenarios if s.sid in wanted] + + total_p = total_f = 0 + with backup_rules_dir(targets): + for sc in all_scenarios: + try: + p, f = run_scenario(sc, poll_s) + except KeyboardInterrupt: + print("\nInterrupted; restoring backups.") + raise + except Exception as exc: + record(sc.sid, "FAIL", f"scenario crashed: {exc}") + p, f = 0, 1 + total_p += p; total_f += f + + print("\n========================================") + print(f"local-rules pen-test: {total_p} PASS, {total_f} FAIL") + print(f"Findings appended to {FINDINGS}") + return 0 if total_f == 0 else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: + print(f"FATAL: {exc}", file=sys.stderr) + sys.exit(3) diff --git a/pentest/linux/phase5_state_fs/audit.sh b/pentest/linux/phase5_state_fs/audit.sh new file mode 100755 index 00000000..1f251cb4 --- /dev/null +++ b/pentest/linux/phase5_state_fs/audit.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Phase 5 — read-only filesystem / state audit (Scenarios E1, F1). +# Read-only; safe to run. Destructive sub-tests (E2/E3/E4) are intentionally NOT here. + +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/../lib/common.sh" + +# E1 — permissions on key dir, log dir, binary, unit +check_mode() { # $1=path $2=expected_octal_max (e.g. 700) $3=expected_owner + local p="$1" max="$2" owner="$3" + # Use sudo for stat so we can read modes inside root-only dirs. + if ! sudo test -e "$p"; then record "E1[$p]" INFO "missing"; return; fi + local mode own + mode=$(sudo stat -c '%a' "$p") + own=$(sudo stat -c '%U' "$p") + if (( 10#$mode > 10#$max )); then + record "E1[$p]" FAIL "mode=$mode (max $max) owner=$own" + elif [[ "$own" != "$owner" ]]; then + record "E1[$p]" FAIL "owner=$own (expected $owner) mode=$mode" + else + record "E1[$p]" PASS "mode=$mode owner=$own" + fi +} + +check_mode "$GPA_KEY_DIR" 700 root +check_mode "$GPA_LOG_DIR" 755 root +check_mode "/usr/sbin/azure-proxy-agent" 755 root +check_mode "/usr/lib/systemd/system/azure-proxy-agent.service" 644 root + +# Any individual key file should be 0600 root +if [[ -d "$GPA_KEY_DIR" ]]; then + while IFS= read -r f; do + check_mode "$f" 600 root + done < <(sudo find "$GPA_KEY_DIR" -maxdepth 2 -type f 2>/dev/null) +fi + +# F4 — Local rules dir is admin-controlled authZ INPUT. Non-root WRITE on this +# dir (or any rules file) lets an unprivileged user inject useLocalFileRules +# content and bypass remote enforcement contracts. Fail on world/group writable +# or non-root owner. Read perms are not flagged — rules are policy, not secrets. +check_rules_acl() { # $1=path + local p="$1" + if ! sudo test -e "$p"; then record "F4[$p]" INFO "missing"; return; fi + local mode own grp + mode=$(sudo stat -c '%a' "$p") + own=$(sudo stat -c '%U' "$p") + grp=$(sudo stat -c '%G' "$p") + if [[ "$own" != "root" ]]; then + record "F4[$p]" FAIL "owner=$own (expected root) mode=$mode group=$grp" + elif (( (10#$mode % 10) >= 2 )); then + record "F4[$p]" FAIL "world-writable mode=$mode owner=$own group=$grp" + elif (( ((10#$mode / 10) % 10) >= 2 )) && [[ "$grp" != "root" ]]; then + record "F4[$p]" FAIL "group-writable by non-root group: mode=$mode group=$grp" + else + record "F4[$p]" PASS "mode=$mode owner=$own group=$grp" + fi +} +check_rules_acl "$GPA_RULES_DIR" +if [[ -d "$GPA_RULES_DIR" ]]; then + while IFS= read -r f; do + check_rules_acl "$f" + done < <(sudo find "$GPA_RULES_DIR" -maxdepth 2 -type f 2>/dev/null) +fi + +# F1 — secret leakage in world-readable status / rules +for f in "$GPA_LOG_DIR/status.json" "$GPA_LOG_DIR"/AuthorizationRules_*.json; do + [[ -f "$f" ]] || continue + if grep -aE '"key"|secret|signature|hmac|token' "$f" >/dev/null 2>&1; then + record "F1[$(basename "$f")]" FAIL "possible secret-shaped string in world-readable file" + else + record "F1[$(basename "$f")]" PASS "no obvious secret-shaped strings" + fi +done + +# Surface eBPF programs / cgroup attachments for review (informational) +if command -v bpftool >/dev/null; then + bpftool prog show 2>/dev/null > "$RESULTS_DIR/bpftool_prog_show.txt" || true + bpftool cgroup tree 2>/dev/null > "$RESULTS_DIR/bpftool_cgroup_tree.txt" || true + record P5-bpftool INFO "saved bpftool snapshots to $RESULTS_DIR" +fi + +echo +echo "Phase 5 (read-only audit) complete. See $FINDINGS" diff --git a/pentest/linux/run_all.sh b/pentest/linux/run_all.sh new file mode 100755 index 00000000..6886f8e6 --- /dev/null +++ b/pentest/linux/run_all.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Driver for the GPA Linux pen-test harness. +# Mirrors the UX of pentest/windows/Run-AllPenTests.ps1. +# +# Default (no args): clears findings.tsv, runs ALL phases +# (2 listener, 3 AuthN/AuthZ, 4 URL diff, 4b local-file rules, 5 FS audit), +# then renders results/report.html. +# +# Phase 4b mutates /var/lib/azure-proxy-agent/rules and requires +# useLocalFileRules=true on the fabric (~13 min); skip with --skip-phase4b +# if running on a fabric that does not allow it. +# +# Usage: +# sudo ./pentest/linux/run_all.sh # ALL + report (truncate) +# sudo ./pentest/linux/run_all.sh --skip-phase4b # safe/read-only phases only +# sudo ./pentest/linux/run_all.sh --no-truncate # append to findings.tsv +# sudo ./pentest/linux/run_all.sh --phase4b-target imds # 4b: IMDS only +# sudo ./pentest/linux/run_all.sh --no-report # skip the HTML render +# ./pentest/linux/run_all.sh --help + +# Note: -e (exit on any error) is intentionally not set here, +# so individual command failures won't abort the script — each phase runs even if a previous one fails. +# That fits this harness, since you want all pen-test phases to execute and report findings independently. +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +RESULTS_DIR="$HERE/results" +FINDINGS="$RESULTS_DIR/findings.tsv" + +TRUNCATE=1 +RUN_4B=1 +P4B_TARGET="both" +RUN_REPORT=1 +RUN_PHASE2=1 +RUN_PHASE3=1 +RUN_PHASE4=1 +RUN_PHASE5=1 + +usage() { + sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' + cat <&2; usage; exit 2 ;; + esac + shift +done + +mkdir -p "$RESULTS_DIR" + +if [[ "$TRUNCATE" -eq 1 ]]; then + : > "$FINDINGS" + echo "Truncated $FINDINGS" +fi + +banner() { echo; echo "===== $* ====="; } + +if [[ "$RUN_PHASE2" -eq 1 ]]; then + banner "Phase 2 — listener" + bash "$HERE/phase2_listener/run.sh" +fi + +if [[ "$RUN_PHASE3" -eq 1 ]]; then + banner "Phase 3 — AuthN / AuthZ" + bash "$HERE/phase3_authn_authz/run.sh" +fi + +if [[ "$RUN_PHASE4" -eq 1 ]]; then + banner "Phase 4 — URL / encoding diff" + python3 "$HERE/phase4_rules_fuzz/url_diff.py" +fi + +if [[ "$RUN_4B" -eq 1 ]]; then + banner "Phase 4b — local-file rules (target=$P4B_TARGET)" + python3 "$HERE/phase4b_local_rules/run.py" --target "$P4B_TARGET" +fi + +if [[ "$RUN_PHASE5" -eq 1 ]]; then + banner "Phase 5 — filesystem audit" + bash "$HERE/phase5_state_fs/audit.sh" +fi + +if [[ "$RUN_REPORT" -eq 1 ]]; then + banner "Generate report" + python3 "$HERE/generate_report.py" +fi + +echo +echo "Done. Findings: $FINDINGS" +[[ "$RUN_REPORT" -eq 1 ]] && echo "Report: $RESULTS_DIR/report.html" +exit 0 diff --git a/pentest/linux/test_catalog.py b/pentest/linux/test_catalog.py new file mode 100644 index 00000000..70524c7f --- /dev/null +++ b/pentest/linux/test_catalog.py @@ -0,0 +1,411 @@ +"""Per-test metadata: design, automation, manual repro, and (when failed) fix. + +Used by generate_report.py to enrich the HTML pen-test report. + +Lookups happen by test ID (e.g. "A4", "C5", "E1[…]", "IMDS-S4-allow-one-path", +"WS-S6-encoding-bypass/certs_dot_segments"). Helpers below normalize the noisy +forms (anything in `[…]` or after `/`) to the base ID before lookup. +""" +from __future__ import annotations + +import re +from typing import Optional, TypedDict + + +class TestInfo(TypedDict, total=False): + title: str + design: str # what invariant is being checked + automation: str # how the harness exercises it (script lines) + repro_script: str # one-liner to reproduce via the harness + repro_manual: str # how to reproduce by hand (shell commands) + fix: str # suggested remediation when this test FAILs + + +# --------------------------------------------------------------------------- +# Phase 2 — listener / DoS +# --------------------------------------------------------------------------- +CATALOG: dict[str, TestInfo] = { + "A1": { + "title": "Listener bound only to loopback", + "design": "GPA must listen ONLY on 127.0.0.1:3080. Any non-loopback bind would expose the proxy to other VMs on the vNet.", + "automation": "ss -tnlH | grep ':3080$' filtered against external IPv4 addresses (`ip -o -4 addr show scope global`).", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", + "repro_manual": "ss -tnlp | grep 3080\nip -o -4 addr show scope global", + "fix": "Bind the proxy listener explicitly to 127.0.0.1, not 0.0.0.0. Check `bind` call in proxy_agent/src/proxy/proxy_listener.rs.", + }, + "A1b": { + "title": "Loopback connect to 3080 succeeds", + "design": "Sanity check: the listener is reachable on 127.0.0.1:3080.", + "automation": "`timeout 2 bash -c '>/dev/tcp/127.0.0.1/3080'`.", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", + "repro_manual": "timeout 2 bash -c '>/dev/tcp/127.0.0.1/3080' && echo OK", + "fix": "If failing, the agent is not running. `systemctl status azure-proxy-agent`; check journalctl for bind errors.", + }, + "A1c": { + "title": "Port 3080 NOT reachable on external IP", + "design": "Even if a misconfiguration binds 0.0.0.0, vNet peers must NOT be able to connect to :3080.", + "automation": "`timeout 2 bash -c '>/dev/tcp//3080'` against the first global-scope IPv4 — must time out.", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", + "repro_manual": "ip -o -4 addr show scope global # pick IP\ntimeout 2 bash -c '>/dev/tcp//3080' || echo refused", + "fix": "Same as A1 — confirm `bind(127.0.0.1, 3080)` and add a host-firewall (nftables/iptables) DROP on 3080 for non-loopback as defense-in-depth.", + }, + "A3": { + "title": "Service survives malformed HTTP", + "design": "Bad method, HTTP/9.9, 65 KB URI, slow-loris must not crash the agent.", + "automation": "Pipes 5 crafted requests through `nc` and verifies `pidof azure-proxy-agent` is unchanged.", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", + "repro_manual": "printf 'INVALIDMETHOD / HTTP/1.1\\r\\n\\r\\n' | nc -q1 127.0.0.1 3080\nprintf 'GET /%s HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n' \"$(python3 -c 'print(\"A\"*65000)')\" | nc -q1 127.0.0.1 3080", + "fix": "If FAIL, gather a core dump (`coredumpctl gdb azure-proxy-agent`) and harden the HTTP parser against the failing input class.", + }, + "A4": { + "title": "CONNECT method must be rejected", + "design": "GPA must not act as a generic HTTP tunnel. CONNECT to arbitrary :443 must be refused with an explicit 4xx, never silently closed.", + "automation": "Sends raw `CONNECT example.com:443 HTTP/1.1` over `nc`, parses the first response status. PASS if the status is non-2xx and non-empty.", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", + "repro_manual": "printf 'CONNECT example.com:443 HTTP/1.1\\r\\nHost: example.com:443\\r\\n\\r\\n' | nc -q2 127.0.0.1 3080", + "fix": "In the request parser (proxy_agent/src/proxy/proxy_server.rs / proxy_listener.rs) explicitly reject any non-{GET,POST,PUT,DELETE,HEAD,OPTIONS,PATCH} method with `405 Method Not Allowed` and a short body BEFORE closing. Never silently drop unsupported methods — write a unit test for CONNECT, TRACE, and a random verb.", + }, + "A5": { + "title": "Not an open proxy", + "design": "GPA must refuse to forward to arbitrary hosts even when explicitly proxied.", + "automation": "`curl -x http://127.0.0.1:3080 http://example.com/` — expects non-2xx.", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", + "repro_manual": "curl -v -x http://127.0.0.1:3080 http://example.com/", + "fix": "Allowlist destinations in the connect-policy redirector; reject any Host:/absolute-URI not in {WireServer, IMDS, HostGAPlugin}.", + }, + "G1": { + "title": "Connection burst doesn't crash", + "design": "200 concurrent /dev/tcp connections; service PID must be stable.", + "automation": "Spawns 200 background connect-and-read loops via `/dev/tcp/127.0.0.1/3080`, then compares pidof before/after.", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", + "repro_manual": "for i in $(seq 1 200); do (exec 3<>/dev/tcp/127.0.0.1/3080; echo -e 'GET / HTTP/1.0\\r\\n\\r' >&3; cat <&3 >/dev/null) & done; wait\npidof azure-proxy-agent", + "fix": "Apply per-source connection limits in the listener and verify `systemctl cat azure-proxy-agent` includes the CPU/Memory drop-ins.", + }, + + # ----------------------------------------------------------------------- + # Phase 3 — AuthN/AuthZ + # ----------------------------------------------------------------------- + "P3-cap": { + "title": "Phase-3 packet capture", + "design": "Records traffic to fabric IPs + lo:3080 for offline analysis.", + "automation": "`sudo tcpdump -i any -U -w results/phase3-*.pcap` filtered to IMDS/WireServer/loopback:3080.", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", + "repro_manual": "sudo tcpdump -i any -w /tmp/p3.pcap 'host 169.254.169.254 or host 168.63.129.16 or (host 127.0.0.1 and port 3080)'", + }, + "B1a": { + "title": "IMDS reachable through GPA", + "design": "A normal IMDS call from the guest must succeed (200) thanks to eBPF redirect + GPA signature injection.", + "automation": "`curl -H 'Metadata: true' http://169.254.169.254/metadata/instance?api-version=2021-02-01`.", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", + "repro_manual": "curl -sS -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'", + "fix": "If FAIL, check eBPF programs are attached (`bpftool prog show`, `bpftool cgroup tree`) and that key-keeper has latched a key (status.json secureChannelState).", + }, + "B1b": { + "title": "ss snapshot of own connection", + "design": "Record what the kernel sees as the peer for our IMDS connection (informational).", + "automation": "Opens `/dev/tcp/IMDS/80` and snapshots `ss -tnp` for current PID.", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", + "repro_manual": "( exec 3<>/dev/tcp/169.254.169.254/80 ); ss -tnp | grep $$", + }, + "B4": { + "title": "Attacker-supplied signature header is overwritten", + "design": "GPA must strip/overwrite client-supplied `x-ms-azure-signature` / `x-ms-azure-time-tick`. Successful 200 means GPA injected its own — confirm in the pcap that the on-wire header value differs.", + "automation": "curl with forged headers; expects 200, then manual pcap diff.", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", + "repro_manual": "curl -sS -H 'Metadata: true' -H 'x-ms-azure-signature: AAAA-attacker' -H 'x-ms-azure-time-tick: 0' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'\n# then inspect: tshark -r results/phase3-*.pcap -Y 'http and ip.dst==168.63.129.16' -T fields -e http.request.line", + "fix": "In the request-rewriting layer, unconditionally remove inbound `x-ms-azure-*` headers before computing/inserting GPA's own signature.", + }, + "B3": { + "title": "Key files not group/world readable", + "design": "Anything under /var/lib/azure-proxy-agent/keys must be 0600 root.", + "automation": "`find /var/lib/azure-proxy-agent/keys -type f \\( -perm -o+r -o -perm -g+r \\)` must return empty.", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", + "repro_manual": "sudo find /var/lib/azure-proxy-agent/keys -type f -exec stat -c '%a %n' {} \\;", + "fix": "Force 0600 on every key write (Rust: `OpenOptions::new().mode(0o600)`); add a self-heal pass on service start.", + }, + "C1": { + "title": "Non-elevated user denied WireServer", + "design": "WireServer requires `runAsElevated`; calls from `nobody` must be 401/403.", + "automation": "`sudo -u nobody curl -H 'x-ms-version: 2012-11-30' http://168.63.129.16/machine/?comp=goalstate`.", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", + "repro_manual": "sudo -u nobody curl -sS -o /dev/null -w '%{http_code}\\n' -H 'x-ms-version: 2012-11-30' 'http://168.63.129.16/machine/?comp=goalstate'", + "fix": "If FAIL with 200, check the WireServer authorizer (`proxy_agent/src/proxy/proxy_authorizer.rs`) — `runAsElevated` gate is broken.", + }, + "C5": { + "title": "cgroup-namespace evasion attempt", + "design": "Inside `unshare -Cr`, a process should still hit the cgroup-attached eBPF program (the cgroup IS still under the root cgroup hierarchy). Connection must remain redirected.", + "automation": "`unshare -Cr -- curl …/metadata/instance` — expects 200.", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", + "repro_manual": "unshare -Cr -- curl -sS -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'", + "fix": "A `000` likely means `unshare -Cr` failed before curl ran on this kernel (no CAP_SYS_ADMIN for the user namespace). Re-run as root, or update the harness to detect ENOSYS/EPERM from `unshare` and record INFO instead of FAIL. If unshare succeeds and curl gets 000 from the redirected port, the eBPF program is not catching the new namespace — investigate `bpftool cgroup tree` and the cgroup attach point.", + }, + "C7": { + "title": "Alternate IP-form bypass attempt", + "design": "`http://0xa9fea9fe/...` and `http://2852039166/...` resolve to 169.254.169.254. The eBPF redirect should treat them the same; AuthZ decision should be identical to the canonical form.", + "automation": "`curl` against each alternate URL, code recorded INFO.", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", + "repro_manual": "curl -sS -o /dev/null -w '%{http_code}\\n' -H 'Metadata: true' http://0xa9fea9fe/metadata/instance?api-version=2021-02-01\ncurl -sS -o /dev/null -w '%{http_code}\\n' -H 'Metadata: true' http://2852039166/metadata/instance?api-version=2021-02-01", + "fix": "If a code DIFFERS from the canonical (200), file a finding: the redirector or matcher treats numeric host forms inconsistently.", + }, + + # ----------------------------------------------------------------------- + # Phase 4 — URL/encoding diff (all rows live in url_diff.tsv, not findings.tsv) + # ----------------------------------------------------------------------- + + # ----------------------------------------------------------------------- + # Phase 5 — fs/state audit + # ----------------------------------------------------------------------- + "E1": { + "title": "Filesystem mode/owner audit", + "design": "Each path's mode must be ≤ the documented max and owner == root. Key files MUST be 0600; dirs 0700/0755; binary 0755; unit file 0644.", + "automation": "`sudo stat -c '%a %U' ` for key dir, log dir, binary, unit file, and every regular file under keys/.", + "repro_script": "bash pentest/linux/phase5_state_fs/audit.sh", + "repro_manual": "sudo stat -c '%a %U %n' /var/lib/azure-proxy-agent/keys /var/log/azure-proxy-agent /usr/sbin/azure-proxy-agent /usr/lib/systemd/system/azure-proxy-agent.service\nsudo find /var/lib/azure-proxy-agent/keys -type f -exec stat -c '%a %U %n' {} \\;", + "fix": ( + "Group-writable binary / unit (0775, 0664): fix the package to install with explicit modes " + "(`install -m 0755` for the binary in pkg_debian/rules; `install -m 0644` for the .service; " + "`%attr(0755,root,root)` / `%attr(0644,root,root)` in the RPM spec). " + "World-readable key files (0644) are the highest-severity finding here — patch the key writers " + "to use `OpenOptions::new().mode(0o600).open()` and explicitly `set_permissions(p, 0o600)` after " + "every write. Self-heal on start: chmod 0600 every file in /var/lib/azure-proxy-agent/keys/. " + "Quick mitigation: `sudo chmod 600 /var/lib/azure-proxy-agent/keys/*`." + ), + }, + "F4": { + "title": "Rules dir mode/owner — admin-only WRITE on local rules input", + "design": "The Rules dir (and every IMDS_Rules.json / WireServer_Rules.json under it) is admin-controlled authZ INPUT — not a secret, but a non-root WRITE there lets an unprivileged user inject useLocalFileRules content and short-circuit remote enforcement contracts. FAIL on world-writable, group-writable by a non-root group, or non-root owner. Read perms are not flagged (rules are policy, not secrets).", + "automation": "`sudo stat -c '%a %U %G'` on the rules dir and every regular file underneath; reports F4[path] PASS/FAIL.", + "repro_script": "bash pentest/linux/phase5_state_fs/audit.sh", + "repro_manual": "sudo stat -c '%a %U %G %n' /var/lib/azure-proxy-agent/rules\nsudo find /var/lib/azure-proxy-agent/rules -type f -exec stat -c '%a %U %G %n' {} \\;", + "fix": "Install the rules dir as `install -d -m 0755 -o root -g root /var/lib/azure-proxy-agent/rules` (or 0700 for tighter posture) and ensure the agent re-applies `chmod 0644 root:root` (or 0600) on every rules file it loads. Reject (or refuse to honor) any rules file that is not owned by root.", + }, + "F1": { + "title": "Status / rules files contain no secrets", + "design": "World-readable status.json and AuthorizationRules_*.json must NOT contain key material, signatures, tokens, or HMACs.", + "automation": "`grep -aE '\"key\"|secret|signature|hmac|token'` over every matching file.", + "repro_script": "bash pentest/linux/phase5_state_fs/audit.sh", + "repro_manual": "grep -aE '\"key\"|secret|signature|hmac|token' /var/log/azure-proxy-agent/status.json /var/log/azure-proxy-agent/AuthorizationRules_*.json", + "fix": "If FAIL, redact the offending field in the writer (search for the field name across `proxy_agent/src/proxy/`).", + }, + "P5-bpftool": { + "title": "bpftool snapshots saved", + "design": "Captures `bpftool prog show` and `bpftool cgroup tree` for offline review.", + "automation": "`bpftool prog show > results/bpftool_prog_show.txt`, `bpftool cgroup tree > results/bpftool_cgroup_tree.txt`.", + "repro_script": "bash pentest/linux/phase5_state_fs/audit.sh", + "repro_manual": "sudo bpftool prog show\nsudo bpftool cgroup tree", + }, + + # ----------------------------------------------------------------------- + # Phase 4b — local-file rules. Same automation pattern for every scenario, + # so we describe the common shape once per scenario and add probe-specific + # fixes only where they actually FAILed in this run. + # ----------------------------------------------------------------------- + "PRE": { + "title": "Pre-flight: useLocalFileRules must be active", + "design": "Phase 4b only meaningful when the fabric delivers a ruleId whose decoded JSON has `useLocalFileRules: true`. Without it, the agent ignores the local files we write.", + "automation": "Reads latest `/var/log/azure-proxy-agent/AuthorizationRules_*.json` and looks for the substring `useLocalFileRules-true`.", + "repro_script": "sudo python3 pentest/linux/phase4b_local_rules/run.py", + "repro_manual": "ls -t /var/log/azure-proxy-agent/AuthorizationRules_*.json | head -1 | xargs grep useLocalFileRules-true", + "fix": "Toggle the flag from your control plane / mock fabric, then re-run.", + }, +} + + +# Phase 4b scenario metadata. We key on the scenario base ID (without the +# /probe_name suffix). Each entry covers ALL probes in the scenario. +PHASE_4B: dict[str, TestInfo] = { + # ---------------- IMDS ---------------- + "IMDS-S1-disabled-allow": { + "title": "Control: mode=disabled + allow", + "design": "Baseline. With enforcement off and default-allow, every IMDS probe must return 200.", + "automation": "Writes `/var/lib/azure-proxy-agent/rules/IMDS_Rules.json` with `mode=disabled, defaultAccess=allow`, waits 20s, GETs `/metadata/instance` and `/metadata/identity/oauth2/token`.", + }, + "IMDS-S2-enforce-deny-empty": { + "title": "Fail-closed when enforce+deny with no allow rules", + "design": "All IMDS calls must return 403 — proves enforcement engages.", + "automation": "Writes `mode=enforce, defaultAccess=deny, rules={}`; expects every probe → 403.", + }, + "IMDS-S3-audit-deny-empty": { + "title": "Local `mode` field must be ignored (regression guard)", + "design": "`LocalAuthorizationRulesFile` deliberately has no `mode` field, and `merge_authorization_item` keeps the remote rule's mode. This scenario writes `mode=audit, defaultAccess=deny, rules={}` to IMDS_Rules.json. PRE confirms the remote mode is `enforce`, so probes must 403. A 200 would mean the agent started honoring `mode` from the local file (audit-mode pass-through) — a security regression.", + "automation": "Writes `mode=audit, defaultAccess=deny, rules={}`; expects every probe → 403.", + "fix": "If this scenario flips to FAIL, someone added a `mode` field to `LocalAuthorizationRulesFile` or wired local `mode` into `merge_authorization_item`. Revert that change — audit mode must only be set by the remote (fabric-delivered) AuthorizationItem.", + }, + "IMDS-S4-allow-one-path": { + "title": "Allow exactly one path via privilege/role/identity/assignment", + "design": "An explicit allow for `/metadata/instance` for the current identity must let `instance` through (200) while everything else stays 403.", + "automation": "Writes a rule doc with one `privilege` (path=/metadata/instance), one `role`, one `identity` ({userName=root, groupName=root, exePath=/usr/bin/curl, processName=curl}), one `roleAssignment`. Probes: instance(200), token(403), versions(403).", + "fix": "instance came back 403 — identity match fails because the harness includes `exePath=/usr/bin/curl` but the actual probe is sent via python3 `http.client`, not curl. Two fixes: (1) Harness: in `phase4b_local_rules/run.py:get_current_user_identity()`, drop `exePath`/`processName` so the rule matches userName/groupName only. (2) Engine: confirm/document whether unset identity fields are wildcards vs strict-match — if they ARE wildcards, the test would already pass with #1.", + }, + "IMDS-S5-wrong-identity": { + "title": "Allow rule bound to non-matching identity", + "design": "Same allow rule, but identity userName=`nosuchuser_xyz`. Must 403.", + "automation": "Writes a rule with bogus identity and asserts `/metadata/instance` → 403.", + }, + "IMDS-S6-encoding-bypass": { + "title": "Encoding/path-traversal bypass attempts", + "design": "Allow only `/metadata/instance`. Probes try to reach `/metadata/identity/oauth2/token` via `%2F`, `%2f`, `%252F`, `%3F`, and `./../`. ALL must 403 — encoding must not let attackers reach denied paths.", + "automation": "Rule allows /metadata/instance; probes the encoded variants of the token path.", + "fix": "`token_dot_segments` returned 404 instead of 403. The dot-segments URL was forwarded raw to IMDS, which 404'd. Normalize `./` and `..` segments BEFORE rule matching in `authorization_rules.rs::is_match()`, then deny if normalization escapes the allowed prefix. Either way the GPA decision should be 403, not pass-through.", + }, + "IMDS-S7-query-param-required": { + "title": "Query parameter must match for allow", + "design": "Allow only when `api-version=2021-02-01`. Matching → 200, missing → 403, wrong value → 403.", + "automation": "Rule includes `queryParameters: {api-version: 2021-02-01}`; sends three variants.", + "fix": "`matching_version` returned 403 — same root cause as IMDS-S4 (identity exePath mismatch). Apply the S4 fix; re-run.", + }, + "IMDS-S8-group-only-identity": { + "title": "Identity match by groupName only", + "design": "Identity has only `groupName=root`. Must allow.", + "automation": "Writes identity with no userName/exePath; expects 200.", + }, + "IMDS-S9-exepath-identity": { + "title": "Per-process identity by exePath", + "design": "Allow only when `exePath=/usr/bin/curl`. Python http.client → 403; real `curl` subprocess → 200.", + "automation": "Two probes: one via http.client (denied), one via `subprocess.run(['curl', …])` (allowed).", + }, + "IMDS-S10-malformed-json": { + "title": "Malformed rules JSON → fail-closed", + "design": "If IMDS_Rules.json is invalid, every IMDS probe must 403 — never silently fall back to allow.", + "automation": "Writes the literal string `{ this is not valid json` and expects 403 on every probe.", + }, + "IMDS-S11-utf8-bom": { + "title": "UTF-8 BOM tolerated when parsing rules", + "design": "Writes IMDS_Rules.json prefixed with EF BB BF + a valid deny doc. Regression guard for `misc_helpers::json_read_from_file` BOM trim. A 200 here would mean the BOM-fix regressed and the agent silently fell back to no-rules / allow.", + "automation": "Prepends b'\\xef\\xbb\\xbf' to the JSON bytes before atomic-rename, then asserts 403 on every IMDS probe.", + }, + "IMDS-S12-duplicate-keys": { + "title": "Duplicate JSON keys resolve last-wins", + "design": "Raw JSON contains two `defaultAccess` entries (allow then deny). serde_json must honor RFC 8259 last-wins — a 200 would mean first-wins (silent escalation) or that the rule loaded as allow-by-default.", + "automation": "Writes the raw string verbatim and asserts 403.", + }, + "IMDS-S17-name-collision-shielded": { + "title": "Local rule names already containing LocalFileRules_ prefix", + "design": "Validates that `prefix_local_rule_names` rewrites both definitions AND cross-references consistently even when the admin pre-includes the prefix — so the rule still binds and grants 200 on /metadata/instance. 403 would mean the prefixer mishandled the doubled prefix and broke the role assignment.", + "automation": "All names in the local file already start with `LocalFileRules_`; probes expect 200 on /metadata/instance and 403 on /metadata/versions.", + }, + "IMDS-S18-dangling-references": { + "title": "Dangling role→privilege reference must reject", + "design": "`validate_local_rules_file` must reject; agent fail-closes to defaultAccess=deny. 200 would mean dangling-reference validation regressed.", + "automation": "Writes a role whose `privileges` array references a non-existent privilege name; expects 403 on every IMDS probe.", + }, + "IMDS-S19-identity-homoglyph": { + "title": "Cyrillic homoglyph in userName must NOT match ASCII user", + "design": "Identity `userName` uses Cyrillic о (U+043E) in place of ASCII 'o'. The matcher must compare bytes, not Unicode-normalized forms — 200 would mean Unicode normalization is happening in identity comparison and could be used to bypass authZ.", + "automation": "Rewrites the harness identity's userName with Cyrillic о substitutions; expects 403.", + }, + + # ---------------- WireServer ---------------- + "WS-S1-disabled-allow": { + "title": "Control: mode=disabled + allow (WireServer)", + "design": "Baseline 200s for goalstate and versions.", + "automation": "Writes WireServer_Rules.json with mode=disabled; probes goalstate and versions.", + }, + "WS-S2-enforce-deny-empty": { + "title": "Fail-closed enforce+deny (WireServer)", + "design": "All WireServer calls 403.", + "automation": "Writes mode=enforce, defaultAccess=deny, no rules.", + }, + "WS-S3-audit-deny-empty": { + "title": "Local `mode` field must be ignored (WireServer regression guard)", + "design": "Same contract as IMDS-S3: writes `mode=audit, defaultAccess=deny, rules={}` to WireServer_Rules.json. PRE confirms remote mode=`enforce`, so probes must 403. A 200 would mean the agent honored `mode` from the local file.", + "automation": "Writes `mode=audit, defaultAccess=deny, rules={}`; expects goalstate → 403 and versions → 403.", + "fix": "Same as IMDS-S3.", + }, + "WS-S4-allow-goalstate-only": { + "title": "Allow only /machine/?comp=goalstate", + "design": "goalstate → 200; sharedConfig / hosting / certs / extensionsConfig / versions → 403.", + "automation": "Writes rule with `path=/machine/, queryParameters={comp: goalstate}`.", + "fix": "`goalstate_allowed` returned 403 — same identity-exePath issue as IMDS-S4. Drop `exePath`/`processName` from the harness identity.", + }, + "WS-S5-wrong-identity": { + "title": "Allow goalstate but identity doesn't match", + "design": "Even goalstate → 403.", + "automation": "Writes rule whose identity userName=`nosuchuser_xyz`.", + }, + "WS-S6-encoding-bypass": { + "title": "Encoding/path-traversal bypass (WireServer)", + "design": "Allow only goalstate; encoded variants of `/machine/?comp=certificates` must all 403; `comp=GOALSTATE` (case-insensitive value) must 200.", + "automation": "Probes `/machine%2F?...`, `/machine/%3Fcomp=certificates`, `./../`, `//machine///`, `comp=Certificates`, `comp=GOALSTATE`.", + "fix": ( + "Two failures:\n" + "• `certs_dot_segments` returned 404 — same issue as IMDS-S6: normalize ./ and .. segments in the matcher before deciding, and deny when normalization escapes the allow prefix.\n" + "• `goalstate_value_case_should_match` returned 403 — likely the same identity-exePath issue (S4); after fixing S4 also confirm query-value comparison is `to_ascii_lowercase()` on both sides in `authorization_rules.rs`." + ), + }, + "WS-S7-query-param-required": { + "title": "Query parameter must match (WireServer)", + "design": "Only `comp=goalstate` allowed; matching/extra-param → 200, missing/wrong → 403.", + "automation": "Probes /machine/?comp=goalstate, /machine/, ?comp=hostingenvironmentconfig, ?comp=goalstate&incarnation=1.", + "fix": "`matching_comp` and `extra_param_ok` returned 403 — same identity-exePath issue as S4. After the harness fix verify that extra (non-required) query params don't break the rule matcher.", + }, + "WS-S8-group-only-identity": { + "title": "Identity match by groupName only (WireServer)", + "design": "groupName=root → goalstate 200.", + "automation": "Writes identity with only groupName.", + }, + "WS-S9-exepath-identity": { + "title": "Per-process identity by exePath (WireServer)", + "design": "python caller denied, real curl allowed.", + "automation": "http.client probe (403), then subprocess curl probe (200).", + }, + "WS-S10-malformed-json": { + "title": "Malformed rules JSON → fail-closed (WireServer)", + "design": "All WireServer probes 403.", + "automation": "Writes invalid JSON literal as WireServer_Rules.json.", + }, + "WS-S11-utf8-bom": { + "title": "UTF-8 BOM tolerated when parsing rules (WireServer)", + "design": "Same contract as IMDS-S11 against WireServer.", + "automation": "Prepends b'\\xef\\xbb\\xbf' to the WireServer_Rules.json bytes; expects 403.", + }, + "WS-S12-duplicate-keys": { + "title": "Duplicate JSON keys resolve last-wins (WireServer)", + "design": "Same contract as IMDS-S12 against WireServer.", + "automation": "Writes raw JSON with duplicate `defaultAccess` keys; expects 403.", + }, + "WS-S17-name-collision-shielded": { + "title": "Local rule names already containing LocalFileRules_ prefix (WireServer)", + "design": "Same contract as IMDS-S17 against /machine/. 200 on goalstate confirms the prefixer is idempotent.", + "automation": "All names in the local file already start with `LocalFileRules_`.", + }, + "WS-S18-dangling-references": { + "title": "Dangling role→privilege reference must reject (WireServer)", + "design": "Validator must reject; agent fail-closes.", + "automation": "Writes a role referencing a non-existent privilege; expects 403.", + }, + "WS-S19-identity-homoglyph": { + "title": "Cyrillic homoglyph in userName must NOT match (WireServer)", + "design": "Same contract as IMDS-S19 against /machine/.", + "automation": "Rewrites the harness identity's userName with Cyrillic о substitutions; expects 403.", + }, +} + + +_BRACKET_RE = re.compile(r"\[.*?\]") + + +def lookup(test_id: str) -> Optional[TestInfo]: + """Return TestInfo for an exact or normalized test ID.""" + if not test_id: + return None + if test_id in CATALOG: + return CATALOG[test_id] + if test_id in PHASE_4B: + return PHASE_4B[test_id] + # Strip [...] suffix (e.g. E1[/path]) + base = _BRACKET_RE.sub("", test_id).strip() + if base in CATALOG: + return CATALOG[base] + # Strip /probe suffix (e.g. IMDS-S4-allow-one-path/instance_allowed) + if "/" in test_id: + head = test_id.split("/", 1)[0] + if head in PHASE_4B: + return PHASE_4B[head] + if head in CATALOG: + return CATALOG[head] + return None + + +def all_known_ids() -> list[str]: + return list(CATALOG.keys()) + list(PHASE_4B.keys()) diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 new file mode 100644 index 00000000..3b630900 --- /dev/null +++ b/pentest/windows/Common.psm1 @@ -0,0 +1,344 @@ +# Common.psm1 — shared helpers for the Windows pen-test harness. +# +# Mirrors the Linux harness's record/append-to-findings.tsv contract so the +# same Python report generator (pentest/generate_report.py) can render Windows +# results without modification. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' # individual probes record FAIL, don't abort the suite + +# --- Paths ------------------------------------------------------------------- + +# Repo root = grandparent of this module file (\pentest\windows\..\..). +$script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$script:WindowsRoot = Join-Path $RepoRoot 'pentest\windows' +$script:ResultsDir = Join-Path $WindowsRoot 'results' +$script:Findings = Join-Path $ResultsDir 'findings.tsv' +$script:UrlDiffTsv = Join-Path $ResultsDir 'url_diff.tsv' +$script:Phase4bLog = Join-Path $ResultsDir 'phase4b_local_rules.log' + +# Windows GPA layout (from proxy_agent/config/GuestProxyAgent.windows.json +# and proxy_agent_setup/src/main.rs). +$script:GpaServiceName = 'GuestProxyAgent' +$script:GpaInstallRoot = "$env:SystemDrive\WindowsAzure\ProxyAgent" + +# Resolve the actual GuestProxyAgent.exe path. The install layout uses a +# versioned package directory (e.g. ...\Package_1.0.38\GuestProxyAgent.exe), so +# the canonical "...\Package\GuestProxyAgent.exe" path doesn't exist on +# real installs. Discover from the service's ImagePath first, then fall back to +# a Package* glob, then to the static legacy path. +function Resolve-GpaExePath { + param([string] $ServiceName, [string] $InstallRoot) + + try { + $svc = Get-CimInstance -ClassName Win32_Service ` + -Filter "Name='$ServiceName'" -ErrorAction Stop + if ($svc -and $svc.PathName) { + # PathName may be quoted and may carry trailing args, e.g. + # "C:\WindowsAzure\ProxyAgent\Package_1.0.38\GuestProxyAgent.exe" --service + $pn = $svc.PathName.Trim() + if ($pn.StartsWith('"')) { + $end = $pn.IndexOf('"', 1) + if ($end -gt 0) { $pn = $pn.Substring(1, $end - 1) } + } else { + $space = $pn.IndexOf(' ') + if ($space -gt 0) { $pn = $pn.Substring(0, $space) } + } + if ($pn -and (Test-Path -LiteralPath $pn)) { return $pn } + } + } catch { } + + # Fallback: newest Package* directory under the install root. + if (Test-Path -LiteralPath $InstallRoot) { + $cand = Get-ChildItem -LiteralPath $InstallRoot -Directory ` + -Filter 'Package*' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + ForEach-Object { Join-Path $_.FullName 'GuestProxyAgent.exe' } | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 + if ($cand) { return $cand } + } + + # Last resort: legacy/expected static path. May not exist; callers handle that. + return (Join-Path $InstallRoot 'Package\GuestProxyAgent.exe') +} + +$script:GpaExe = Resolve-GpaExePath -ServiceName $GpaServiceName -InstallRoot $GpaInstallRoot +$script:GpaLogDir = Join-Path $GpaInstallRoot 'Logs' +$script:GpaEventDir = Join-Path $GpaInstallRoot 'Events' +$script:GpaKeyDir = Join-Path $GpaInstallRoot 'Keys' +$script:GpaRulesDir = Join-Path $GpaInstallRoot 'Rules' + +# Fabric endpoints (identical to Linux). +$script:ImdsIp = '169.254.169.254' +$script:WireServerIp = '168.63.129.16' +$script:HostGaPort = 32526 +$script:GpaListenerPort = 3080 +$script:GpaListener = "127.0.0.1:$GpaListenerPort" + +$script:ImdsUrl = "http://$ImdsIp/metadata/instance?api-version=2021-02-01" +$script:ImdsHeaders = @{ 'Metadata' = 'true' } +$script:WireServerUrl = "http://$WireServerIp/machine/?comp=goalstate" +$script:WireHeaders = @{ 'x-ms-version' = '2012-11-30' } + +if (-not (Test-Path $ResultsDir)) { + New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null +} + +# --- record() — append a single finding row to findings.tsv ------------------ +# +# Columns: \t\t\t +# status ∈ { PASS, FAIL, INFO } + +function Write-Finding { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Id, + [Parameter(Mandatory)] [ValidateSet('PASS','FAIL','INFO','DEBUG')] [string] $Status, + [Parameter(Mandatory)] [string] $Message + ) + # Sanitize message: strip TAB and CR/LF so the TSV stays one row per record. + $clean = ($Message -replace "[\t\r\n]+", ' ').Trim() + $ts = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + $row = "$ts`t$Id`t$Status`t$clean" + Add-Content -Path $Findings -Value $row -Encoding UTF8 + + $color = switch ($Status) { + 'PASS' { 'Green' } + 'FAIL' { 'Red' } + 'DEBUG' { 'DarkGray' } + default { 'Yellow' } + } + Write-Host "[$Status] $Id`t$clean" -ForegroundColor $color +} + +# Write a diagnostic trace row. Persisted to findings.tsv with status=DEBUG so +# the audit trail is complete, but Generate-Report.ps1 filters DEBUG rows out +# of the rendered report so they don't show up as test findings. +function Write-FindingDebug { + param( + [Parameter(Mandatory)] [string] $Id, + [Parameter(Mandatory)] [string] $Message + ) + Write-Finding -Id $Id -Status DEBUG -Message $Message +} + +# --- Convenience wrappers ---------------------------------------------------- + +function Test-ServiceRunning { + param([string] $Name) + try { (Get-Service -Name $Name -ErrorAction Stop).Status -eq 'Running' } + catch { $false } +} + +function Get-HttpStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Url, + [hashtable] $Headers = @{}, + [int] $TimeoutSec = 8, + [string] $Method = 'GET', + [System.Net.ICredentials] $Credentials + ) + try { + $resp = Invoke-WebRequest -Uri $Url -Headers $Headers -Method $Method ` + -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop + return [pscustomobject]@{ Code = [int]$resp.StatusCode; Body = $resp.Content } + } catch [System.Net.WebException] { + $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } + return [pscustomobject]@{ Code = $code; Body = '' } + } catch { + return [pscustomobject]@{ Code = 0; Body = '' } + } +} + +function Get-Sha256 { + param([string] $Text) + if (-not $Text) { return '' } + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } finally { $sha.Dispose() } +} + +function Test-Administrator { + $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $p = New-Object System.Security.Principal.WindowsPrincipal($id) + $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# --- Temporary standard-user account ---------------------------------------- +# +# Some pen-test scenarios (notably C1) require a probe to be issued from a +# non-elevated, non-Administrator account. When the harness is launched as +# Administrator, we provision a throwaway local user, share the credential +# in *global* scope (not $script:, because phase scripts re-import this +# module with -Force, which would wipe module-scope state) so individual +# phase scripts can pick it up via Get-PenTestStandardUserCredential, and +# remove the account at the end of the run via Remove-PenTestStandardUser. +# The password is generated locally with a CSPRNG and never written to disk. + +# Initialise global state once. Set-StrictMode forbids reading an unset +# variable, so probe via Test-Path before assigning. +if (-not (Test-Path Variable:Global:PenTestStdUser)) { $global:PenTestStdUser = $null } # PSCredential +if (-not (Test-Path Variable:Global:PenTestStdUserName)) { $global:PenTestStdUserName = $null } # string +if (-not (Test-Path Variable:Global:PenTestStdUserCreated)) { $global:PenTestStdUserCreated = $false } # whether *we* created the account + +function Get-PenTestStdUserState { + [pscustomobject]@{ + Cred = (Get-Variable -Scope Global -Name PenTestStdUser -ValueOnly -ErrorAction SilentlyContinue) + Name = (Get-Variable -Scope Global -Name PenTestStdUserName -ValueOnly -ErrorAction SilentlyContinue) + Created = [bool](Get-Variable -Scope Global -Name PenTestStdUserCreated -ValueOnly -ErrorAction SilentlyContinue) + } +} + +function New-PenTestRandomPassword { + param([int] $Length = 24) + # 4 char classes so the new local user satisfies the default Windows + # complexity policy regardless of how the host is configured. + $upper = [char[]]'ABCDEFGHJKLMNPQRSTUVWXYZ' + $lower = [char[]]'abcdefghijkmnopqrstuvwxyz' + $digit = [char[]]'23456789' + $sym = [char[]]'!@#$%^&*()-_=+[]{}' + $all = $upper + $lower + $digit + $sym + + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { + function Get-RandomChar([char[]] $Set) { + $buf = New-Object byte[] 4 + $rng.GetBytes($buf) + $u = [BitConverter]::ToUInt32($buf, 0) + $Set[[int]($u % [uint32]$Set.Length)] + } + $chars = New-Object System.Collections.Generic.List[char] + $chars.Add((Get-RandomChar $upper)) | Out-Null + $chars.Add((Get-RandomChar $lower)) | Out-Null + $chars.Add((Get-RandomChar $digit)) | Out-Null + $chars.Add((Get-RandomChar $sym)) | Out-Null + for ($i = $chars.Count; $i -lt $Length; $i++) { + $chars.Add((Get-RandomChar $all)) | Out-Null + } + # Fisher-Yates shuffle so the guaranteed class chars aren't always at the front. + for ($i = $chars.Count - 1; $i -gt 0; $i--) { + $buf = New-Object byte[] 4 + $rng.GetBytes($buf) + $j = [int]([BitConverter]::ToUInt32($buf, 0) % [uint32]($i + 1)) + $tmp = $chars[$i]; $chars[$i] = $chars[$j]; $chars[$j] = $tmp + } + -join $chars + } finally { $rng.Dispose() } +} + +function New-PenTestStandardUser { + [CmdletBinding()] + param([string] $NamePrefix = 'gpapen') + + if (-not (Test-Administrator)) { + Write-Finding PRE INFO "not Administrator; cannot provision standard-user account for C1" + return $null + } + $existing = (Get-PenTestStdUserState).Cred + if ($existing) { return $existing } + if (-not (Get-Command New-LocalUser -ErrorAction SilentlyContinue)) { + Write-Finding PRE INFO "Microsoft.PowerShell.LocalAccounts module unavailable; cannot create standard-user" + return $null + } + + # Username must be <=20 chars; suffix with 8 random hex. + $suffix = ([guid]::NewGuid().ToString('N')).Substring(0, 8) + $name = "$NamePrefix$suffix" + $pwd = New-PenTestRandomPassword + $secure = ConvertTo-SecureString $pwd -AsPlainText -Force + + try { + # New-LocalUser caps -Description at 48 chars. + New-LocalUser -Name $name -Password $secure ` + -FullName 'GPA pen-test ephemeral standard user' ` + -Description 'GPA pentest harness; safe to delete.' ` + -PasswordNeverExpires -UserMayNotChangePassword ` + -AccountNeverExpires -ErrorAction Stop | Out-Null + Add-LocalGroupMember -Group 'Users' -Member $name -ErrorAction Stop + + $global:PenTestStdUserName = $name + $global:PenTestStdUserCreated = $true + $global:PenTestStdUser = New-Object System.Management.Automation.PSCredential( + "$env:COMPUTERNAME\$name", $secure) + Write-Finding PRE INFO "provisioned ephemeral standard user '$name' (member of Users); password is in-memory only" + return $global:PenTestStdUser + } catch { + Write-Finding PRE INFO "could not create ephemeral standard user: $_" + # Best-effort partial cleanup. + Get-LocalUser -Name $name -ErrorAction SilentlyContinue | Remove-LocalUser -ErrorAction SilentlyContinue + return $null + } +} + +function Get-PenTestStandardUserCredential { (Get-PenTestStdUserState).Cred } + +function Remove-PenTestStandardUser { + $state = Get-PenTestStdUserState + if (-not $state.Created) { return } + $name = $state.Name + if (-not $name) { return } + + $errors = New-Object System.Collections.Generic.List[string] + + # 1) Best-effort: kill any leftover processes the user may have. Use + # Get-Process -IncludeUserName (PS 5+) since CimInstance Win32_Process + # has no .GetOwner() method (that's WMI-only and was removed in PS 7). + try { + $procs = Get-Process -IncludeUserName -ErrorAction SilentlyContinue | + Where-Object { $_.UserName -like "*\$name" } + foreach ($pr in $procs) { + try { Stop-Process -Id $pr.Id -Force -ErrorAction Stop } + catch { $errors.Add("kill PID=$($pr.Id): $_") | Out-Null } + } + } catch { $errors.Add("enumerate processes: $_") | Out-Null } + + # 2) Drop group membership (idempotent; ignore "not a member"). + try { + Remove-LocalGroupMember -Group 'Users' -Member $name -ErrorAction Stop + } catch { + if ($_.Exception.Message -notmatch 'not a member|could not be found') { + $errors.Add("remove from Users: $_") | Out-Null + } + } + + # 3) Remove the local account itself. This is the step that actually + # matters; do not skip it if (1)/(2) failed. + $accountRemoved = $false + try { + if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) { + Remove-LocalUser -Name $name -ErrorAction Stop + } + $accountRemoved = $true + } catch { + $errors.Add("Remove-LocalUser: $_") | Out-Null + } + + # 4) Remove the user profile directory and its Win32_UserProfile entry. + try { + $profilePath = Join-Path $env:SystemDrive "Users\$name" + if (Test-Path $profilePath) { + Remove-Item -Path $profilePath -Recurse -Force -ErrorAction SilentlyContinue + } + Get-CimInstance Win32_UserProfile -ErrorAction SilentlyContinue | + Where-Object { $_.LocalPath -like "*\$name" } | + ForEach-Object { Remove-CimInstance -InputObject $_ -ErrorAction SilentlyContinue } + } catch { $errors.Add("profile cleanup: $_") | Out-Null } + + if ($accountRemoved -and $errors.Count -eq 0) { + Write-Finding PRE INFO "removed ephemeral standard user '$name'" + } elseif ($accountRemoved) { + Write-Finding PRE INFO "removed ephemeral standard user '$name' with warnings: $($errors -join '; ')" + } else { + Write-Finding PRE INFO "could not fully remove ephemeral standard user '$name': $($errors -join '; ') (manual cleanup: 'net user $name /delete' and 'rmdir /s /q C:\Users\$name')" + } + + $global:PenTestStdUser = $null + $global:PenTestStdUserName = $null + $global:PenTestStdUserCreated = $false +} + +Export-ModuleMember -Function * -Variable * diff --git a/pentest/windows/DESIGN.md b/pentest/windows/DESIGN.md new file mode 100644 index 00000000..9ce0d4d8 --- /dev/null +++ b/pentest/windows/DESIGN.md @@ -0,0 +1,211 @@ +# GPA Pen-Test Design — Windows + +Target: Guest Proxy Agent (GPA) running on Windows +(service `GuestProxyAgent`, listener `127.0.0.1:3080`, **WFP** (Windows +Filtering Platform) callouts redirecting IMDS `169.254.169.254:80`, +WireServer `168.63.129.16:80`, HostGAPlugin `168.63.129.16:32526`). + +This document is the Windows companion to +[../linux/DESIGN.md](../linux/DESIGN.md). The threat model and scenario IDs +(`A1`, `B1b`, `C7`, `IMDS-S6`, …) are intentionally identical so findings can +be compared across platforms; only the *implementation* of each probe differs. + +--- + +## 1. Scope & Trust Model + +### In scope (Windows enforcement boundary) +- TCP listener `127.0.0.1:3080` (`GuestProxyAgent.exe`). +- WFP callout(s) registered by the agent that intercept connections to + IMDS / WireServer / HostGAPlugin and rewrite the destination to the local + listener. +- HMAC signing path injected by GPA on authorized requests + (`x-ms-azure-signature`). +- AuthZ engine in [proxy_authorizer.rs](../../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../../proxy_agent/src/proxy/authorization_rules.rs). +- Key-keeper / latch state under + `%SystemDrive%\WindowsAzure\ProxyAgent\Keys` and config in + [GuestProxyAgent.windows.json](../../proxy_agent/config/GuestProxyAgent.windows.json). +- Provisioning, status, and log surfaces under + `%SystemDrive%\WindowsAzure\ProxyAgent\Logs` and `…\Events`. +- Service binary: `%SystemDrive%\WindowsAzure\ProxyAgent\GuestProxyAgent\GuestProxyAgent.exe`. + +### Out of scope +- WireServer / IMDS server-side responses, host fabric, Azure control plane. +- Windows kernel / WFP framework CVEs unrelated to GPA. + +### Attacker models to test against +1. **Standard-user** Windows account (no admin) — primary threat: confused-deputy / SSRF. +2. **Local Administrator** — GPA must still not be a privilege oracle for + WireServer if not allowed. +3. **Workload inside a Windows container** (Server Containers, Hyper-V + isolation) — verify the WFP redirect still applies, or the container + network is denied entirely. +4. **Network-adjacent attacker** (vNet peer, NSG misconfig) — must not + authenticate. +5. **Local malicious binary with ACL/symlink tricks** — can it forge + `processFullPath` / `exePath` identity? + +--- + +## 2. Pen-Test Scenarios (mapped to GPA invariants) + +Scenarios are identical to the Linux harness; only the **implementation** +column differs. See [../linux/DESIGN.md](../linux/DESIGN.md) for the full +"why this matters" rationale per scenario. + +### A. Network exposure / listener hardening + +| ID | Test (Windows implementation) | Expected | +|----|-------------------------------|----------| +| A1 | `Get-NetTCPConnection -State Listen | Where LocalPort -eq 3080`. | Only loopback (`127.0.0.1` / `::1`); FAIL on any external/wildcard bind. | +| A1b | `Test-NetConnection 127.0.0.1 -Port 3080`. | Connection accepted. | +| A2 | `Test-NetConnection -Port 3080` against every non-loopback IPv4 from `Get-NetIPAddress`. | All refused. | +| A3 | Send 64 KB `X-Big:` header via raw `TcpClient`; re-check `Get-Service GuestProxyAgent` (recorded as `A3-survive`). | Graceful close, service still Running. | +| A4 | Raw `CONNECT example.com:443 HTTP/1.1` over `TcpClient`. | `HTTP/1.1 405 Method Not Allowed`. | +| A5 | `HttpWebRequest` with `WebProxy("http://127.0.0.1:3080")` to `http://example.com/`. | Non-2xx — GPA refuses to act as a forward proxy. | +| G1 | 200 sequential `TcpClient.Connect` to `:3080`, then re-check service status. | Service still Running. | + +### B. AuthN bypass — forging the signature + +| ID | Test (Windows implementation) | Expected | +|----|-------------------------------|----------| +| B1a | `Invoke-WebRequest http://169.254.169.254/metadata/instance` with `Metadata: true`. | 200; capture body sha256 as canonical for C7. | +| B1b | Open `TcpClient` to `169.254.169.254:80`; read `Get-NetTCPConnection -LocalPort -State Established`. | Kernel reports the established peer as `127.0.0.1:3080` (proves WFP redirect is active for THIS process). FAIL if it shows the raw IMDS IP. | +| B3 | `Get-Acl` on every file under `…\ProxyAgent\Keys`. | No Read/Modify/FullControl ACE for `Users` (S-1-5-32-545), `Authenticated Users` (S-1-5-11), `Everyone` (S-1-1-0), or `Anonymous` (S-1-5-7). | +| B4 | `Invoke-WebRequest` with forged `x-ms-azure-signature: AAAA-attacker-supplied` header. | 200 (GPA overwrote it; verify in pktmon capture that on-wire header value differs). | + +### C. AuthZ bypass + +| ID | Test (Windows implementation) | Expected | +|----|-------------------------------|----------| +| C1 | If harness was launched as a standard user, probe WireServer directly. If launched as Administrator, attempt `runas /trustlevel:0x20000` (basic user) — `runas` cannot return stdout reliably, so this case is recorded as INFO with a manual-repro hint. | Standard-user probe → 401/403. INFO when not testable. | +| C5 | `Get-WindowsOptionalFeature -Online -FeatureName Containers`. | INFO. Manual verification required: from inside a container, IMDS must reach GPA or fail entirely. | +| C7 | `Invoke-WebRequest` against `http://0xa9fea9fe/...` and `http://2852039166/...`; compare status + body sha256 to canonical from B1a. | PASS on parity, INFO on body-only drift, PASS on 4xx, FAIL on differing non-4xx. | + +### D / 4b. AuthZ rule engine — local-file rules + +Phase 4b ([Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1)) is the +PowerShell port of the Linux Phase 4b harness and runs the **same 30 +scenarios** (`IMDS-S1`…`S10` + `IMDS-S11`/`S12`/`S17`/`S18`/`S19`, and +the matching `WS-` set). Pre-flight, scenario construction, +refresh-wait, probe matrix, and triage rules are identical. See the +Linux design doc's [Phase 4b section](../linux/DESIGN.md) for the +per-scenario rationale. + +Windows-specific deltas in Phase 4b: + +- **Rules dir**: `%SystemDrive%\WindowsAzure\ProxyAgent\Rules\` (vs + `/var/lib/azure-proxy-agent/rules/`). +- **Identity** is captured from `WindowsIdentity::GetCurrent()`. The default + identity used in non-S9 scenarios pins only `userName` + `groupName` + (`Administrators`); `exePath` / `processName` are deliberately left UNSET so + the matcher's wildcard semantics apply (avoids false-alarm 403s when probes + come from `pwsh.exe` or `powershell.exe`). +- **S9 (`exePath` identity)** allow-lists the *current* PowerShell + interpreter (`(Get-Process -Id $PID).Path`); the negative side issues a + probe via `curl.exe` (shipped with Windows 10/11) which must be denied + because its `exePath` differs. +- **S3 (audit mode)** is recorded as INFO for the same reason as Linux: the + agent's local-rules merge logic in + [local_rules.rs](../../proxy_agent/src/key_keeper/local_rules.rs) takes + `mode` only from the remote (fabric-delivered) AuthorizationItem. + +### E / F. Filesystem / state audit + +| ID | Test (Windows implementation) | Expected | +|----|-------------------------------|----------| +| E1 | `Get-Acl` on `…\Keys` (with `-SecretsOnly`), `…\Logs`, `…\GuestProxyAgent\GuestProxyAgent.exe`, install root, and per-file under Keys. | No `Write`/`Modify`/`FullControl`/`Delete`/`ChangePermissions`/`TakeOwnership` ACE for principals outside `{NT AUTHORITY\SYSTEM, BUILTIN\Administrators, NT SERVICE\TrustedInstaller, COMPUTERNAME\Administrator}`. Secrets paths additionally FAIL on any `Read` from non-admin principals. | +| F1 | Regex `"(key|secret|signature|hmac|token)"\s*:\s*"[^"]+"` against `status.json` + `AuthorizationRules_*.json` under `…\Logs`. | No match. | +| F4 | `Get-Acl` on `…\Rules` (NOT `-SecretsOnly` — rules are policy, not secrets; only `Write`/`Modify`/`FullControl`/`Delete`/`ChangePermissions`/`TakeOwnership` are flagged). Local rules are admin-controlled authZ INPUT: any non-admin WRITE here lets an unprivileged user inject `useLocalFileRules` content and short-circuit remote enforcement. | Write-class ACEs restricted to the same admin/SYSTEM principal set as E1. | +| P5-svc | `Get-Service GuestProxyAgent`. | INFO with status / start type. | +| P5-wfp | `netsh wfp show filters file=results\wfp_filters_windows.txt`. | Saved snapshot for offline review (Windows analog of Linux `bpftool prog show`). | + +--- + +## 3. Execution Plan + +### Phase 0 — Prep +- Snapshot the VM (or at least back up `%SystemDrive%\WindowsAzure\ProxyAgent`). +- Record baseline: `Get-Service GuestProxyAgent`, + `Get-NetTCPConnection -State Listen`, + `netsh wfp show filters file=baseline.txt`, + ACLs of Keys/Logs/exe, `Get-FileHash GuestProxyAgent.exe`. + +### Phase 2 — listener / DoS +- A1, A1b, A2, A3, A4, A5, G1 → [Phase2-Listener.ps1](Phase2-Listener.ps1). + +### Phase 3 — AuthN / AuthZ +- P3-cap, B1a, B1b, B3, B4, C1, C5, C7 → [Phase3-AuthN-AuthZ.ps1](Phase3-AuthN-AuthZ.ps1). +- Captures `phase3-windows-*.etl` via `pktmon` for offline analysis (convert + to `.pcapng` via `pktmon etl2pcap` if needed). + +### Phase 4 — URL / encoding diff +- 11 URL variants → [Phase4-RulesFuzz.ps1](Phase4-RulesFuzz.ps1). +- Output: `results\url_diff.tsv`. Latest run is rendered in the report + appendix. + +### Phase 4b — local-file rules +- IMDS S1–S10 + IMDS S11/S12/S17/S18/S19 + WS S1–S10 + WS S11/S12/S17/S18/S19 → [Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1). +- **Pre-conditions** (auto-checked, fail-fast `PRE FAIL`): + 1. `useLocalFileRules-true` substring present in the latest + `AuthorizationRules_*.json`. + 2. Effective remote `mode` is `Enforce` for the chosen target(s). +- Backups of any pre-existing `IMDS_Rules.json` / `WireServer_Rules.json` + are restored on exit (including on `Ctrl+C` / crash). +- Run by the driver unconditionally; the target matrix can be narrowed + with `-Phase4bTarget {imds|wireserver|both}` (default `both`). + +### Phase 5 — Filesystem / state audit +- E1, F1, F4, P5-svc, P5-wfp → [Phase5-FileSystemAudit.ps1](Phase5-FileSystemAudit.ps1). + +### Phase 7 — Triage & report +- All harness records are appended (TSV) to + `pentest\windows\results\findings.tsv` with the same schema as the Linux + harness: + ``` + \t\t\t + ``` +- HTML report is built by [Generate-Report.ps1](Generate-Report.ps1) and + written to `pentest\windows\results\report.html`. Report sections (mirror + of the Linux generator): + 1. **Run configuration & harness setup** — `CFG`/`PRE` rows. + 2. **Failures requiring triage**. + 3. **Informational findings**. + 4. **Per-phase tables**. + 5. **Phase 4 URL/encoding differential** — latest `url_diff.tsv`. + 6. **Cheat sheet & artifacts**. +- Driver: [Run-AllPenTests.ps1](Run-AllPenTests.ps1) runs phases 2 / 3 / 4 / 4b / 5 + in order, then renders the HTML report. Phase 4b can be scoped with + `-Phase4bTarget {imds|wireserver|both}`; skip the report with `-NoReport`. + +--- + +## 4. Tooling staged on this box + +Built-in to Windows 10/11 / Server 2019+: +- `pktmon` (packet capture, ETL output) +- `netsh wfp show filters` (WFP snapshot) +- `Get-NetTCPConnection`, `Test-NetConnection`, `Get-NetIPAddress` +- `Get-Acl` / DACL inspection +- `curl.exe` (used by Phase 4b S9 negative probe) + +No third-party tools or Python required. + +--- + +## 5. Cross-platform mapping (Linux concept → Windows implementation) + +| Linux | Windows | +|---|---| +| cgroup-attached eBPF redirect | WFP (Windows Filtering Platform) callouts in `GuestProxyAgent` service | +| `ss -tnp` (B1b kernel-side proof) | `Get-NetTCPConnection` keyed on local ephemeral port | +| `tcpdump -i any` (P3-cap) | `pktmon start --etw -f *.etl` filtered on IMDS / WireServer / `:3080` | +| `bpftool prog show` / `cgroup tree` | `netsh wfp show filters` | +| POSIX modes (E1) | DACL ACEs (`Get-Acl`) — flag non-{SYSTEM, Administrators, TrustedInstaller} write/modify; secrets paths flag any non-admin read | +| `sudo -u nobody curl …` (C1) | Re-run from a standard-user PowerShell (no analogous always-present unprivileged account) | +| `unshare -Cr` (C5) | INFO note — `Get-WindowsOptionalFeature Containers`; manual verification | +| `/var/lib/azure-proxy-agent/keys` | `%SystemDrive%\WindowsAzure\ProxyAgent\Keys` | +| `/var/log/azure-proxy-agent` | `%SystemDrive%\WindowsAzure\ProxyAgent\Logs` | +| `/var/lib/azure-proxy-agent/rules` | `%SystemDrive%\WindowsAzure\ProxyAgent\Rules` | +| `/usr/sbin/azure-proxy-agent` | `%SystemDrive%\WindowsAzure\ProxyAgent\GuestProxyAgent\GuestProxyAgent.exe` | +| `python3 generate_report.py` | `Generate-Report.ps1` (pure PowerShell, no Python dependency) | diff --git a/pentest/windows/Generate-Report.ps1 b/pentest/windows/Generate-Report.ps1 new file mode 100644 index 00000000..99766d39 --- /dev/null +++ b/pentest/windows/Generate-Report.ps1 @@ -0,0 +1,412 @@ +# Generate-Report.ps1 — Windows port of pentest/linux/generate_report.py. +# +# Builds an HTML pen-test report from results\findings.tsv (and the latest +# results\url_diff.tsv run, if present). Pure PowerShell — no Python, no +# external dependencies. Output: results\report.html. + +[CmdletBinding()] +param([string] $Output) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'TestCatalog.psm1') -Force + +if (-not $Output) { $Output = Join-Path $ResultsDir 'report.html' } + +# --- helpers ---------------------------------------------------------------- + +$PhasePatterns = @( + @{ Name = 'Phase 2 — Listener / DoS (A,G)'; Regex = '^(A\d|G\d)' } + @{ Name = 'Phase 3 — AuthN / AuthZ (B,C,P3)'; Regex = '^(B\d|C\d|P3)' } + @{ Name = 'Phase 4b — Local-file rules (IMDS)'; Regex = '^IMDS-S' } + @{ Name = 'Phase 4b — Local-file rules (WireServer)'; Regex = '^WS-S' } + @{ Name = 'Phase 4b — pre-flight'; Regex = '^PRE\b' } + @{ Name = 'Phase 5 — Filesystem / state (E,F,P5)'; Regex = '^(E\d|F\d|P5)' } +) + +function Get-PhaseFor { + param([string] $Id) + foreach ($p in $PhasePatterns) { + if ($Id -match $p.Regex) { return $p.Name } + } + return 'Other' +} + +function HtmlEncode { + param([string] $S) + if ($null -eq $S) { return '' } + return [System.Net.WebUtility]::HtmlEncode($S) +} + +function Get-Findings { + if (-not (Test-Path $Findings)) { return @() } + $rows = @() + foreach ($line in Get-Content -Path $Findings) { + if (-not $line) { continue } + $parts = $line -split "`t", 4 + while ($parts.Count -lt 4) { $parts += '' } + $status = $parts[2].ToUpper() + # DEBUG rows are diagnostic traces (e.g. C1 spawn details); keep them in + # the TSV for offline inspection but never show them in the report. + if ($status -eq 'DEBUG') { continue } + $rows += [pscustomobject]@{ + Ts = $parts[0] + Id = $parts[1] + Status = $status + Msg = $parts[3] + Phase = Get-PhaseFor $parts[1] + } + } + + # Trim stale rows from earlier runs. Run-AllPenTests.ps1 writes a + # `CFG INFO platform = Windows ...` row as the first line of every run; + # we keep only rows from the most recent such marker onward so the report + # reflects the latest run, even when findings.tsv was not truncated. + # NOTE: the marker is searched against the *unfiltered* file contents + # because we already stripped DEBUG rows above; the CFG row is INFO so it + # is always present in $rows. + $startIdx = -1 + for ($i = $rows.Count - 1; $i -ge 0; $i--) { + if ($rows[$i].Id -eq 'CFG' -and $rows[$i].Msg -like 'platform =*') { + $startIdx = $i; break + } + } + if ($startIdx -gt 0) { $rows = $rows[$startIdx..($rows.Count - 1)] } + return $rows +} + +function Get-UrlDiffLatest { + if (-not (Test-Path $UrlDiffTsv)) { return @() } + $rows = @() + foreach ($line in Get-Content -Path $UrlDiffTsv) { + if ($line) { $rows += , ($line -split "`t") } + } + if (-not $rows) { return @() } + $lastTs = $rows[-1][0] + return $rows | Where-Object { $_[0] -eq $lastTs } +} + +# --- detail block (per-row design / repro / fix) ---------------------------- + +function Get-DetailHtml { + param([pscustomobject] $Row) + $info = Get-CatalogEntry -TestId $Row.Id + if (-not $info) { + return "no catalog entry for this ID" + } + $parts = New-Object System.Collections.Generic.List[string] + + if ($info.Title) { + $parts.Add("
$(HtmlEncode $info.Title)
") + } + function Section($label, $body, $mono) { + $cls = if ($mono) { 'mono' } else { '' } + return "
$(HtmlEncode $label)
" + + "
$(HtmlEncode $body)
" + } + if ($info.Design) { $parts.Add( (Section 'Design' $info.Design $false) ) } + if ($info.Automation) { $parts.Add( (Section 'Automation' $info.Automation $false) ) } + if ($info.ReproScript) { $parts.Add( (Section 'Repro (script)' $info.ReproScript $true ) ) } + if ($info.ReproManual) { $parts.Add( (Section 'Repro (manual)' $info.ReproManual $true ) ) } + if ($Row.Status -eq 'FAIL' -and $info.Fix) { + $parts.Add("
Suggested fix
" + + "
$(HtmlEncode $info.Fix)
") + } + return ($parts -join '') +} + +function Get-RowHtml { + param([pscustomobject] $Row, [bool] $WithPhaseCol) + $cls = switch ($Row.Status) { 'FAIL' { 'fail' } 'PASS' { 'pass' } default { 'info' } } + $sev = if ($Row.Status -eq 'FAIL' -and $WithPhaseCol) { 'High' } else { $Row.Status } + $openAttr = if ($Row.Status -eq 'FAIL') { ' open' } else { '' } + $cells = New-Object System.Collections.Generic.List[string] + $cells.Add("$(HtmlEncode $Row.Ts)") + $cells.Add("$(HtmlEncode $Row.Id)") + if ($WithPhaseCol) { $cells.Add("$(HtmlEncode $Row.Phase)") } + $cells.Add("$(HtmlEncode $sev)") + $cells.Add("
$(HtmlEncode $Row.Msg)
" + + "
details" + + "
$(Get-DetailHtml -Row $Row)
") + return '' + ($cells -join '') + '' +} + +# --- CSS / JS (kept in lockstep with linux/generate_report.py) -------------- + +$Css = @' +:root { color-scheme: light dark; } +* { box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin: 0; padding: 24px; background: #f6f8fa; color: #24292f; } +h1 { margin: 0 0 4px 0; font-size: 24px; } +.sub { color: #57606a; margin-bottom: 24px; font-size: 13px; } +.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 24px; } +.card { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 16px 20px; flex: 1 1 140px; } +.card .num { font-size: 28px; font-weight: 700; } +.card .lbl { font-size: 12px; color: #57606a; text-transform: uppercase; letter-spacing: .04em; } +.card.pass .num { color: #1a7f37; } +.card.fail .num { color: #cf222e; } +.card.info .num { color: #9a6700; } +section { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; margin-bottom: 20px; } +section > h2 { margin: 0; padding: 12px 16px; border-bottom: 1px solid #d0d7de; font-size: 16px; + background: #f6f8fa; border-radius: 8px 8px 0 0; } +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eaeef2; vertical-align: top; } +th { background: #f6f8fa; font-weight: 600; color: #57606a; } +tr:last-child > td { border-bottom: none; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; } +.badge.pass { background: #dafbe1; color: #116329; } +.badge.fail { background: #ffebe9; color: #82071e; } +.badge.info { background: #fff8c5; color: #7d4e00; } +.id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.msg { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; margin-bottom: 4px; } +.ts { color: #57606a; white-space: nowrap; font-variant-numeric: tabular-nums; } +details > summary { cursor: pointer; color: #0969da; font-size: 12px; list-style: none; } +details > summary::-webkit-details-marker { display: none; } +details > summary::before { content: '▸ '; transition: transform .15s; } +details[open] > summary::before { content: '▾ '; } +.detbox { display: flex; flex-direction: column; gap: 6px; padding: 8px 0 4px 0; } +.dettitle { font-weight: 600; color: #24292f; } +.det { display: flex; gap: 10px; flex-wrap: wrap; } +.detlbl { flex: 0 0 110px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #57606a; padding-top: 2px; } +.detval { flex: 1 1 320px; font-size: 12px; } +.detval.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; + background: #f6f8fa; border: 1px solid #eaeef2; border-radius: 4px; padding: 6px 8px; } +.det.fix .detlbl { color: #cf222e; } +.det.fix .detval { background: #ffebe9; border: 1px solid #ffcecb; border-radius: 4px; padding: 6px 8px; color: #82071e; white-space: pre-wrap; } +.nodet { color: #8c959f; font-size: 12px; font-style: italic; } +.legend { font-size: 12px; color: #57606a; margin-bottom: 12px; } +.footer { color: #57606a; font-size: 12px; margin-top: 24px; text-align: center; } +.meta { font-size: 12px; color: #57606a; padding: 8px 16px 0; } +.toolbar { display: flex; gap: 8px; align-items: center; padding: 6px 16px; border-bottom: 1px solid #eaeef2; + background: #fafbfc; font-size: 12px; } +.toolbar button { font: inherit; padding: 3px 10px; border: 1px solid #d0d7de; border-radius: 6px; + background: #fff; color: #24292f; cursor: pointer; } +.toolbar button:hover { background: #eef1f4; } +@media (prefers-color-scheme: dark) { + body { background: #0d1117; color: #c9d1d9; } + .card, section { background: #161b22; border-color: #30363d; } + section > h2, th, .toolbar { background: #161b22; color: #8b949e; border-color: #30363d; } + td { border-color: #21262d; } + .badge.pass { background: #033a16; color: #56d364; } + .badge.fail { background: #3c0a13; color: #ff7b72; } + .badge.info { background: #3a2d04; color: #e3b341; } + details > summary { color: #58a6ff; } + .detval.mono { background: #0d1117; border-color: #30363d; } + .det.fix .detval { background: #3c0a13; border-color: #5a1018; color: #ff7b72; } + .toolbar button { background: #21262d; color: #c9d1d9; border-color: #30363d; } + .toolbar button:hover { background: #30363d; } +} +'@ + +$Js = @' +function expandIn(el, open){ el.querySelectorAll('details.row').forEach(d => d.open = open); } +'@ + +# --- main build ------------------------------------------------------------- + +# NOTE: do not use $findings here — the variable name collides (case-insensitive) +# with $Findings exported from Common.psm1, which holds the path to findings.tsv +# and is referenced later in the Artifacts section. +$findingRows = Get-Findings +$urlRows = Get-UrlDiffLatest + +if (-not $findingRows) { + Write-Error "No findings to report (no rows in $Findings)." + exit 1 +} + +$SetupIds = @('CFG', 'PRE') +$setupRows = $findingRows | Where-Object { $SetupIds -contains $_.Id } +$testRows = $findingRows | Where-Object { $SetupIds -notcontains $_.Id } + +$total = $testRows.Count +$nPass = ($testRows | Where-Object Status -eq 'PASS').Count +$nFail = ($testRows | Where-Object Status -eq 'FAIL').Count +$nInfo = ($testRows | Where-Object Status -eq 'INFO').Count + +$failRows = $testRows | Where-Object Status -eq 'FAIL' +$infoRows = $testRows | Where-Object Status -eq 'INFO' + +$byPhase = @{} +foreach ($p in $PhasePatterns) { $byPhase[$p.Name] = @() } +$byPhase['Other'] = @() +foreach ($r in $testRows) { $byPhase[$r.Phase] += $r } +$phaseOrder = ($PhasePatterns.Name + 'Other') | Where-Object { $byPhase[$_].Count -gt 0 } + +$generated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') +$hostName = $env:COMPUTERNAME +$osinfo = "Windows $((Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).Version) ($([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture))" + +$out = New-Object System.Collections.Generic.List[string] +$out.Add('') +$out.Add('') +$out.Add('GPA Pen-Test Report (Windows)') +$out.Add("") +$out.Add('

Guest Proxy Agent — Pen-Test Report (Windows)

') +$out.Add("
Generated $generated · host $(HtmlEncode $hostName) · " + + "$(HtmlEncode $osinfo) · " + + 'design source: pentest/windows/DESIGN.md · catalog: pentest/windows/TestCatalog.psm1
') + +$out.Add('
') +$out.Add("
$total
Total tests
") +$out.Add("
$nPass
Pass
") +$out.Add("
$nFail
Fail
") +$out.Add("
$nInfo
Info
") +$out.Add('
') + +$out.Add('
Each row is expandable — click details for ' + + 'design, automation, manual repro, and (when failed) the suggested fix. ' + + 'FAIL rows are auto-expanded.
') + +# Run configuration / setup +$out.Add('

Run configuration & harness setup

') +$out.Add("
" + + 'Diagnostic rows emitted by the test harnesses themselves (poll interval, current identity, ' + + 'enabled targets, pre-flight checks). They describe how the suite was run and are ' + + 'not pen-test cases, so they are excluded from the counts above.
') +if (-not $setupRows) { + $out.Add("
No setup records.
") +} else { + $out.Add('' + + "" + + "") + foreach ($r in $setupRows) { + $cls = switch ($r.Status) { 'FAIL' {'fail'} 'PASS' {'pass'} default {'info'} } + $out.Add("" + + "" + + "" + + "") + } + $out.Add('
TimeIDStatusDetail
$(HtmlEncode $r.Ts)$(HtmlEncode $r.Id)$(HtmlEncode $r.Status)
$(HtmlEncode $r.Msg)
') +} +$out.Add('
') + +# Failures +$out.Add('

Failures requiring triage

') +$out.Add("
" + + "" + + "
") +if (-not $failRows) { + $out.Add("
No FAIL entries — all invariants held.
") +} else { + $out.Add('' + + "" + + '') + foreach ($r in $failRows) { $out.Add( (Get-RowHtml -Row $r -WithPhaseCol $true) ) } + $out.Add('
TimeTest IDPhaseSeverityDetail
') +} +$out.Add('
') + +# Informational +$out.Add('

Informational findings (no action required)

') +$out.Add("
" + + "" + + "
") +if (-not $infoRows) { + $out.Add("
No INFO entries.
") +} else { + $out.Add("
" + + 'INFO rows record observations that are not failures: environment limitations ' + + '(e.g. low-IL probe could not be spawned), audit-mode probes, scenarios that require ' + + 'manual review, or by-design behavior that is logged for the record.
') + $out.Add('' + + "" + + '') + foreach ($r in $infoRows) { $out.Add( (Get-RowHtml -Row $r -WithPhaseCol $true) ) } + $out.Add('
TimeTest IDPhaseSeverityDetail
') +} +$out.Add('
') + +# Per-phase +foreach ($phase in $phaseOrder) { + $rows = $byPhase[$phase] + $cP = ($rows | Where-Object Status -eq 'PASS').Count + $cF = ($rows | Where-Object Status -eq 'FAIL').Count + $cI = ($rows | Where-Object Status -eq 'INFO').Count + $out.Add('
') + $out.Add("

$(HtmlEncode $phase) " + + "" + + "$cP pass · $cF fail · $cI info

") + $out.Add("
" + + "" + + "
") + $out.Add('' + + "" + + '') + foreach ($r in $rows) { $out.Add( (Get-RowHtml -Row $r -WithPhaseCol $false) ) } + $out.Add('
TimeTest IDStatusDetail
') +} + +# URL diff appendix +if ($urlRows) { + $out.Add('

Phase 4 — URL/encoding differential (latest run)

') + $out.Add('
' + + 'Compares response codes for canonical vs encoded variants of the same path. ' + + 'DIFF rows warrant review.
' + + 'Repro (script): .\\pentest\\windows\\Phase4-RulesFuzz.ps1
' + + '
') + $out.Add('' + + '' + + '') + foreach ($r in $urlRows) { + $verdict = if ($r.Count -gt 3) { $r[3] } else { '' } + $cls = if ($verdict.Trim().ToUpper() -eq 'DIFF') { 'fail' } else { 'pass' } + $out.Add('' + + "" + + "" + + "" + + "" + + "" + + '') + } + $out.Add('
TimeVariantStatusVerdictPath
$(HtmlEncode $r[0])$(HtmlEncode $r[1])$(HtmlEncode $r[2])$(HtmlEncode $verdict)$(HtmlEncode ($r[4]))
') +} + +# Cheat sheet +$out.Add('

How to reproduce — cheat sheet

') +$out.Add('

Run from the workspace root in an elevated PowerShell:

') +$cheat = @" +# All phases plus report (one shot): +.\pentest\windows\Run-AllPenTests.ps1 -TruncateFindings + +# Individual phases: +.\pentest\windows\Phase2-Listener.ps1 # A1-A5, G1 +.\pentest\windows\Phase3-AuthN-AuthZ.ps1 # B1, B3, B4, C1, C5, C7 (+ pktmon capture) +.\pentest\windows\Phase4-RulesFuzz.ps1 # URL encoding differential +.\pentest\windows\Phase4b-LocalRules.ps1 # local-file rules (needs useLocalFileRules=true) +.\pentest\windows\Phase4b-LocalRules.ps1 -Target imds +.\pentest\windows\Phase4b-LocalRules.ps1 -Scenarios IMDS-S6-encoding-bypass,WS-S6-encoding-bypass +.\pentest\windows\Phase5-FileSystemAudit.ps1 # E1, F1 (+ netsh wfp snapshot) + +# Regenerate this report: +.\pentest\windows\Generate-Report.ps1 +"@ +$out.Add("
" +
+         (HtmlEncode $cheat) + '
') +$out.Add('
') + +# Artifacts +$out.Add('

Artifacts

    ') +foreach ($p in @($Findings, $UrlDiffTsv, $Phase4bLog)) { + if (Test-Path $p) { + $sz = (Get-Item $p).Length + $out.Add("
  • $(HtmlEncode $p) ($sz bytes)
  • ") + } +} +foreach ($p in (Get-ChildItem -Path $ResultsDir -Filter 'phase3-*.etl' -ErrorAction SilentlyContinue | Sort-Object Name)) { + $out.Add("
  • $(HtmlEncode $p.FullName) ($($p.Length) bytes)
  • ") +} +foreach ($p in (Get-ChildItem -Path $ResultsDir -Filter 'wfp_*.txt' -ErrorAction SilentlyContinue | Sort-Object Name)) { + $out.Add("
  • $(HtmlEncode $p.FullName) ($($p.Length) bytes)
  • ") +} +$out.Add('
') + +$out.Add('') +$out.Add('') + +Set-Content -Path $Output -Value ($out -join "`n") -Encoding UTF8 + +Write-Host ("Wrote {0} ({1} test entries: {2} pass, {3} fail, {4} info; {5} setup rows)" -f ` + $Output, $total, $nPass, $nFail, $nInfo, $setupRows.Count) diff --git a/pentest/windows/Phase2-Listener.ps1 b/pentest/windows/Phase2-Listener.ps1 new file mode 100644 index 00000000..03d28b39 --- /dev/null +++ b/pentest/windows/Phase2-Listener.ps1 @@ -0,0 +1,149 @@ +# Phase2-Listener.ps1 — Windows port of pentest/phase2_listener/run.sh. +# +# Scenarios A1, A2 (cross-host probe), A3 (malformed HTTP), A4 (CONNECT), +# A5 (open-proxy), G1 (connection burst). +# +# Run from an elevated PowerShell: +# powershell -ExecutionPolicy Bypass -File .\Phase2-Listener.ps1 + +param([switch] $SkipBurst) + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +# A1 — only 127.0.0.1:3080 should be open; nothing on external NICs. +$listening = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalPort -eq $GpaListenerPort } +$onLoopback = $listening | Where-Object { $_.LocalAddress -in @('127.0.0.1','::1') } +$onExternal = $listening | Where-Object { $_.LocalAddress -notin @('127.0.0.1','::1','0.0.0.0','::') } +$onWildcard = $listening | Where-Object { $_.LocalAddress -in @('0.0.0.0','::') } + +if (-not $listening) { + Write-Finding A1 FAIL "no process listening on TCP $GpaListenerPort — is $GpaServiceName running?" +} elseif ($onExternal -or $onWildcard) { + $bad = ($listening | ForEach-Object { "$($_.LocalAddress):$($_.LocalPort)" }) -join ', ' + Write-Finding A1 FAIL "GPA listener bound to non-loopback address ($bad) — exposed to network" +} else { + Write-Finding A1 PASS "GPA listener bound only to loopback ($($onLoopback.Count) socket(s) on $GpaListenerPort)" +} + +# A1b — confirm loopback connect succeeds. +$tnc = Test-NetConnection -ComputerName 127.0.0.1 -Port $GpaListenerPort -InformationLevel Quiet -WarningAction SilentlyContinue +if ($tnc) { + Write-Finding A1b PASS "loopback TCP connect to $GpaListener OK" +} else { + Write-Finding A1b FAIL "cannot connect to $GpaListener" +} + +# A2 — cross-host: try every non-loopback IPv4 the box has, the listener must NOT answer. +$exposed = @() +$ifaceIps = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | + Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -ne '0.0.0.0' } +foreach ($ip in $ifaceIps) { + $r = Test-NetConnection -ComputerName $ip.IPAddress -Port $GpaListenerPort ` + -InformationLevel Quiet -WarningAction SilentlyContinue + if ($r) { $exposed += $ip.IPAddress } +} +if ($exposed) { + Write-Finding A2 FAIL "GPA listener answered on non-loopback IF: $($exposed -join ', ')" +} else { + Write-Finding A2 PASS "GPA listener does not answer on any external interface" +} + +# A3 — malformed HTTP: oversized header, slow-loris-style partial line. +function Send-RawTcp { + param([string] $Payload, [int] $ReadTimeoutMs = 3000) + $client = New-Object System.Net.Sockets.TcpClient + try { + $client.Connect('127.0.0.1', $GpaListenerPort) + $stream = $client.GetStream() + $stream.ReadTimeout = $ReadTimeoutMs + $bytes = [System.Text.Encoding]::ASCII.GetBytes($Payload) + $stream.Write($bytes, 0, $bytes.Length) + $buf = New-Object byte[] 4096 + try { + $n = $stream.Read($buf, 0, $buf.Length) + return [System.Text.Encoding]::ASCII.GetString($buf, 0, $n) + } catch { return '' } + } finally { $client.Close() } +} + +$bigHeader = "GET / HTTP/1.1`r`nHost: 127.0.0.1`r`nX-Big: " + ('A' * 65536) + "`r`n`r`n" +try { + $resp = Send-RawTcp $bigHeader + if ($resp -match '^HTTP/1\.[01] (4\d\d|5\d\d)') { + Write-Finding A3 PASS "oversized-header request rejected gracefully ($($resp.Split("`n")[0].Trim()))" + } elseif (-not $resp) { + Write-Finding A3 PASS "oversized-header request closed without crash (no response)" + } else { + Write-Finding A3 INFO "oversized-header response: $($resp.Split("`n")[0].Trim())" + } +} catch { + Write-Finding A3 INFO "raw socket test failed: $_" +} + +if (-not (Test-ServiceRunning $GpaServiceName)) { + Write-Finding A3-survive FAIL "$GpaServiceName not running after malformed-HTTP probe" +} else { + Write-Finding A3-survive PASS "$GpaServiceName still running after malformed-HTTP probe" +} + +# A4 — CONNECT must return 405 (no generic forward proxy). We send a raw +# `CONNECT example.com:443 HTTP/1.1` since Invoke-WebRequest doesn't expose +# the CONNECT method directly. +$connectReq = "CONNECT example.com:443 HTTP/1.1`r`nHost: example.com:443`r`n`r`n" +$resp = Send-RawTcp $connectReq +$firstLine = ($resp -split "`r?`n")[0] +if ($firstLine -match 'HTTP/1\.[01] 405') { + Write-Finding A4 PASS "CONNECT rejected with 405 ($firstLine)" +} elseif ($firstLine -match 'HTTP/1\.[01] (4\d\d|5\d\d)') { + Write-Finding A4 PASS "CONNECT rejected ($firstLine)" +} elseif ($firstLine -match 'HTTP/1\.[01] 2\d\d') { + Write-Finding A4 FAIL "CONNECT not refused — GPA may be acting as forward proxy ($firstLine)" +} else { + Write-Finding A4 FAIL "CONNECT response unexpected/empty: '$firstLine'" +} + +# A5 — open-proxy attempt: target an arbitrary external host through GPA's +# listener as if it were an HTTP forward proxy. +try { + $proxy = New-Object System.Net.WebProxy("http://127.0.0.1:$GpaListenerPort", $true) + $req = [System.Net.HttpWebRequest]::Create("http://example.com/") + $req.Proxy = $proxy + $req.Timeout = 5000 + try { + $r = $req.GetResponse() + $code = [int]$r.StatusCode + $r.Close() + } catch [System.Net.WebException] { + $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } + } + if ($code -in 200..299) { + Write-Finding A5 FAIL "open-proxy: GPA forwarded a request to example.com (code=$code)" + } else { + Write-Finding A5 PASS "open-proxy refused (code=$code)" + } +} catch { + Write-Finding A5 PASS "open-proxy attempt rejected: $_" +} + +# G1 — light connection burst, just confirm service stays up. +if (-not $SkipBurst) { + $errors = 0 + 1..200 | ForEach-Object { + try { + $c = New-Object System.Net.Sockets.TcpClient + $c.SendTimeout = 1000 + $c.Connect('127.0.0.1', $GpaListenerPort) + $c.Close() + } catch { $errors++ } + } + if (-not (Test-ServiceRunning $GpaServiceName)) { + Write-Finding G1 FAIL "$GpaServiceName died after 200-conn burst (errors=$errors)" + } else { + Write-Finding G1 PASS "$GpaServiceName survived 200-conn burst (errors=$errors)" + } +} + +Write-Host "" +Write-Host "Phase 2 complete. Findings: $Findings" diff --git a/pentest/windows/Phase3-AuthN-AuthZ.ps1 b/pentest/windows/Phase3-AuthN-AuthZ.ps1 new file mode 100644 index 00000000..9f7cb494 --- /dev/null +++ b/pentest/windows/Phase3-AuthN-AuthZ.ps1 @@ -0,0 +1,281 @@ +# Phase3-AuthN-AuthZ.ps1 — Windows port of pentest/phase3_authn_authz/run.sh. +# +# Scenarios B1, B1b (redirect-active proof), B3, B4, C1, C5-equivalent +# (AppContainer / WSL bypass note), C7 (alt IP-form parity). +# +# WindowsRedirector vs Linux eBPF: on Windows the redirect is implemented via +# WFP (Windows Filtering Platform) callouts in the GuestProxyAgent service, +# not eBPF cgroup attach. The behavioural invariant — "any in-guest connection +# to 169.254.169.254:80 / 168.63.129.16:80 / :32526 must land on the local GPA +# listener" — is the same, so the probes (B1b, C7) test that invariant +# end-to-end without caring how it's implemented. + +[CmdletBinding()] +param( + # Path to a CliXml-serialized PSCredential for an ephemeral standard user, + # written by Run-AllPenTests.ps1. Optional — if absent, C1 falls back to + # whatever the current process token is and may record INFO instead of PASS. + [string] $StandardUserCredPath +) + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +# Hydrate the standard-user credential (if the driver provided one) into the +# global slot that Get-PenTestStandardUserCredential reads. +if ($StandardUserCredPath -and (Test-Path $StandardUserCredPath)) { + try { + $global:PenTestStdUser = Import-Clixml -Path $StandardUserCredPath + } catch { + Write-Finding PRE INFO "could not load standard-user credential from $StandardUserCredPath : $_" + } +} + +# ---------- Optional packet capture (P3-cap) --------------------------------- +$pktmonStarted = $false +if (Get-Command pktmon -ErrorAction SilentlyContinue) { + try { + $cap = Join-Path $ResultsDir ("phase3-windows-{0}.etl" -f (Get-Date).ToUniversalTime().ToString("yyyyMMdd'T'HHmmss'Z'")) + & pktmon filter remove | Out-Null + & pktmon filter add -i $ImdsIp | Out-Null + & pktmon filter add -i $WireServerIp | Out-Null + & pktmon filter add -p $GpaListenerPort | Out-Null + & pktmon start --etw -f $cap | Out-Null + $pktmonStarted = $true + Write-Finding P3-cap INFO "pktmon capture started → $cap" + } catch { + Write-Finding P3-cap INFO "could not start pktmon: $_" + } +} else { + Write-Finding P3-cap INFO "pktmon not available; skipping packet capture" +} + +# ---------- B1a — IMDS reachable through GPA path (canonical baseline) ------- +$canonical = Get-HttpStatus -Url $ImdsUrl -Headers $ImdsHeaders -TimeoutSec 8 +if ($canonical.Code -eq 200) { + Write-Finding B1a PASS "IMDS reachable through GPA-redirected path (200)" +} else { + Write-Finding B1a FAIL "IMDS call failed code=$($canonical.Code)" +} +$canonicalSha = Get-Sha256 $canonical.Body + +# ---------- B1b — kernel-side proof the redirect is in effect ---------------- +# Open a TCP connection to the IMDS IP and check what the local TCP stack +# reports as the remote endpoint. If the WFP redirect is active, the kernel +# rewrites the destination to 127.0.0.1:3080 BEFORE the SYN goes out, and +# Get-NetTCPConnection will show that as the established peer. +try { + $client = New-Object System.Net.Sockets.TcpClient + $client.Connect($ImdsIp, 80) + $localEp = $client.Client.LocalEndPoint + $remoteEp = $client.Client.RemoteEndPoint + # Match by local port for our PID. + $own = Get-NetTCPConnection -LocalPort $localEp.Port -ErrorAction SilentlyContinue | + Where-Object { $_.State -eq 'Established' } | Select-Object -First 1 + $client.Close() + $kernelPeer = if ($own) { "$($own.RemoteAddress):$($own.RemotePort)" } else { "$remoteEp" } + + if ($kernelPeer -match "127\.0\.0\.1:$GpaListenerPort" -or $kernelPeer -match ":$GpaListenerPort$") { + Write-Finding B1b PASS "kernel reports our IMDS connection landed on the GPA listener (WFP redirect active): $kernelPeer" + } elseif ($kernelPeer -match [regex]::Escape($ImdsIp)) { + Write-Finding B1b FAIL "kernel reports our IMDS connection went DIRECTLY to $ImdsIp — WFP redirect bypassed: $kernelPeer" + } else { + Write-Finding B1b INFO "kernel-side peer for IMDS connection: $kernelPeer (manual review)" + } +} catch { + Write-Finding B1b INFO "could not open TCP to ${ImdsIp}:80 (no route or blocked): $_" +} + +# ---------- B3 — key files must not be readable by non-Administrators -------- +if (Test-Path $GpaKeyDir) { + $bad = @() + Get-ChildItem -Path $GpaKeyDir -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { + try { + $acl = Get-Acl $_.FullName + foreach ($ace in $acl.Access) { + $sid = $ace.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) + # Flag any non-system principal with read access: + # Users (S-1-5-32-545), Authenticated Users (S-1-5-11), + # Everyone (S-1-1-0), Anonymous (S-1-5-7). + if ($sid.Value -in @('S-1-5-32-545','S-1-5-11','S-1-1-0','S-1-5-7') ` + -and ($ace.FileSystemRights -match 'Read|FullControl|Modify') ` + -and $ace.AccessControlType -eq 'Allow') { + $bad += "$($_.FullName) grants $($ace.FileSystemRights) to $($ace.IdentityReference)" + } + } + } catch { } + } + if ($bad) { + Write-Finding B3 FAIL ("key files ACL too permissive: " + ($bad -join '; ')) + } else { + Write-Finding B3 PASS "key files in $GpaKeyDir restricted to admin/system principals" + } +} else { + Write-Finding B3 INFO "key dir $GpaKeyDir not present" +} + +# ---------- B4 — forged signature header must be ignored / overwritten ------- +$forgedHeaders = @{ + 'Metadata' = 'true' + 'x-ms-azure-signature' = 'AAAA-attacker-supplied' + 'x-ms-azure-time-tick' = '0' +} +$r = Get-HttpStatus -Url $ImdsUrl -Headers $forgedHeaders -TimeoutSec 8 +if ($r.Code -eq 200) { + Write-Finding B4 PASS "request with attacker-supplied signature still succeeded (GPA overwrote it; verify in pktmon capture)" +} else { + Write-Finding B4 INFO "request with forged signature got code=$($r.Code) (manual review)" +} + +# ---------- C1 — non-elevated WireServer call must be denied ----------------- +# Run the WireServer probe under a real non-Administrator token. If the harness +# itself is already running as a standard user, we just call directly. When the +# harness is elevated, we use the ephemeral standard-user account provisioned +# by Run-AllPenTests.ps1 (see Common.psm1::New-PenTestStandardUser) and execute +# the probe via Start-Process -Credential, capturing the HTTP status code from +# a temp file. The account is removed by the driver after all phases finish. +# +# Return codes: +# >=0 HTTP status code (or 0 for "request never reached server") +# -1 could not run probe under standard user (Start-Process failed, etc.) +# -2 no standard-user credential available at all +function Invoke-AsStandardUser { + param([string] $Url, [hashtable] $Headers) + + if (-not (Test-Administrator)) { + Write-FindingDebug C1 "harness is already non-elevated; running probe under current token ($env:USERNAME)" + return (Get-HttpStatus -Url $Url -Headers $Headers).Code + } + + $cred = Get-PenTestStandardUserCredential + if (-not $cred) { + Write-FindingDebug C1 "no PenTest standard-user credential in module state (was Run-AllPenTests.ps1 the entry point and did New-PenTestStandardUser succeed?)" + return -2 + } + Write-FindingDebug C1 "C1 probe will run as '$($cred.UserName)'" + + # Build a small child script that performs the probe and writes the status + # code (and any error) to $outFile / $errFile. Use C:\Windows\Temp because + # %TEMP% on the elevated session resolves to the admin's profile, which the + # standard user cannot read; C:\Windows\Temp is world-writable. + $hdrLiteral = ($Headers.GetEnumerator() | ForEach-Object { + "'{0}'='{1}'" -f ($_.Key -replace "'","''"), ($_.Value -replace "'","''") + }) -join ';' + $tmpDir = Join-Path $env:SystemRoot 'Temp' + if (-not (Test-Path $tmpDir)) { $tmpDir = $env:TEMP } + $stamp = [guid]::NewGuid().ToString('N') + $script = Join-Path $tmpDir "gpapen-c1-$stamp.ps1" + $outFile = Join-Path $tmpDir "gpapen-c1-$stamp.out" + $errFile = Join-Path $tmpDir "gpapen-c1-$stamp.err" + $body = @" +`$ProgressPreference = 'SilentlyContinue' +`$code = 0 +try { + `$r = Invoke-WebRequest -UseBasicParsing -Uri '$Url' `` + -Headers @{$hdrLiteral} -TimeoutSec 8 -ErrorAction Stop + `$code = [int]`$r.StatusCode +} catch [System.Net.WebException] { + if (`$_.Exception.Response) { `$code = [int]`$_.Exception.Response.StatusCode } else { `$code = 0 } +} catch { + `$code = 0 +} +Set-Content -Path '$outFile' -Value `$code -Encoding ASCII +"@ + Set-Content -Path $script -Value $body -Encoding UTF8 + + # Ensure the standard user can read the script and write the output files. + foreach ($f in @($script, $outFile, $errFile)) { + if (-not (Test-Path $f)) { New-Item -Path $f -ItemType File -Force | Out-Null } + try { + $acl = Get-Acl $f + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $cred.UserName, 'Modify', 'Allow') + $acl.AddAccessRule($rule) + Set-Acl -Path $f -AclObject $acl + } catch { + Write-FindingDebug C1 "could not grant ACE on $f to $($cred.UserName): $_" + } + } + + # Pin the working directory to a path the standard user can access. + # Default Start-Process inherits $PWD, which on an admin shell is usually + # the admin's profile and yields "The directory name is invalid" for the + # new token. C:\Windows is read+execute for Users. + $workDir = $env:SystemRoot + Write-FindingDebug C1 "Start-Process: file=powershell.exe workdir=$workDir script=$script out=$outFile" + + try { + $proc = Start-Process -FilePath 'powershell.exe' ` + -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$script) ` + -Credential $cred -WorkingDirectory $workDir ` + -RedirectStandardError $errFile ` + -WindowStyle Hidden -PassThru -Wait -ErrorAction Stop + Write-FindingDebug C1 "child process exited (PID=$($proc.Id), ExitCode=$($proc.ExitCode))" + if (Test-Path $outFile) { + $code = (Get-Content -Path $outFile -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($code -match '^\d+$') { return [int]$code } + Write-FindingDebug C1 "outfile contents not numeric: '$code'" + } else { + Write-FindingDebug C1 "outfile $outFile not produced" + } + if (Test-Path $errFile) { + $errTxt = (Get-Content -Path $errFile -Raw -ErrorAction SilentlyContinue) + if ($errTxt) { Write-FindingDebug C1 "child stderr: $($errTxt.Trim())" } + } + return -1 + } catch { + Write-FindingDebug C1 "Start-Process -Credential failed: $_" + return -1 + } finally { + Remove-Item $script -Force -ErrorAction SilentlyContinue + Remove-Item $outFile -Force -ErrorAction SilentlyContinue + Remove-Item $errFile -Force -ErrorAction SilentlyContinue + } +} +$c1code = Invoke-AsStandardUser -Url $WireServerUrl -Headers $WireHeaders +switch ($c1code) { + {$_ -in 401,403} { Write-Finding C1 PASS "non-elevated WireServer call denied ($c1code)" } + 200 { Write-Finding C1 FAIL "non-elevated WireServer call SUCCEEDED ($c1code) — AuthZ bypass" } + -1 { Write-Finding C1 INFO "could not run C1 probe as standard user (see preceding INFO rows for diagnostics)" } + -2 { Write-Finding C1 INFO "no standard-user credential available; launch via Run-AllPenTests.ps1 from an elevated shell, or run this script from a standard-user shell directly" } + default { Write-Finding C1 INFO "non-elevated WireServer call code=$c1code (inconclusive)" } +} + +# ---------- C5-equivalent — workload outside the redirect scope -------------- +# On Windows the WFP redirect attaches at the system level, but the agent's +# scoping rules vary by version (per-process, per-app-container, per-silo). +# Best-effort probe: if the Windows Sandbox or Server Container feature is +# enabled, note that as a manual-review item for the operator. +$silo = Get-WindowsOptionalFeature -Online -FeatureName Containers -ErrorAction SilentlyContinue +if ($silo -and $silo.State -eq 'Enabled') { + Write-Finding C5 INFO "Windows Containers feature enabled — manually verify a container can still reach IMDS only through GPA" +} else { + Write-Finding C5 INFO "Windows Containers feature not enabled; C5 (silo evasion) not applicable here" +} + +# ---------- C7 — alt IP-form parity vs canonical ----------------------------- +$alts = @( + "http://0xa9fea9fe/metadata/instance?api-version=2021-02-01", + "http://2852039166/metadata/instance?api-version=2021-02-01" +) +foreach ($alt in $alts) { + $r = Get-HttpStatus -Url $alt -Headers $ImdsHeaders -TimeoutSec 6 + $altSha = Get-Sha256 $r.Body + if ($r.Code -eq $canonical.Code -and $altSha -eq $canonicalSha) { + Write-Finding "C7[$alt]" PASS "alt IP form parity with canonical (code=$($r.Code), body sha matches)" + } elseif ($r.Code -eq $canonical.Code) { + Write-Finding "C7[$alt]" INFO "same code=$($r.Code) but body sha differs (canonical=$canonicalSha alt=$altSha) — manual diff" + } elseif ($r.Code -eq 0 -or ($r.Code -ge 400 -and $r.Code -lt 500)) { + Write-Finding "C7[$alt]" PASS "alt IP form refused (code=$($r.Code)) — no bypass" + } else { + Write-Finding "C7[$alt]" FAIL "alt IP form returned code=$($r.Code), canonical=$($canonical.Code) — possible WFP/SSRF-filter bypass" + } +} + +# ---------- Stop pktmon, if started ------------------------------------------ +if ($pktmonStarted) { + & pktmon stop | Out-Null +} + +Write-Host "" +Write-Host "Phase 3 complete. Findings: $Findings" diff --git a/pentest/windows/Phase4-RulesFuzz.ps1 b/pentest/windows/Phase4-RulesFuzz.ps1 new file mode 100644 index 00000000..bf8b068f --- /dev/null +++ b/pentest/windows/Phase4-RulesFuzz.ps1 @@ -0,0 +1,83 @@ +# Phase4-RulesFuzz.ps1 — Windows port of pentest/linux/phase4_rules_fuzz/url_diff.py. +# +# Sends a matrix of equivalent URLs (different encodings of the same path) to +# the GPA-redirected IMDS endpoint and records the response code GPA hands back. +# A correctly-implemented rule engine should produce the same authorization +# decision for every variant; differences are potential bypasses. +# +# Output: appends one TSV row per variant to results\url_diff.tsv: +# \t\t\t\t\t +# +# Generate-Report.ps1 renders the latest run's rows in the report. + +param([string] $Out) + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +if (-not $Out) { $Out = $UrlDiffTsv } + +$canonical = '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' + +$variants = [ordered]@{ + 'canonical' = $canonical + 'uppercase_pct' = '/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/' + 'lowercase_pct' = '/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/' + 'double_encoded' = '/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/' + 'trailing_dot_path' = '/metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/' + 'dot_segments' = '/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' + 'param_injection' = '/metadata/identity/oauth2/token;x=1?api-version=2018-02-01&resource=https://management.azure.com/' + 'case_path' = '/Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/' + 'extra_slashes' = '//metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/' + 'fragment' = '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/#x' + 'unicode_dotless' = "/metadata/$([char]0x131)dentity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" +} + +function Send-Variant { + param([string] $Path) + # Use HttpWebRequest so we can pass paths with raw %-sequences without + # Invoke-WebRequest re-encoding them. + $url = "http://${ImdsIp}${Path}" + try { + $req = [System.Net.HttpWebRequest]::Create($url) + $req.Method = 'GET' + $req.Timeout = 8000 + $req.Headers.Add('Metadata', 'true') + $resp = $req.GetResponse() + $code = [int]$resp.StatusCode + $sr = New-Object IO.StreamReader($resp.GetResponseStream()) + $body = $sr.ReadToEnd() + $sr.Close(); $resp.Close() + $head = ($body.Substring(0, [Math]::Min(80, $body.Length))) -replace "[`r`n`t]", ' ' + return [pscustomobject]@{ Code = $code; Head = $head } + } catch [System.Net.WebException] { + $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { -1 } + $head = "$($_.Exception.GetType().Name): $($_.Exception.Message)" + $head = ($head.Substring(0, [Math]::Min(60, $head.Length))) -replace "[`r`n`t]", ' ' + return [pscustomobject]@{ Code = $code; Head = $head } + } catch { + return [pscustomobject]@{ Code = -1; Head = ($_.Exception.Message -replace "[`r`n`t]", ' ') } + } +} + +$baseline = Send-Variant $canonical +Write-Host ("baseline ({0}) for canonical path" -f $baseline.Code) + +$ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') +$rows = @() +$diffs = 0 +foreach ($k in $variants.Keys) { + $r = Send-Variant $variants[$k] + $verdict = if ($r.Code -ne $baseline.Code) { 'DIFF' } else { 'same' } + if ($verdict -eq 'DIFF') { $diffs++ } + Write-Host (" {0,-18} status={1,-4} {2}" -f $k, $r.Code, $verdict) + $rows += "$ts`t$k`t$($r.Code)`t$verdict`t$($variants[$k])`t$($r.Head)" +} + +if ($Out) { + $dir = Split-Path -Parent $Out + if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + Add-Content -Path $Out -Value $rows -Encoding UTF8 + Write-Host "" + Write-Host ("Wrote {0} rows to {1}; {2} differ from baseline." -f $rows.Count, $Out, $diffs) +} diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 new file mode 100644 index 00000000..ea101470 --- /dev/null +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -0,0 +1,872 @@ +# Phase4b-LocalRules.ps1 — Windows port of pentest/linux/phase4b_local_rules/run.py. +# +# Auto-runs the local-file authorization-rules scenario matrix against the +# Windows GuestProxyAgent. +# +# PRE-REQ: +# The HostGAPlugin / fabric must already be delivering a ruleId whose +# decoded JSON has useLocalFileRules:true (so the agent merges the files +# in %SystemDrive%\WindowsAzure\ProxyAgent\Rules\ instead of ignoring them). +# The script verifies this at startup. +# +# Run from elevated PowerShell: +# .\Phase4b-LocalRules.ps1 [-Target imds|wireserver|both] [-Scenarios sid1,sid2] +# [-PollSeconds 15] + +[CmdletBinding()] +param( + [ValidateSet('imds', 'wireserver', 'both')] + [string] $Target = 'both', + [string[]] $Scenarios, + [int] $PollSeconds +) + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +# --- environment ------------------------------------------------------------- + +$ImdsRulesFile = Join-Path $GpaRulesDir 'IMDS_Rules.json' +$WsRulesFile = Join-Path $GpaRulesDir 'WireServer_Rules.json' +$PollFallback = 15 +$SlackSeconds = 5 + +if (-not (Test-Administrator)) { + Write-Finding PRE FAIL 'Phase 4b must be run elevated (the Rules dir is admin/SYSTEM-only).' + exit 2 +} + +if (-not (Test-Path $GpaRulesDir)) { + try { + New-Item -ItemType Directory -Path $GpaRulesDir -Force -ErrorAction Stop | Out-Null + } + catch { + Write-Finding PRE FAIL "Rules dir $GpaRulesDir does not exist and could not be created: $_" + exit 2 + } +} + +# Resolve poll interval from agent config if not overridden. +if (-not $PollSeconds -or $PollSeconds -le 0) { + $cfg = Join-Path $GpaInstallRoot 'GuestProxyAgent\GuestProxyAgent.json' + if (Test-Path $cfg) { + try { + $j = Get-Content -Raw $cfg | ConvertFrom-Json + if ($j.pollKeyStatusIntervalInSeconds) { + $PollSeconds = [int]$j.pollKeyStatusIntervalInSeconds + } + } + catch { } + } + if (-not $PollSeconds) { + # Try the source-tree config as a last resort. + $repoCfg = Join-Path $RepoRoot 'proxy_agent\config\GuestProxyAgent.windows.json' + if (Test-Path $repoCfg) { + try { + $j = Get-Content -Raw $repoCfg | ConvertFrom-Json + if ($j.pollKeyStatusIntervalInSeconds) { + $PollSeconds = [int]$j.pollKeyStatusIntervalInSeconds + } + } + catch { } + } + } + if (-not $PollSeconds) { $PollSeconds = $PollFallback } +} + +Write-Finding CFG INFO "poll interval = ${PollSeconds}s" + +# --- pre-flight: useLocalFileRules + effective remote mode ------------------- + +function Get-LatestAuthRulesSnapshot { + Get-ChildItem -Path $GpaLogDir -Filter 'AuthorizationRules_*.json' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 +} + +function Assert-UseLocalFileRules { + $snap = Get-LatestAuthRulesSnapshot + if (-not $snap) { + Write-Finding PRE FAIL "no AuthorizationRules_*.json under $GpaLogDir; is GuestProxyAgent running?" + exit 2 + } + $text = Get-Content -Raw $snap.FullName + if ($text -notmatch 'useLocalFileRules-true') { + Write-Finding PRE FAIL ("useLocalFileRules-true NOT present in $($snap.Name); " + + 'fabric is delivering plain rule ids — local rules will be ignored. ' + + 'Enable useLocalFileRules from the control plane and retry.') + exit 2 + } + # Effective remote mode must be Enforce, otherwise deny scenarios silently 200. + $modes = @{} + try { + $j = $text | ConvertFrom-Json + foreach ($t in @('imds', 'wireserver')) { + $m = $null + if ($j.computedRules -and $j.computedRules.$t) { $m = $j.computedRules.$t.mode } + if (-not $m -and $j.inputRules -and $j.inputRules.$t) { $m = $j.inputRules.$t.mode } + if ($m) { $modes[$t] = $m } + } + } + catch { } + $nonEnforce = @{} + foreach ($k in $modes.Keys) { + if ($modes[$k].ToLower() -ne 'enforce') { $nonEnforce[$k] = $modes[$k] } + } + if ($nonEnforce.Count -gt 0) { + $detail = ($nonEnforce.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ' + Write-Finding PRE FAIL ("effective remote mode is not Enforce ($detail); " + + 'deny scenarios will be silently audited and report bogus FAILs. ' + + 'Switch the fabric/HostGAPlugin rule mode to Enforce and retry.') + exit 2 + } + $modeStr = ($modes.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ' + Write-Finding PRE PASS "useLocalFileRules-true confirmed in $($snap.Name); effective remote mode={$modeStr}" +} + +Assert-UseLocalFileRules + +# --- identity captured at probe-time ---------------------------------------- +# Probes are sent from this PowerShell process. The matcher AND-matches every +# Some(_) field on Identity (None == wildcard). To avoid false-alarm 403s in +# the non-S9 scenarios we pin only userName + groupName here. The S9 scenarios +# use a separate, exePath-pinned identity built inline. + +$me = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$Identity = @{ + name = 'selfAdmin' + userName = ($me.Name -replace '.*\\', '') + groupName = 'Administrators' +} +Write-Finding CFG INFO "current identity: user=$($Identity.userName) group=$($Identity.groupName)" +Write-Finding CFG INFO "targets: $Target" + +# Latin → Cyrillic visual-homoglyph table used by the S19 scenarios. Pairs +# only characters that render (in common fonts) as visually indistinguishable +# from their Latin counterparts — that is exactly the confusable-character +# attack class the test is meant to exercise. Keep this list conservative; +# adding non-confusable pairs would dilute the meaning of the test. +# +# Hashtable LITERALS in PowerShell are case-insensitive, so 'a' and 'A' +# collide at parse time. Build the table imperatively with an Ordinal +# (case-sensitive) comparer instead. +$script:HomoglyphMap = [System.Collections.Hashtable]::new( + [System.StringComparer]::Ordinal) +$script:HomoglyphMap['a'] = [char]0x0430; $script:HomoglyphMap['A'] = [char]0x0410 +$script:HomoglyphMap['c'] = [char]0x0441; $script:HomoglyphMap['C'] = [char]0x0421 +$script:HomoglyphMap['e'] = [char]0x0435; $script:HomoglyphMap['E'] = [char]0x0415 +$script:HomoglyphMap['o'] = [char]0x043E; $script:HomoglyphMap['O'] = [char]0x041E +$script:HomoglyphMap['p'] = [char]0x0440; $script:HomoglyphMap['P'] = [char]0x0420 +$script:HomoglyphMap['x'] = [char]0x0445; $script:HomoglyphMap['X'] = [char]0x0425 +$script:HomoglyphMap['y'] = [char]0x0443; $script:HomoglyphMap['Y'] = [char]0x0423 +$script:HomoglyphMap['B'] = [char]0x0412; $script:HomoglyphMap['H'] = [char]0x041D +$script:HomoglyphMap['K'] = [char]0x041A; $script:HomoglyphMap['M'] = [char]0x041C +$script:HomoglyphMap['T'] = [char]0x0422 + +# Returns a copy of $UserName with the first character that has a Cyrillic +# look-alike replaced by its homoglyph (plus the position swapped, for the +# log message). Returns $null if no character in $UserName has a homoglyph — +# in that case the caller should skip the S19 scenario because there's no +# way to construct a meaningful visual-confusion test against this user. +function Get-HomoglyphUserName { + param([string] $UserName) + if (-not $UserName) { return $null } + for ($i = 0; $i -lt $UserName.Length; $i++) { + $ch = $UserName[$i].ToString() + if ($script:HomoglyphMap.ContainsKey($ch)) { + $homo = $script:HomoglyphMap[$ch] + $swapped = $UserName.Substring(0, $i) + $homo + $UserName.Substring($i + 1) + return [pscustomobject]@{ + UserName = $swapped + Original = $ch + Replaced = $homo + Index = $i + } + } + } + return $null +} + +$script:HomoglyphInfo = Get-HomoglyphUserName -UserName $Identity.userName +if ($script:HomoglyphInfo) { + Write-Finding CFG INFO ("S19 homoglyph: '{0}' → '{1}' (swapped '{2}' at idx {3} for U+{4:X4})" -f ` + $Identity.userName, $script:HomoglyphInfo.UserName, + $script:HomoglyphInfo.Original, $script:HomoglyphInfo.Index, + [int]$script:HomoglyphInfo.Replaced) +} +else { + Write-Finding CFG INFO ("S19 homoglyph: no Latin character in '{0}' has a Cyrillic look-alike in our table; S19 scenarios will be skipped" -f $Identity.userName) +} + +# --- scenario data ---------------------------------------------------------- + +class Probe { + [string] $Name + [string] $Path + [object] $Expected # int or int[] + [string] $Method = 'GET' + Probe([string]$n, [string]$p, [object]$e) { + $this.Name = $n; $this.Path = $p; $this.Expected = $e + } +} + +class Scenario { + [string] $Sid + [string] $TargetName # 'imds' | 'wireserver' + [string] $Description + [object] $Rules # hashtable or raw string + [Probe[]] $Probes + [bool] $Raw = $false + [bool] $Bom = $false # prepend a UTF-8 BOM (EF BB BF) to the written file +} + +function New-Scenario { + param( + [string] $Sid, [string] $TargetName, [string] $Description, + [object] $Rules, [Probe[]] $Probes, [switch] $Raw, [switch] $Bom + ) + $s = [Scenario]::new() + $s.Sid = $Sid; $s.TargetName = $TargetName; $s.Description = $Description + $s.Rules = $Rules; $s.Probes = $Probes; $s.Raw = [bool]$Raw; $s.Bom = [bool]$Bom + return $s +} + +function Build-ImdsScenarios { + param([hashtable] $Identity) + $pfx = 'IMDS' + $scn = @() + + # S1 + $scn += New-Scenario -Sid "$pfx-S1-disabled-allow" -TargetName imds ` + -Description 'mode=disabled, defaultAccess=allow → no enforcement, baseline 200s' ` + -Rules @{ defaultAccess = 'allow'; mode = 'disabled'; id = 'pentest-s1'; rules = @{} } ` + -Probes @( + [Probe]::new('instance', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('token', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', 200) + ) + + # S2 + $scn += New-Scenario -Sid "$pfx-S2-enforce-deny-empty" -TargetName imds ` + -Description 'mode=enforce, defaultAccess=deny, no rules → all 403' ` + -Rules @{ defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s2'; rules = @{} } ` + -Probes @( + [Probe]::new('instance', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions', '/metadata/versions', 403) + ) + + # S3 — regression test: the agent MUST ignore 'mode' written into the + # local rules file. PRE has already confirmed the remote mode is enforce, + # so writing mode=audit + defaultAccess=deny + empty rules must still 403. + # A 200 here would mean the agent honored the local mode (audit-mode + # pass-through) — a security regression in merge_authorization_item / + # LocalAuthorizationRulesFile (see proxy_agent/src/key_keeper/local_rules.rs). + $scn += New-Scenario -Sid "$pfx-S3-audit-deny-empty" -TargetName imds ` + -Description 'local mode=audit must be ignored; remote enforce + local deny → 403' ` + -Rules @{ defaultAccess = 'deny'; mode = 'audit'; id = 'pentest-s3'; rules = @{} } ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S4 + $scn += New-Scenario -Sid "$pfx-S4-allow-one-path" -TargetName imds ` + -Description 'enforce + deny, allow only /metadata/instance for current identity' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s4' + rules = @{ + privileges = @( @{ name = 'p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'r_instance'; privileges = @('p_instance') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_instance'; identities = @($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('token_denied', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S5 + $bogus = $Identity.Clone(); $bogus.name = 'nobodyId'; $bogus.userName = 'nosuchuser_xyz' + $scn += New-Scenario -Sid "$pfx-S5-wrong-identity" -TargetName imds ` + -Description 'enforce + deny, allow path /metadata/instance for a non-matching user' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s5' + rules = @{ + privileges = @( @{ name = 'p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'r_instance'; privileges = @('p_instance') } ) + identities = @( $bogus ) + roleAssignments = @( @{ role = 'r_instance'; identities = @($bogus.name) } ) + } + } ` + -Probes @( [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403) ) + + # S6 + $scn += New-Scenario -Sid "$pfx-S6-encoding-bypass" -TargetName imds ` + -Description 'enforce + deny; allow /metadata/instance; test %2F-encoded slash variants of token path' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s6' + rules = @{ + privileges = @( @{ name = 'p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'r_instance'; privileges = @('p_instance') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_instance'; identities = @($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('token_uppercase_pct', '/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_lowercase_pct', '/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_double_encoded', '/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_question_encoded', '/metadata/identity/oauth2/token%3Fapi-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_dot_segments', '/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', @(403, 404)) + ) + + # S7 + $scn += New-Scenario -Sid "$pfx-S7-query-param-required" -TargetName imds ` + -Description 'enforce + deny; allow /metadata/instance ONLY with api-version=2021-02-01' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s7' + rules = @{ + privileges = @( @{ name = 'p_instance_q'; path = '/metadata/instance'; queryParameters = @{ 'api-version' = '2021-02-01' } } ) + roles = @( @{ name = 'r_q'; privileges = @('p_instance_q') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_q'; identities = @($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('matching_version', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('missing_version', '/metadata/instance', 403), + [Probe]::new('wrong_version', '/metadata/instance?api-version=2017-04-02', 403) + ) + + # S8 + $scn += New-Scenario -Sid "$pfx-S8-group-only-identity" -TargetName imds ` + -Description 'enforce + deny; identity matches by groupName only' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s8' + rules = @{ + privileges = @( @{ name = 'p_any'; path = '/metadata/' } ) + roles = @( @{ name = 'r_any'; privileges = @('p_any') } ) + identities = @( @{ name = 'adminGroup'; groupName = $Identity.groupName } ) + roleAssignments = @( @{ role = 'r_any'; identities = @('adminGroup') } ) + } + } ` + -Probes @( [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200) ) + + # S9 — caller is PowerShell; allow only that exePath. Probe sent via this + # process is allowed; the harness then issues an extra probe via curl.exe + # (shipped with Windows 10/11) which must be denied. + $psExe = (Get-Process -Id $PID).Path + $scn += New-Scenario -Sid "$pfx-S9-exepath-identity" -TargetName imds ` + -Description "enforce + deny; identity restricts to exePath=$psExe" ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s9' + rules = @{ + privileges = @( @{ name = 'p_any'; path = '/metadata/' } ) + roles = @( @{ name = 'r_any'; privileges = @('p_any') } ) + identities = @( @{ name = 'psOnly'; exePath = $psExe } ) + roleAssignments = @( @{ role = 'r_any'; identities = @('psOnly') } ) + } + } ` + -Probes @( [Probe]::new('powershell_caller_allowed', '/metadata/instance?api-version=2021-02-01', 200) ) + + # S10 — malformed JSON + $scn += New-Scenario -Sid "$pfx-S10-malformed-json" -TargetName imds ` + -Description 'malformed local rules JSON → agent must fail-closed (all 403)' ` + -Rules '{ this is not valid json' -Raw ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # ------------------------------------------------------------------ + # S11..S19 — local rules file as adversarial admin input + # ------------------------------------------------------------------ + + # S11: file written with an explicit UTF-8 BOM (EF BB BF). Regression + # guard for the misc_helpers::json_read_from_file BOM fix — the agent + # must trim the BOM before parsing; resulting valid rules deny all. + $scn += New-Scenario -Sid "$pfx-S11-utf8-bom" -TargetName imds ` + -Description 'rules file prefixed with a UTF-8 BOM must still parse (deny → 403)' ` + -Rules @{ defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s11'; rules = @{} } ` + -Bom ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S12: duplicate "defaultAccess" keys (allow then deny). serde_json takes + # last-wins per RFC 8259 guidance; the agent must therefore deny. + $scn += New-Scenario -Sid "$pfx-S12-duplicate-keys" -TargetName imds ` + -Description 'duplicate defaultAccess key (allow,deny) must resolve last-wins → 403' ` + -Rules '{"defaultAccess":"allow","defaultAccess":"deny","id":"pentest-s12","rules":{}}' -Raw ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S17: local rule names that ALREADY start with the LocalFileRules_ prefix. + # Validates that prefix_local_rule_names is idempotent / safe (it rewrites + # both definitions AND references consistently), so the rule still binds. + $s17Identity = $Identity.Clone(); $s17Identity.name = 'LocalFileRules_idCurrentUser' + $scn += New-Scenario -Sid "$pfx-S17-name-collision-shielded" -TargetName imds ` + -Description 'local rule names already containing LocalFileRules_ prefix still bind correctly' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s17' + rules = @{ + privileges = @( @{ name = 'LocalFileRules_p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'LocalFileRules_r_instance'; privileges = @('LocalFileRules_p_instance') } ) + identities = @( $s17Identity ) + roleAssignments = @( @{ role = 'LocalFileRules_r_instance'; identities = @($s17Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S18: dangling role -> privilege reference. validate_local_rules_file + # must reject; agent fail-closes to defaultAccess=deny. + $scn += New-Scenario -Sid "$pfx-S18-dangling-references" -TargetName imds ` + -Description 'role references non-existent privilege → validator rejects → 403' ` + -Rules @{ + defaultAccess = 'allow'; mode = 'enforce'; id = 'pentest-s18' + rules = @{ + privileges = @( @{ name = 'p_real'; path = '/metadata/' } ) + roles = @( @{ name = 'r_broken'; privileges = @('p_ghost') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_broken'; identities = @($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S19: identity userName uses a Cyrillic homoglyph in place of one of the + # current user's ASCII characters (e.g. "Аdministrator" with a Cyrillic А, + # or "аzureuser" with a Cyrillic а). The two strings render identically + # in most fonts but are NOT byte-equal. The matcher must compare bytes + # (not Unicode-normalized / NFKC forms), so the current user must NOT + # match the rule → 403. A 200 would mean the matcher is applying Unicode + # normalization in identity comparison, which is a confusable-character + # bypass. + # + # If the current user's name has no character with a Cyrillic look-alike + # in our table (e.g. all digits, or already non-Latin), there is no + # meaningful way to construct this test — skip with an INFO row. + if ($script:HomoglyphInfo) { + $homoIdentity = $Identity.Clone() + $homoIdentity.name = 'homoUser' + $homoIdentity.userName = $script:HomoglyphInfo.UserName + $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName imds ` + -Description ("Cyrillic homoglyph in userName ('{0}' vs '{1}') must NOT match → 403" -f ` + $Identity.userName, $script:HomoglyphInfo.UserName) ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s19' + rules = @{ + privileges = @( @{ name = 'p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'r_instance'; privileges = @('p_instance') } ) + identities = @( $homoIdentity ) + roleAssignments = @( @{ role = 'r_instance'; identities = @($homoIdentity.name) } ) + } + } ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + } + else { + Write-Finding "$pfx-S19-identity-homoglyph" INFO ("skipped: '{0}' has no character with a Cyrillic visual look-alike in our table" -f $Identity.userName) + } + + return $scn +} + +function Build-WireServerScenarios { + param([hashtable] $Identity) + $pfx = 'WS' + $scn = @() + + $GOALSTATE = '/machine/?comp=goalstate' + $SHARED = '/machine/?comp=sharedConfig' + $HOSTING = '/machine/?comp=hostingenvironmentconfig' + $CERTS = '/machine/?comp=certificates' + $EXTCONFIG = '/machine/?comp=extensionsConfig' + $VERSIONS = '/?comp=versions' + + $scn += New-Scenario -Sid "$pfx-S1-disabled-allow" -TargetName wireserver ` + -Description 'mode=disabled, defaultAccess=allow → baseline 200s' ` + -Rules @{ defaultAccess = 'allow'; mode = 'disabled'; id = 'pentest-ws-s1'; rules = @{} } ` + -Probes @( + [Probe]::new('goalstate', $GOALSTATE, 200), + [Probe]::new('versions', $VERSIONS, 200) + ) + + $scn += New-Scenario -Sid "$pfx-S2-enforce-deny-empty" -TargetName wireserver ` + -Description 'mode=enforce, defaultAccess=deny, no rules → all 403' ` + -Rules @{ defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s2'; rules = @{} } ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403), + [Probe]::new('shared_denied', $SHARED, 403) + ) + + # S3 — regression test: the agent MUST ignore 'mode' written into the + # local rules file. Same contract as IMDS-S3. + $scn += New-Scenario -Sid "$pfx-S3-audit-deny-empty" -TargetName wireserver ` + -Description 'local mode=audit must be ignored; remote enforce + local deny → 403' ` + -Rules @{ defaultAccess = 'deny'; mode = 'audit'; id = 'pentest-ws-s3'; rules = @{} } ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + $scn += New-Scenario -Sid "$pfx-S4-allow-goalstate-only" -TargetName wireserver ` + -Description 'enforce + deny; allow only /machine/ with comp=goalstate' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s4' + rules = @{ + privileges = @( @{ name = 'p_goalstate'; path = '/machine/'; queryParameters = @{ comp = 'goalstate' } } ) + roles = @( @{ name = 'r_goalstate'; privileges = @('p_goalstate') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_goalstate'; identities = @($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('goalstate_allowed', $GOALSTATE, 200), + [Probe]::new('shared_denied', $SHARED, 403), + [Probe]::new('hosting_denied', $HOSTING, 403), + [Probe]::new('certs_denied', $CERTS, 403), + [Probe]::new('extconfig_denied', $EXTCONFIG, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + $bogus = $Identity.Clone(); $bogus.name = 'nobodyId'; $bogus.userName = 'nosuchuser_xyz' + $scn += New-Scenario -Sid "$pfx-S5-wrong-identity" -TargetName wireserver ` + -Description 'enforce + deny; allow goalstate but bound to non-matching user' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s5' + rules = @{ + privileges = @( @{ name = 'p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'r_machine'; privileges = @('p_machine') } ) + identities = @( $bogus ) + roleAssignments = @( @{ role = 'r_machine'; identities = @($bogus.name) } ) + } + } ` + -Probes @( [Probe]::new('goalstate_denied', $GOALSTATE, 403) ) + + $scn += New-Scenario -Sid "$pfx-S6-encoding-bypass" -TargetName wireserver ` + -Description 'enforce + deny; allow only goalstate; test encoded/path-tricks for /machine/?comp=certificates' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s6' + rules = @{ + privileges = @( @{ name = 'p_goalstate'; path = '/machine/'; queryParameters = @{ comp = 'goalstate' } } ) + roles = @( @{ name = 'r_goalstate'; privileges = @('p_goalstate') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_goalstate'; identities = @($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('certs_uppercase_pct', '/machine%2F?comp=certificates', 403), + [Probe]::new('certs_question_encoded', '/machine/%3Fcomp=certificates', 403), + [Probe]::new('certs_dot_segments', '/machine/./../machine/?comp=certificates', @(403, 404)), + [Probe]::new('certs_extra_slashes', '//machine///?comp=certificates', 403), + [Probe]::new('certs_value_case', '/machine/?comp=Certificates', 403), + [Probe]::new('goalstate_value_case_should_match', '/machine/?comp=GOALSTATE', 200) + ) + + $scn += New-Scenario -Sid "$pfx-S7-query-param-required" -TargetName wireserver ` + -Description 'enforce + deny; allow /machine/ ONLY with comp=goalstate' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s7' + rules = @{ + privileges = @( @{ name = 'p_goalstate_q'; path = '/machine/'; queryParameters = @{ comp = 'goalstate' } } ) + roles = @( @{ name = 'r_q'; privileges = @('p_goalstate_q') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_q'; identities = @($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('matching_comp', $GOALSTATE, 200), + [Probe]::new('missing_comp', '/machine/', 403), + [Probe]::new('wrong_comp', '/machine/?comp=hostingenvironmentconfig', 403), + [Probe]::new('extra_param_ok', '/machine/?comp=goalstate&incarnation=1', @(200, 400)) + ) + + $scn += New-Scenario -Sid "$pfx-S8-group-only-identity" -TargetName wireserver ` + -Description 'enforce + deny; identity matches by groupName only' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s8' + rules = @{ + privileges = @( @{ name = 'p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'r_machine'; privileges = @('p_machine') } ) + identities = @( @{ name = 'adminGroup'; groupName = $Identity.groupName } ) + roleAssignments = @( @{ role = 'r_machine'; identities = @('adminGroup') } ) + } + } ` + -Probes @( [Probe]::new('goalstate_allowed', $GOALSTATE, 200) ) + + $psExe = (Get-Process -Id $PID).Path + $scn += New-Scenario -Sid "$pfx-S9-exepath-identity" -TargetName wireserver ` + -Description "enforce + deny; identity restricts to exePath=$psExe" ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s9' + rules = @{ + privileges = @( @{ name = 'p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'r_machine'; privileges = @('p_machine') } ) + identities = @( @{ name = 'psOnly'; exePath = $psExe } ) + roleAssignments = @( @{ role = 'r_machine'; identities = @('psOnly') } ) + } + } ` + -Probes @( [Probe]::new('powershell_caller_allowed', $GOALSTATE, 200) ) + + $scn += New-Scenario -Sid "$pfx-S10-malformed-json" -TargetName wireserver ` + -Description 'malformed local rules JSON → agent must fail-closed (all 403)' ` + -Rules '{ this is not valid json' -Raw ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + # ------------------------------------------------------------------ + # S11..S19 — mirror of IMDS adversarial-admin-input tests + # ------------------------------------------------------------------ + + $scn += New-Scenario -Sid "$pfx-S11-utf8-bom" -TargetName wireserver ` + -Description 'rules file prefixed with a UTF-8 BOM must still parse (deny → 403)' ` + -Rules @{ defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s11'; rules = @{} } ` + -Bom ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + $scn += New-Scenario -Sid "$pfx-S12-duplicate-keys" -TargetName wireserver ` + -Description 'duplicate defaultAccess key (allow,deny) must resolve last-wins → 403' ` + -Rules '{"defaultAccess":"allow","defaultAccess":"deny","id":"pentest-ws-s12","rules":{}}' -Raw ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + $ws17Identity = $Identity.Clone(); $ws17Identity.name = 'LocalFileRules_idCurrentUser' + $scn += New-Scenario -Sid "$pfx-S17-name-collision-shielded" -TargetName wireserver ` + -Description 'local rule names already containing LocalFileRules_ prefix still bind correctly' ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s17' + rules = @{ + privileges = @( @{ name = 'LocalFileRules_p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'LocalFileRules_r_machine'; privileges = @('LocalFileRules_p_machine') } ) + identities = @( $ws17Identity ) + roleAssignments = @( @{ role = 'LocalFileRules_r_machine'; identities = @($ws17Identity.name) } ) + } + } ` + -Probes @( [Probe]::new('goalstate_allowed', $GOALSTATE, 200) ) + + $scn += New-Scenario -Sid "$pfx-S18-dangling-references" -TargetName wireserver ` + -Description 'role references non-existent privilege → validator rejects → 403' ` + -Rules @{ + defaultAccess = 'allow'; mode = 'enforce'; id = 'pentest-ws-s18' + rules = @{ + privileges = @( @{ name = 'p_real'; path = '/machine/' } ) + roles = @( @{ name = 'r_broken'; privileges = @('p_ghost') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_broken'; identities = @($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + # S19: see IMDS-S19. Cyrillic homoglyph in one of the current user's + # characters; matcher must compare bytes → must NOT match → 403. Skip + # when the current user has no character with a Cyrillic look-alike. + if ($script:HomoglyphInfo) { + $wsHomoIdentity = $Identity.Clone() + $wsHomoIdentity.name = 'homoUser' + $wsHomoIdentity.userName = $script:HomoglyphInfo.UserName + $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName wireserver ` + -Description ("Cyrillic homoglyph in userName ('{0}' vs '{1}') must NOT match → 403" -f ` + $Identity.userName, $script:HomoglyphInfo.UserName) ` + -Rules @{ + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s19' + rules = @{ + privileges = @( @{ name = 'p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'r_machine'; privileges = @('p_machine') } ) + identities = @( $wsHomoIdentity ) + roleAssignments = @( @{ role = 'r_machine'; identities = @($wsHomoIdentity.name) } ) + } + } ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + } + else { + Write-Finding "$pfx-S19-identity-homoglyph" INFO ("skipped: '{0}' has no character with a Cyrillic visual look-alike in our table" -f $Identity.userName) + } + + return $scn +} + +# --- HTTP probe -------------------------------------------------------------- + +function Send-Probe { + param([string] $TargetName, [string] $Method, [string] $Path) + $hostHeader = if ($TargetName -eq 'imds') { $ImdsIp } else { $WireServerIp } + $defaultHeaders = if ($TargetName -eq 'imds') { @{ 'Metadata' = 'true' } } else { @{ 'x-ms-version' = '2012-11-30' } } + $url = "http://${hostHeader}${Path}" + try { + $req = [System.Net.HttpWebRequest]::Create($url) + $req.Method = $Method + $req.Timeout = 8000 + foreach ($k in $defaultHeaders.Keys) { $req.Headers.Add($k, $defaultHeaders[$k]) } + $resp = $req.GetResponse() + $code = [int]$resp.StatusCode + $resp.Close() + return $code + } + catch [System.Net.WebException] { + if ($_.Exception.Response) { return [int]$_.Exception.Response.StatusCode } + return -1 + } + catch { return -1 } +} + +# --- file management -------------------------------------------------------- + +function Backup-RulesDir { + $bak = @{} + foreach ($p in @($ImdsRulesFile, $WsRulesFile)) { + if (Test-Path $p) { + $b = "$p.pentest-bak.$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" + Copy-Item -Path $p -Destination $b -Force + $bak[$p] = $b + } + } + return $bak +} + +function Restore-RulesDir { + param([hashtable] $Backups) + foreach ($p in @($ImdsRulesFile, $WsRulesFile)) { + if (Test-Path $p) { Remove-Item -Path $p -Force -ErrorAction SilentlyContinue } + } + foreach ($orig in $Backups.Keys) { + Move-Item -Path $Backups[$orig] -Destination $orig -Force + } +} + +function Write-Rules { + param([string] $Path, [object] $Doc, [bool] $Raw, [bool] $Bom) + $tmp = "$Path.tmp" + if ($Raw) { + $text = [string]$Doc + } + else { + $text = $Doc | ConvertTo-Json -Depth 10 + } + if ($Bom) { + # Bypass Set-Content so the file gets EXACTLY one UTF-8 BOM + # regardless of which PowerShell version is running (PS 5.1 adds a BOM + # by default; PS 7+ does not). Tests that need an explicit BOM must + # be deterministic across both. + $bytes = [byte[]] @(0xEF, 0xBB, 0xBF) + [System.Text.Encoding]::UTF8.GetBytes($text) + [System.IO.File]::WriteAllBytes($tmp, $bytes) + } + else { + Set-Content -Path $tmp -Value $text -NoNewline -Encoding UTF8 + } + Move-Item -Path $tmp -Destination $Path -Force +} + +# --- runner ----------------------------------------------------------------- + +function Wait-ForRefresh { + $delay = $PollSeconds + $SlackSeconds + Write-Host " ... waiting ${delay}s for rule refresh ..." + Start-Sleep -Seconds $delay +} + +function Invoke-CurlExe { + param([string] $TargetName, [string] $Path) + $curl = (Get-Command curl.exe -ErrorAction SilentlyContinue).Source + if (-not $curl) { return -1 } + $hostHeader = if ($TargetName -eq 'imds') { $ImdsIp } else { $WireServerIp } + $hdr = if ($TargetName -eq 'imds') { 'Metadata: true' } else { 'x-ms-version: 2012-11-30' } + $url = "http://${hostHeader}${Path}" + try { + $out = & $curl -sS -o NUL -w '%{http_code}' --max-time 8 -H $hdr $url 2>$null + return [int]$out + } + catch { return -1 } +} + +function Invoke-Scenario { + param([Scenario] $Scenario) + Write-Host "" + Write-Host ("=== {0}: {1}" -f $Scenario.Sid, $Scenario.Description) + $rulesPath = if ($Scenario.TargetName -eq 'imds') { $ImdsRulesFile } else { $WsRulesFile } + Write-Rules -Path $rulesPath -Doc $Scenario.Rules -Raw $Scenario.Raw -Bom $Scenario.Bom + Wait-ForRefresh + + $passes = 0; $fails = 0 + foreach ($p in $Scenario.Probes) { + $actual = Send-Probe $Scenario.TargetName $p.Method $p.Path + $ok = if ($p.Expected -is [array]) { $p.Expected -contains $actual } else { $actual -eq $p.Expected } + $expectedStr = if ($p.Expected -is [array]) { '[' + ($p.Expected -join ',') + ']' } else { "$($p.Expected)" } + $shortPath = if ($p.Path.Length -gt 80) { $p.Path.Substring(0, 80) } else { $p.Path } + Write-Finding "$($Scenario.Sid)/$($p.Name)" ($(if ($ok) { 'PASS' } else { 'FAIL' })) ` + "expected=$expectedStr actual=$actual path=$shortPath" + if ($ok) { $passes++ } else { $fails++ } + } + + # Extra negative probe for S9: invoke via curl.exe so the agent sees a + # different exePath; the rule must deny it. + if ($Scenario.Sid -like '*S9-exepath-identity') { + $defaultPath = if ($Scenario.TargetName -eq 'imds') { '/metadata/instance?api-version=2021-02-01' } else { '/machine/?comp=goalstate' } + $actual = Invoke-CurlExe $Scenario.TargetName $defaultPath + $ok = ($actual -eq 403) + Write-Finding "$($Scenario.Sid)/curl_caller_denied" ($(if ($ok) { 'PASS' } else { 'FAIL' })) ` + "expected=403 actual=$actual (curl.exe invocation — different exePath)" + if ($ok) { $passes++ } else { $fails++ } + } + + return @($passes, $fails) +} + +function Build-AllScenarios { + $all = @() + if ($Target -in @('imds', 'both')) { $all += Build-ImdsScenarios -Identity $Identity } + if ($Target -in @('wireserver', 'both')) { $all += Build-WireServerScenarios -Identity $Identity } + if ($Scenarios) { $all = $all | Where-Object { $Scenarios -contains $_.Sid } } + return $all +} + +# --- main ------------------------------------------------------------------- + +$all = Build-AllScenarios +$totalP = 0; $totalF = 0 +$backups = Backup-RulesDir +try { + foreach ($s in $all) { + try { + $r = Invoke-Scenario $s + $totalP += $r[0]; $totalF += $r[1] + } + catch { + Write-Finding $s.Sid FAIL "scenario crashed: $_" + $totalF++ + } + } +} +finally { + Restore-RulesDir -Backups $backups +} + +Write-Host "" +Write-Host ("local-rules pen-test: {0} PASS, {1} FAIL" -f $totalP, $totalF) +Write-Host "Findings appended to $Findings" + +if ($totalF -gt 0) { exit 1 } diff --git a/pentest/windows/Phase5-FileSystemAudit.ps1 b/pentest/windows/Phase5-FileSystemAudit.ps1 new file mode 100644 index 00000000..6617d895 --- /dev/null +++ b/pentest/windows/Phase5-FileSystemAudit.ps1 @@ -0,0 +1,121 @@ +# Phase5-FileSystemAudit.ps1 — Windows port of pentest/phase5_state_fs/audit.sh. +# +# Scenarios E1 (ACL audit on key dir, log dir, binary, service config), +# F1 (no secret-shaped strings in world-readable status/rules JSON), and a +# read-only snapshot of WFP filters / GuestProxyAgent service config (E4 +# adjacent — analogous to Linux's bpftool snapshot). + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +# --- E1 — ACLs on critical paths --------------------------------------------- +# +# Windows policy: Administrators + SYSTEM should have full control; no other +# principal should have Write/Modify/FullControl. Read-only is acceptable on +# logs/binaries (see DESIGN.md F1: status surface is intentionally observable), +# but the Keys directory must be admin/SYSTEM-only for both read AND write. +$ALLOWED_PRINCIPALS = @( + 'NT AUTHORITY\SYSTEM', + 'BUILTIN\Administrators', + 'NT SERVICE\TrustedInstaller', + "$env:COMPUTERNAME\Administrator" +) + +function Test-AclTight { + param( + [string] $Path, + [switch] $SecretsOnly, # if set, even READ access from non-admins is a FAIL + [string] $Tag = 'E1' # finding ID prefix; allows E2 for rules-dir audit + ) + if (-not (Test-Path $Path)) { + Write-Finding "$Tag[$Path]" INFO "path does not exist on disk; nothing to audit (GPA may not be installed at the expected location)" + return + } + try { $acl = Get-Acl $Path } catch { + Write-Finding "$Tag[$Path]" INFO "could not read ACL: $_"; return + } + $bad = @() + foreach ($ace in $acl.Access) { + $name = $ace.IdentityReference.Value + if ($name -in $ALLOWED_PRINCIPALS) { continue } + $rights = [string]$ace.FileSystemRights + $writeLike = $rights -match 'Write|Modify|FullControl|Delete|ChangePermissions|TakeOwnership' + $readLike = $rights -match 'Read|FullControl' + if ($SecretsOnly -and $readLike -and $ace.AccessControlType -eq 'Allow') { + $bad += "$name has $rights (read on a secrets path)" + } elseif ($writeLike -and $ace.AccessControlType -eq 'Allow') { + $bad += "$name has $rights (write on a system-managed path)" + } + } + if ($bad) { + Write-Finding "$Tag[$Path]" FAIL ("ACL too permissive: " + ($bad -join '; ')) + } else { + Write-Finding "$Tag[$Path]" PASS "ACL restricted to admin/SYSTEM principals" + } +} + +Test-AclTight $GpaKeyDir -SecretsOnly +Test-AclTight $GpaLogDir +Test-AclTight $GpaExe +Test-AclTight $GpaInstallRoot + +# F4 — Rules dir is admin-controlled authZ INPUT. We don't care about read perms +# (rules are public-ish authZ policy, not secrets), but any non-admin write would +# let a non-privileged user inject useLocalFileRules content and bypass remote +# enforcement contracts. Audit write-class permissions specifically. +Test-AclTight $GpaRulesDir -Tag 'F4' + +# Per-key file ACL (mirrors the Linux per-file 0600 check). +if (Test-Path $GpaKeyDir) { + Get-ChildItem -Path $GpaKeyDir -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { + Test-AclTight $_.FullName -SecretsOnly + } +} + +# --- F1 — secret-shaped strings in observable status / rules JSON ------------ +$candidates = @() +$candidates += Get-ChildItem -Path $GpaLogDir -Filter 'status.json' -Recurse -ErrorAction SilentlyContinue +$candidates += Get-ChildItem -Path $GpaLogDir -Filter 'AuthorizationRules_*.json' -Recurse -ErrorAction SilentlyContinue +$secretRegex = '"(key|secret|signature|hmac|token)"\s*:\s*"[^"]+"' +foreach ($f in $candidates) { + try { + $text = Get-Content -Raw -Path $f.FullName + if ($text -match $secretRegex) { + Write-Finding "F1[$($f.Name)]" FAIL "possible secret-shaped string in observable file" + } else { + Write-Finding "F1[$($f.Name)]" PASS "no obvious secret-shaped strings" + } + } catch { + Write-Finding "F1[$($f.Name)]" INFO "could not read: $_" + } +} + +# --- WFP / service snapshots (informational, analogous to bpftool) ---------- +$svc = Get-Service -Name $GpaServiceName -ErrorAction SilentlyContinue +if (-not $svc) { + Write-Finding P5-svc FAIL "GuestProxyAgent service not installed" +} else { + $svcInfo = "$($svc.Name) status=$($svc.Status) startType=$($svc.StartType)" + if ($svc.Status -eq 'Running' -and $svc.StartType -eq 'Automatic') { + Write-Finding P5-svc PASS "GuestProxyAgent service: $svcInfo" + } elseif ($svc.Status -ne 'Running') { + Write-Finding P5-svc FAIL "GuestProxyAgent service not running: $svcInfo" + } else { + # Running but StartType != Automatic (Manual / Disabled-but-running) - + # operational anomaly worth surfacing without failing the suite. + Write-Finding P5-svc INFO "GuestProxyAgent service running but StartType is not Automatic: $svcInfo" + } +} + +if (Get-Command netsh -ErrorAction SilentlyContinue) { + try { + $wfpDump = Join-Path $ResultsDir 'wfp_filters_windows.txt' + & netsh wfp show filters file=$wfpDump 2>&1 | Out-Null + Write-Finding P5-wfp INFO "saved WFP filter dump to $wfpDump" + } catch { + Write-Finding P5-wfp INFO "netsh wfp show filters failed: $_" + } +} + +Write-Host "" +Write-Host "Phase 5 complete. Findings: $Findings" diff --git a/pentest/windows/README.md b/pentest/windows/README.md new file mode 100644 index 00000000..9627e930 --- /dev/null +++ b/pentest/windows/README.md @@ -0,0 +1,85 @@ +# GPA Pen-Test — Windows Harness + +Local pen-test scaffolding for the Windows build of the Guest Proxy Agent. +Pure PowerShell — no Python, no third-party tools required. + +Mirror of the Linux harness under [../linux/](../linux/), with the same +scenario IDs and the same `findings.tsv` schema; design rationale lives in +[DESIGN.md](DESIGN.md). + +## Layout + +| File | Purpose | +|------|---------| +| [Common.psm1](Common.psm1) | Shared module: paths, `Write-Finding` (TSV writer), HTTP helpers, admin check. | +| [TestCatalog.psm1](TestCatalog.psm1) | Per-test metadata (title / design / automation / repro / fix) consumed by the report. | +| [Phase2-Listener.ps1](Phase2-Listener.ps1) | A1, A1b, A2, A3, A3-survive, A4 (CONNECT → 405), A5 (open-proxy), G1 (burst). | +| [Phase3-AuthN-AuthZ.ps1](Phase3-AuthN-AuthZ.ps1) | P3-cap (pktmon), B1a, B1b (kernel-side WFP-redirect proof), B3 (key ACL), B4 (forged signature), C1 (low-IL probe), C5 (Containers note), C7 (alt-IP-form parity). | +| [Phase4-RulesFuzz.ps1](Phase4-RulesFuzz.ps1) | URL / encoding differential (11 variants of the IMDS token URL). | +| [Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1) | Local-file authorization-rules matrix: IMDS S1–S10 + S11/S12/S17/S18/S19 and WS S1–S10 + S11/S12/S17/S18/S19 (30 scenarios). | +| [Phase5-FileSystemAudit.ps1](Phase5-FileSystemAudit.ps1) | E1 (Keys/Logs/exe ACL audit), F1 (no secrets in observable JSON), F4 (Rules dir DACL — admin-only WRITE), P5-svc, P5-wfp (`netsh wfp show filters`). | +| [Generate-Report.ps1](Generate-Report.ps1) | Renders `results\report.html` from `results\findings.tsv` (+ latest `url_diff.tsv`). Pure PowerShell. | +| [Run-AllPenTests.ps1](Run-AllPenTests.ps1) | Driver. Runs phases 2 / 3 / 4 / 4b / 5 + report by default. Flags: `-TruncateFindings`, `-SkipBurst`, `-Phase4bTarget {imds|wireserver|both}`, `-NoReport`. | +| `results/` | Populated at runtime: `findings.tsv`, `url_diff.tsv`, `report.html`, pktmon `.etl` captures, `wfp_filters_windows.txt`. | + +## Prereqs + +- Windows 10 / 11 / Server 2019+ with PowerShell 5.1 or PowerShell 7. +- An elevated PowerShell session (ACL audit, pktmon capture, and Phase 4b + require Administrator). +- The `GuestProxyAgent` service must be installed and running for the + product-side scenarios to mean anything. + +No `pip install`, no Python — `Generate-Report.ps1` is the pure-PowerShell +equivalent of `pentest/linux/generate_report.py`. + +## Run + +```powershell +# Default — phases 2, 3, 4, 4b, 5 + HTML report (Phase 4b mutates the Rules +# dir, so the test VM must be one where useLocalFileRules is honored): +powershell -ExecutionPolicy Bypass -File .\pentest\windows\Run-AllPenTests.ps1 -TruncateFindings + +# Phase 4b: only the IMDS half (skips the WireServer mutations): +.\pentest\windows\Run-AllPenTests.ps1 -TruncateFindings -Phase4bTarget imds + +# Phase 4b alone, IMDS only: +.\pentest\windows\Phase4b-LocalRules.ps1 -Target imds + +# Re-render the report from the existing findings.tsv: +.\pentest\windows\Generate-Report.ps1 +``` + +`-TruncateFindings` clears `results\findings.tsv` first (equivalent to +`> findings.tsv` on Linux). Omit it to append. +`-SkipBurst` skips the Phase 2 G1 burst (useful on production VMs where the +200-connect spike would trigger alerts). +`-Phase4bTarget` chooses which Phase 4b target matrix to write (`both`, +`imds`, or `wireserver`; default `both`). +`-NoReport` skips the final `Generate-Report.ps1` invocation. + +## Result format + +Same TSV schema as the Linux harness: + +``` +\t\t\t +``` + +A `FAIL` means the GPA invariant was violated (potential finding) and warrants +triage. Designs, repro steps, and suggested fixes are inlined per row in the +HTML report. + +## Notes vs the Linux harness + +- Same scenario IDs (A4, B1b, C7, IMDS-S6, …) — findings can be compared + side-by-side with the Linux runs. +- Same report sections (Run configuration / Failures / Informational / + Per-phase / URL diff / Cheat sheet / Artifacts). +- Differences in *implementation* are documented in [DESIGN.md §5](DESIGN.md): + WFP instead of eBPF, ACLs instead of POSIX modes, `pktmon` instead of + `tcpdump`, `netsh wfp show filters` instead of `bpftool prog show`, + Containers feature instead of `unshare -Cr`, etc. +- C1 caveat: launch from a *standard-user* PowerShell to actually exercise + the low-IL path; otherwise the harness records INFO with a manual-repro + hint. diff --git a/pentest/windows/Run-AllPenTests.ps1 b/pentest/windows/Run-AllPenTests.ps1 new file mode 100644 index 00000000..d00282a3 --- /dev/null +++ b/pentest/windows/Run-AllPenTests.ps1 @@ -0,0 +1,103 @@ +# Run-AllPenTests.ps1 — Windows driver, equivalent of pentest/linux/run_all.sh. +# +# Usage (from an elevated PowerShell): +# powershell -ExecutionPolicy Bypass -File .\Run-AllPenTests.ps1 [options] +# +# By default runs phases 2, 3, 4 (URL diff), 4b (local-file rules), 5, then +# renders the HTML report. Phase 4b mutates +# %SystemDrive%\WindowsAzure\ProxyAgent\Rules\ and only produces meaningful +# PASS/FAIL when the fabric has provisioned the agent with +# useLocalFileRules=true; otherwise scenarios short-circuit as SKIP/INFO. + +[CmdletBinding()] +param( + [switch] $TruncateFindings, + [switch] $SkipBurst, + [ValidateSet('imds', 'wireserver', 'both')] + [string] $Phase4bTarget = 'both', + [switch] $NoReport +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +if (-not (Test-Administrator)) { + Write-Warning 'Not running as Administrator. E1 ACL audit, pktmon capture, and Phase 4b will be limited or skipped.' +} + + +if ($TruncateFindings) { + Set-Content -Path $Findings -Value '' -Encoding UTF8 + Write-Host "Truncated $Findings" +} + +# CFG / PRE rows are surfaced in their own section by Generate-Report.ps1. +Write-Finding CFG INFO "platform = Windows $((Get-CimInstance Win32_OperatingSystem).Version)" +Write-Finding CFG INFO "current identity: user=$env:USERNAME admin=$(Test-Administrator)" +Write-Finding CFG INFO "service=$GpaServiceName running=$(Test-ServiceRunning $GpaServiceName) listener=$GpaListener" + +# Provision an ephemeral standard-user account so phases that require a +# non-elevated probe (C1) have a real, verifiable identity to run as. The +# account is removed in the finally block below regardless of how the run ends +# (success, failure, or Ctrl+C). Only attempted when running as Administrator. +# +# We persist the PSCredential to a per-run CliXml file so child phase scripts +# can pick it up reliably — in-process module/global state is unreliable +# because each phase script does `Import-Module ... -Force` which can wipe it. +# Export-Clixml encrypts the SecureString with DPAPI scoped to the current +# user, so the file is unreadable by any other account on the box. +$stdUserCredPath = $null +if (Test-Administrator) { + $cred = New-PenTestStandardUser + if ($cred) { + $stdUserCredPath = Join-Path $ResultsDir '.stduser.cred.xml' + $cred | Export-Clixml -Path $stdUserCredPath -Force + } +} + +try { + $phases = @( + 'Phase2-Listener.ps1', + 'Phase3-AuthN-AuthZ.ps1', + 'Phase4-RulesFuzz.ps1', + 'Phase5-FileSystemAudit.ps1' + ) + + foreach ($p in $phases) { + $script = Join-Path $PSScriptRoot $p + Write-Host '' + Write-Host "===== $p =====" -ForegroundColor Cyan + switch ($p) { + 'Phase2-Listener.ps1' { + if ($SkipBurst) { & $script -SkipBurst } else { & $script } + } + 'Phase3-AuthN-AuthZ.ps1' { + if ($stdUserCredPath) { + & $script -StandardUserCredPath $stdUserCredPath + } else { + & $script + } + } + default { & $script } + } + } + + Write-Host '' + Write-Host '===== Phase4b-LocalRules.ps1 =====' -ForegroundColor Cyan + & (Join-Path $PSScriptRoot 'Phase4b-LocalRules.ps1') -Target $Phase4bTarget + + if (-not $NoReport) { + Write-Host '' + Write-Host '===== Generate-Report.ps1 =====' -ForegroundColor Cyan + & (Join-Path $PSScriptRoot 'Generate-Report.ps1') + } +} +finally { + if ($stdUserCredPath -and (Test-Path $stdUserCredPath)) { + Remove-Item $stdUserCredPath -Force -ErrorAction SilentlyContinue + } + Remove-PenTestStandardUser +} + +Write-Host '' +Write-Host "All Windows phases complete. Findings: $Findings" diff --git a/pentest/windows/TestCatalog.psm1 b/pentest/windows/TestCatalog.psm1 new file mode 100644 index 00000000..a67e430f --- /dev/null +++ b/pentest/windows/TestCatalog.psm1 @@ -0,0 +1,250 @@ +# TestCatalog.psm1 — per-test metadata for the Windows pen-test report. +# +# Mirrors pentest/linux/test_catalog.py for the Windows scenario set. Used by +# Generate-Report.ps1 to inline design notes, automation summary, and repro +# steps into each row of the HTML report. +# +# Each entry has: Title, Design, Automation, ReproScript, ReproManual, Fix. + +Set-StrictMode -Version Latest + +$script:Catalog = @{ + # ---------- Phase 2 — listener / DoS ---------- + 'A1' = @{ + Title = 'Listener bound only to loopback' + Design = 'GPA must listen ONLY on 127.0.0.1:3080. Any non-loopback bind would expose the proxy to other VMs on the vNet.' + Automation = "Get-NetTCPConnection -State Listen | Where LocalPort -eq 3080; FAIL on any LocalAddress not in {127.0.0.1, ::1}." + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = "Get-NetTCPConnection -State Listen | Where-Object { `$_.LocalPort -eq 3080 }" + Fix = 'Bind the proxy listener explicitly to 127.0.0.1, not 0.0.0.0. Check the bind call in proxy_agent/src/proxy/proxy_listener.rs and the Windows-specific wiring under proxy_agent/src/service/windows/.' + } + 'A1b' = @{ + Title = 'Loopback connect to 3080 succeeds' + Design = 'Sanity check: the listener is reachable on 127.0.0.1:3080.' + Automation = 'Test-NetConnection -ComputerName 127.0.0.1 -Port 3080.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = 'Test-NetConnection 127.0.0.1 -Port 3080' + Fix = 'If failing, the agent is not running. Get-Service GuestProxyAgent; check the Application/System event log for service errors.' + } + 'A2' = @{ + Title = 'Port 3080 NOT reachable on external IP' + Design = 'Even if a misconfiguration binds 0.0.0.0, vNet peers must NOT be able to connect to :3080.' + Automation = 'Test-NetConnection against every non-loopback IPv4 from Get-NetIPAddress.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = "Get-NetIPAddress -AddressFamily IPv4 | Where IPAddress -notlike '127.*'`nTest-NetConnection -Port 3080" + Fix = 'Same as A1 — confirm bind(127.0.0.1, 3080) and add a Windows Firewall rule blocking inbound 3080 from non-loopback as defense-in-depth.' + } + 'A3' = @{ + Title = 'Service survives malformed HTTP' + Design = 'Bad method, oversized headers, slow-loris must not crash GuestProxyAgent.exe.' + Automation = 'Sends a 64KB X-Big header via a raw TcpClient and re-checks Get-Service status.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = '$c = New-Object System.Net.Sockets.TcpClient; $c.Connect("127.0.0.1",3080); ...' + Fix = 'If FAIL, gather a crash dump (Windows Error Reporting / procdump) and harden the HTTP parser.' + } + 'A3-survive' = @{ + Title = 'Service still running after malformed HTTP' + Design = 'Companion check to A3 — Get-Service must report Running after the burst.' + Automation = 'Test-ServiceRunning GuestProxyAgent after the A3 probes.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = 'Get-Service GuestProxyAgent' + Fix = 'See A3.' + } + 'A4' = @{ + Title = 'CONNECT method must be rejected' + Design = 'GPA must not act as a generic HTTP tunnel. CONNECT to arbitrary :443 must be refused with an explicit 4xx (preferably 405).' + Automation = "Sends raw 'CONNECT example.com:443 HTTP/1.1' over a TcpClient; expects HTTP/1.1 405." + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = '(via raw TcpClient — see Send-RawTcp helper in Phase2-Listener.ps1)' + Fix = "In the request parser (proxy_agent/src/proxy/proxy_server.rs) explicitly reject any non-{GET,POST,PUT,DELETE,HEAD,OPTIONS,PATCH} method with 405 Method Not Allowed and a short body BEFORE closing. Never silently drop unsupported methods." + } + 'A5' = @{ + Title = 'Not an open proxy' + Design = 'GPA must refuse to forward to arbitrary external hosts even when explicitly configured as an HTTP proxy.' + Automation = "HttpWebRequest with WebProxy('http://127.0.0.1:3080') to http://example.com/ — expects non-2xx." + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = '$proxy=New-Object System.Net.WebProxy("http://127.0.0.1:3080",$true); $r=[Net.HttpWebRequest]::Create("http://example.com/"); $r.Proxy=$proxy; $r.GetResponse()' + Fix = 'Allowlist destinations in the connect-policy redirector; reject any Host:/absolute-URI not in {WireServer, IMDS, HostGAPlugin}.' + } + 'G1' = @{ + Title = "Connection burst doesn't crash" + Design = '200 sequential TcpClient connects; service must remain Running.' + Automation = 'Loops 1..200 with TcpClient.Connect, then re-checks Get-Service status.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = '1..200 | %% { (New-Object Net.Sockets.TcpClient).Connect("127.0.0.1",3080) }' + Fix = 'Apply per-source connection limits in the listener; verify service recovery options in services.msc are set to Restart.' + } + + # ---------- Phase 3 — AuthN/AuthZ ---------- + 'P3-cap' = @{ + Title = 'Phase-3 packet capture' + Design = 'Records traffic to fabric IPs + lo:3080 for offline analysis (Windows uses pktmon).' + Automation = 'pktmon filter add IMDS/WireServer/3080; pktmon start --etw -f results\phase3-windows-*.etl.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = 'pktmon filter add -i 169.254.169.254; pktmon start --etw -f c:\temp\p3.etl' + } + 'B1a' = @{ + Title = 'IMDS reachable through GPA' + Design = 'A normal IMDS call from the guest must succeed (200) thanks to WFP redirect + GPA signature injection.' + Automation = "Invoke-WebRequest http://169.254.169.254/metadata/instance?api-version=2021-02-01 -Headers @{'Metadata'='true'}." + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "Invoke-WebRequest -UseBasicParsing -Headers @{'Metadata'='true'} 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'" + Fix = 'If FAIL, check WFP filters (netsh wfp show filters) and that key-keeper has latched a key (status.json secureChannelState).' + } + 'B1b' = @{ + Title = 'Kernel-side proof WFP redirect is in effect' + Design = 'Open a TCP connection to 169.254.169.254:80 and read what Get-NetTCPConnection reports as the established peer. PASS if 127.0.0.1:3080.' + Automation = 'TcpClient.Connect; match Get-NetTCPConnection -LocalPort -State Established.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = '$c=New-Object Net.Sockets.TcpClient; $c.Connect("169.254.169.254",80); Get-NetTCPConnection -LocalPort $c.Client.LocalEndPoint.Port' + Fix = "If kernel reports the raw IMDS IP, the WFP callout is not attached for this connection's owner. Check the redirector service status and any per-process exclusions." + } + 'B3' = @{ + Title = 'Key files NOT readable by non-admins' + Design = "Anything under %SystemDrive%\\WindowsAzure\\ProxyAgent\\Keys must grant access only to SYSTEM / BUILTIN\\Administrators / TrustedInstaller. Any read grant to Users / Authenticated Users / Everyone / Anonymous is a FAIL." + Automation = 'Get-Acl on every file in the Keys dir; FAIL on Read/Modify/FullControl ACEs for the four well-known low-trust SIDs.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "Get-ChildItem `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Keys -Recurse | Get-Acl | Format-List Path,AccessToString" + Fix = 'Force restrictive ACLs on every key write (Rust: SetSecurityInfo/SetNamedSecurityInfo). Add a self-heal pass on service start that re-applies the canonical DACL.' + } + 'B4' = @{ + Title = 'Attacker-supplied signature header is overwritten' + Design = "GPA must strip/overwrite client-supplied x-ms-azure-signature / x-ms-azure-time-tick. A 200 response means GPA injected its own — confirm in the pktmon capture that the on-wire header value differs." + Automation = "Invoke-WebRequest with forged headers; expects 200, then manual pktmon diff." + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "Invoke-WebRequest -Headers @{'Metadata'='true';'x-ms-azure-signature'='AAAA';'x-ms-azure-time-tick'='0'} ..." + Fix = "In the request-rewriting layer, unconditionally remove inbound x-ms-azure-* headers before computing/inserting GPA's own signature." + } + 'C1' = @{ + Title = 'Non-elevated user denied WireServer' + Design = 'WireServer requires elevated identity at GPA; calls from a low-IL/standard-user token must be 401/403.' + Automation = "If harness runs as Admin: tries 'runas /trustlevel:0x20000' (basic user) — captures stdout unreliable on Windows so falls back to INFO. If harness runs as standard user: probes WireServer directly under that token." + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "From a standard-user PowerShell:`nInvoke-WebRequest -UseBasicParsing -Headers @{'x-ms-version'='2012-11-30'} 'http://168.63.129.16/machine/?comp=goalstate'" + Fix = 'If FAIL with 200 from a low-IL/standard-user PowerShell, the WireServer authorizer (proxy_agent/src/proxy/proxy_authorizer.rs) is not enforcing runAsElevated. Verify the per-connection identity capture on Windows is reading the actual peer token, not the agent process token.' + } + 'C5' = @{ + Title = 'Container / silo evasion (Windows Containers)' + Design = 'Workloads running inside a Windows container (Server Containers / Hyper-V Isolation) must still be subject to the WFP redirect, OR be denied at the network layer entirely. There is no Windows analog to `unshare -Cr` outside containers, so the harness records INFO when the feature is enabled and asks for manual verification.' + Automation = 'Get-WindowsOptionalFeature -Online -FeatureName Containers; INFO with manual-review note.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "From inside a container: Invoke-WebRequest -Headers @{'Metadata'='true'} 'http://169.254.169.254/metadata/instance?api-version=2021-02-01' — must either land on GPA or fail." + } + 'C7' = @{ + Title = 'Alternate IP-form bypass attempt' + Design = 'http://0xa9fea9fe/... and http://2852039166/... resolve to 169.254.169.254. The WFP redirect must treat them identically; AuthZ decision must match the canonical form.' + Automation = 'Captures canonical (status, body sha256) at B1a; for each alt URL: PASS on parity, INFO on body-only drift, PASS on 4xx, FAIL on differing non-4xx.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "Invoke-WebRequest -Headers @{'Metadata'='true'} 'http://0xa9fea9fe/metadata/instance?api-version=2021-02-01'" + Fix = 'If a code DIFFERS from the canonical (200), file a finding: the WFP redirect or matcher treats numeric host forms inconsistently.' + } + + # ---------- Phase 5 — fs/state audit ---------- + 'E1' = @{ + Title = 'ACL audit on critical paths' + Design = "Each path's DACL must allow Write/Modify/FullControl ONLY to SYSTEM / BUILTIN\\Administrators / TrustedInstaller. The Keys directory (and every file under it) is also Secrets-only: any READ grant to a non-admin principal is a FAIL." + Automation = "Get-Acl + Test-AclTight per path; -SecretsOnly switch on the Keys dir tightens to read-as-FAIL for non-admins." + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = "Get-Acl `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Keys | Format-List" + Fix = "Force the canonical DACL on install (SDDL: O:SYG:SYD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)) and re-apply on service start." + } + 'F4' = @{ + Title = 'Rules dir DACL — admin-only WRITE on local rules input' + Design = "The Rules dir (and every IMDS_Rules.json / WireServer_Rules.json under it) is admin-controlled authZ INPUT — not a secret, but a non-admin WRITE there lets an unprivileged user inject useLocalFileRules content and short-circuit remote enforcement contracts. Audit Write/Modify/FullControl: must be SYSTEM / BUILTIN\\Administrators / TrustedInstaller only. Read access is intentionally not flagged (rules are policy, not secrets)." + Automation = "Get-Acl + Test-AclTight -Tag F4 on `$GpaRulesDir." + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = "Get-Acl `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Rules | Format-List" + Fix = "Force admin-only WRITE on install (SDDL: O:BAG:BAD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FR;;;BU)) and re-apply on service start." + } + 'F1' = @{ + Title = 'Status / rules files contain no secrets' + Design = "Observable files (status.json, AuthorizationRules_*.json) under the Logs dir must NOT contain key material, signatures, tokens, or HMACs." + Automation = "Regex '`"(key|secret|signature|hmac|token)`"\\s*:\\s*`"[^`"]+`"' against each file." + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = "Select-String -Pattern '`"(key|secret|signature|hmac|token)`":' `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Logs\\*.json" + Fix = 'If FAIL, redact the offending field in the writer (search for the field name across proxy_agent/src/proxy/).' + } + 'P5-svc' = @{ + Title = 'GuestProxyAgent service status' + Design = 'Confirms the service is installed and (ideally) Running with StartType=Automatic.' + Automation = 'Get-Service GuestProxyAgent.' + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = 'Get-Service GuestProxyAgent | Format-List *' + } + 'P5-wfp' = @{ + Title = 'WFP filter snapshot' + Design = "Captures 'netsh wfp show filters' for offline review — Windows analog of bpftool prog show / cgroup tree on Linux." + Automation = 'netsh wfp show filters file=results\wfp_filters_windows.txt.' + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = 'netsh wfp show filters file=c:\temp\wfp.txt' + } + + # ---------- Phase 4 — URL/encoding diff ---------- + # Rows live in url_diff.tsv; the report renders them in their own section. + + # ---------- Phase 4b — local-file rules pre-flight ---------- + 'PRE' = @{ + Title = 'Pre-flight: useLocalFileRules must be active' + Design = 'Phase 4b is only meaningful when the fabric delivers a ruleId whose decoded JSON has useLocalFileRules:true. Without it, the agent ignores the local files we write.' + Automation = 'Reads the latest AuthorizationRules_*.json from the Logs dir and looks for the substring useLocalFileRules-true.' + ReproScript = '.\pentest\windows\Phase4b-LocalRules.ps1' + ReproManual = "Get-ChildItem `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Logs\\AuthorizationRules_*.json | Sort LastWriteTime -Desc | Select -First 1 | Select-String useLocalFileRules-true" + Fix = 'Toggle the flag from your control plane / mock fabric, then re-run.' + } +} + +# Phase 4b scenarios — same shape across IMDS/WS, so per-scenario titles only. +$script:Phase4b = @{ + 'IMDS-S1-disabled-allow' = @{ Title='Control: mode=disabled + allow'; Design='Baseline. Every IMDS probe must return 200.' } + 'IMDS-S2-enforce-deny-empty' = @{ Title='Fail-closed when enforce+deny with no allow rules'; Design='All IMDS calls must return 403 — proves enforcement engages.' } + 'IMDS-S3-audit-deny-empty' = @{ Title='Local `mode` field must be ignored (regression guard)'; Design='Writes mode=audit + defaultAccess=deny + empty rules to IMDS_Rules.json. PRE confirms remote mode=enforce, so probes must 403. A 200 would mean the agent started honoring `mode` from the local file — security regression in LocalAuthorizationRulesFile / merge_authorization_item.' } + 'IMDS-S4-allow-one-path' = @{ Title='Allow exactly one path via privilege/role/identity/assignment'; Design='instance(200), token(403), versions(403).' } + 'IMDS-S5-wrong-identity' = @{ Title='Allow rule bound to non-matching identity'; Design='Same allow rule but identity userName=nosuchuser_xyz. Must 403.' } + 'IMDS-S6-encoding-bypass' = @{ Title='Encoding/path-traversal bypass attempts'; Design="Allow only /metadata/instance. Probes try %2F/%2f/%252F/dot-segments to reach token. ALL must 403 (or 404 for dot-segments)." } + 'IMDS-S7-query-param-required'= @{ Title='Query parameter must match for allow'; Design='Allow only when api-version=2021-02-01. Matching → 200, missing/wrong → 403.' } + 'IMDS-S8-group-only-identity' = @{ Title='Identity match by groupName only'; Design='Identity has only groupName. Must allow.' } + 'IMDS-S9-exepath-identity' = @{ Title='Per-process identity by exePath'; Design='Allow only when exePath=PowerShell.exe (Windows variant). PowerShell caller → 200; arbitrary exe → 403.' } + 'IMDS-S10-malformed-json' = @{ Title='Malformed rules JSON → fail-closed'; Design='Every IMDS probe must 403 — never silently fall back to allow.' } + 'IMDS-S11-utf8-bom' = @{ Title='UTF-8 BOM tolerated when parsing rules'; Design='Writes IMDS_Rules.json prefixed with EF BB BF + valid deny doc. Regression guard for `misc_helpers::json_read_from_file` BOM trim. 200 here would mean BOM-fix has regressed and the agent fell back to no-rules / allow.' } + 'IMDS-S12-duplicate-keys' = @{ Title='Duplicate JSON keys resolve last-wins'; Design='Writes `{"defaultAccess":"allow","defaultAccess":"deny"}`. serde_json must take last-wins per RFC 8259 — 200 would mean first-wins (silent escalation) or rule loaded as allow-by-default.' } + 'IMDS-S17-name-collision-shielded' = @{ Title='Local rule names already containing LocalFileRules_ prefix'; Design='Tests that `prefix_local_rule_names` rewrites both names AND cross-references consistently even when the admin pre-includes the prefix — so the rule still binds. 403 on /metadata/instance would mean the prefixer mishandled the doubled prefix.' } + 'IMDS-S18-dangling-references'= @{ Title='Dangling role→privilege reference must reject'; Design='Validator must reject; agent fail-closes to defaultAccess=deny. 200 means dangling-ref validation regressed.' } + 'IMDS-S19-identity-homoglyph' = @{ Title='Cyrillic homoglyph in userName must NOT match ASCII user'; Design='Identity userName uses a Cyrillic look-alike (e.g. а=U+0430, е=U+0435, о=U+043E, …) in place of one character of the current user''s ASCII name. The two strings render identically in most fonts but are NOT byte-equal. Matcher must compare bytes, not Unicode-normalized forms — 200 would mean Unicode normalization is happening in identity comparison and could be used to bypass authZ. Skipped (INFO) when the current user has no character with a Cyrillic look-alike in the harness table.' } + 'WS-S1-disabled-allow' = @{ Title='Control: mode=disabled + allow (WireServer)'; Design='Baseline 200s for goalstate and versions.' } + 'WS-S2-enforce-deny-empty' = @{ Title='Fail-closed enforce+deny (WireServer)'; Design='All WireServer calls 403.' } + 'WS-S3-audit-deny-empty' = @{ Title='Local `mode` field must be ignored (WireServer regression guard)'; Design='Writes mode=audit + defaultAccess=deny + empty rules to WireServer_Rules.json. PRE confirms remote mode=enforce, so probes must 403. A 200 would mean the agent honored `mode` from the local file.' } + 'WS-S4-allow-goalstate-only' = @{ Title='Allow only /machine/?comp=goalstate'; Design='goalstate→200; sharedConfig/hosting/certs/extensionsConfig/versions→403.' } + 'WS-S5-wrong-identity' = @{ Title='Allow goalstate but identity does not match'; Design='Even goalstate → 403.' } + 'WS-S6-encoding-bypass' = @{ Title='Encoding/path-traversal bypass (WireServer)'; Design='Encoded variants of /machine/?comp=certificates must all 403; comp=GOALSTATE (case-insensitive) must 200.' } + 'WS-S7-query-param-required' = @{ Title='Query parameter must match (WireServer)'; Design='Only comp=goalstate allowed; matching/extra-param → 200/400, missing/wrong → 403.' } + 'WS-S8-group-only-identity' = @{ Title='Identity match by groupName only (WireServer)'; Design='groupName=Administrators → goalstate 200.' } + 'WS-S9-exepath-identity' = @{ Title='Per-process identity by exePath (WireServer)'; Design='Allow only when exePath=PowerShell.exe.' } + 'WS-S10-malformed-json' = @{ Title='Malformed rules JSON → fail-closed (WireServer)'; Design='All WireServer probes 403.' } + 'WS-S11-utf8-bom' = @{ Title='UTF-8 BOM tolerated when parsing rules (WireServer)'; Design='Writes WireServer_Rules.json prefixed with EF BB BF + valid deny doc. Regression guard for `misc_helpers::json_read_from_file` BOM trim.' } + 'WS-S12-duplicate-keys' = @{ Title='Duplicate JSON keys resolve last-wins (WireServer)'; Design='Same contract as IMDS-S12 against WireServer.' } + 'WS-S17-name-collision-shielded' = @{ Title='Local rule names already containing LocalFileRules_ prefix (WireServer)'; Design='Same contract as IMDS-S17 against /machine/.' } + 'WS-S18-dangling-references' = @{ Title='Dangling role→privilege reference must reject (WireServer)'; Design='Validator must reject; agent fail-closes.' } + 'WS-S19-identity-homoglyph' = @{ Title='Cyrillic homoglyph in userName must NOT match (WireServer)'; Design='Same contract as IMDS-S19 against /machine/. Skipped (INFO) when the current user has no character with a Cyrillic look-alike in the harness table.' } +} + +function Get-CatalogEntry { + [CmdletBinding()] + param([string] $TestId) + if (-not $TestId) { return $null } + if ($Catalog.ContainsKey($TestId)) { return $Catalog[$TestId] } + if ($Phase4b.ContainsKey($TestId)) { return $Phase4b[$TestId] } + + # Strip [...] suffix (e.g. E1[C:\...\status.json]) + $base = ($TestId -replace '\[.*?\]', '').Trim() + if ($Catalog.ContainsKey($base)) { return $Catalog[$base] } + + # Strip /probe suffix (e.g. IMDS-S4-allow-one-path/instance_allowed) + if ($TestId.Contains('/')) { + $head = $TestId.Split('/', 2)[0] + if ($Phase4b.ContainsKey($head)) { return $Phase4b[$head] } + if ($Catalog.ContainsKey($head)) { return $Catalog[$head] } + } + return $null +} + +Export-ModuleMember -Function Get-CatalogEntry -Variable Catalog,Phase4b diff --git a/proxy_agent/Cargo.toml b/proxy_agent/Cargo.toml index a18cb85d..aa2ce8b7 100644 --- a/proxy_agent/Cargo.toml +++ b/proxy_agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "azure-proxy-agent" -version = "1.0.44" # always 3-number version +version = "1.0.45" # always 3-number version edition = "2021" build = "build.rs" readme = "README.md" @@ -9,7 +9,7 @@ license = "MIT" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -proxy_agent_shared = { path ="../proxy_agent_shared"} +proxy_agent_shared = { path = "../proxy_agent_shared", features = ["signing"] } once_cell = "1.17.0" # use Lazy serde = "1.0.152" serde_derive = "1.0.152" diff --git a/proxy_agent/src/common/constants.rs b/proxy_agent/src/common/constants.rs index 095aaf8f..3709b742 100644 --- a/proxy_agent/src/common/constants.rs +++ b/proxy_agent/src/common/constants.rs @@ -8,7 +8,7 @@ pub const IMDS_IP: &str = "169.254.169.254"; pub const IMDS_PORT: u16 = 80u16; pub const WINDOWS_AZURE: &str = "Windows Azure"; -pub const PROXY_AGENT_SERVICE_NAME: &str = "GuestProxyAgent"; +pub use proxy_agent_shared::constants::PROXY_AGENT_SERVICE_NAME; pub const PROXY_AGENT_IP: &str = "127.0.0.1"; pub const PROXY_AGENT_PORT: u16 = 3080; diff --git a/proxy_agent/src/key_keeper.rs b/proxy_agent/src/key_keeper.rs index 119f2902..a42f5bce 100644 --- a/proxy_agent/src/key_keeper.rs +++ b/proxy_agent/src/key_keeper.rs @@ -935,7 +935,21 @@ impl KeyKeeper { key_file.display(), e ))) - }) + })?; + + #[cfg(not(windows))] + { + // set the file permissions to 600 for non-windows platform + proxy_agent_shared::linux::set_file_permissions(&key_file, 0o600).map_err(|e| { + Error::Key(KeyErrorType::StoreLocalKey(format!( + "set_file_permissions '{}' failed {}", + key_file.display(), + e + ))) + })?; + } + + Ok(()) } } diff --git a/proxy_agent/src/provision.rs b/proxy_agent/src/provision.rs index 89862fc3..827af8f3 100644 --- a/proxy_agent/src/provision.rs +++ b/proxy_agent/src/provision.rs @@ -435,11 +435,21 @@ async fn write_provision_state( } if let Err(e) = std::fs::write( - provisioned_file, + &provisioned_file, misc_helpers::get_date_time_string_with_milliseconds(), ) { logger::write_error(format!("Failed to write provisioned file with error: {e}")); } + #[cfg(not(windows))] + { + proxy_agent_shared::linux::set_file_permissions(&provisioned_file, 0o600).unwrap_or_else( + |e| { + logger::write_error(format!( + "Failed to set provisioned file permission to 600 with error: {e}" + )); + }, + ); + } let mut failed_state_message = get_provision_failed_state_message(provision_shared_state, agent_status_shared_state).await; @@ -484,6 +494,19 @@ async fn write_provision_state( logger::write_error(format!("Failed to write temp status file with error: {e}")); } } + + #[cfg(not(windows))] + { + proxy_agent_shared::linux::set_file_permissions( + &provision_dir.join(STATUS_TAG_FILE_NAME), + 0o600, + ) + .unwrap_or_else(|e| { + logger::write_error(format!( + "Failed to set status file permission to 600 with error: {e}" + )); + }); + } } /// Get provision failed state message diff --git a/proxy_agent/src/proxy/proxy_server.rs b/proxy_agent/src/proxy/proxy_server.rs index fb2a0abd..5d1dc5ea 100644 --- a/proxy_agent/src/proxy/proxy_server.rs +++ b/proxy_agent/src/proxy/proxy_server.rs @@ -38,6 +38,7 @@ use http_body_util::{combinators::BoxBody, BodyExt}; use hyper::body::{Bytes, Incoming}; use hyper::header::{HeaderName, HeaderValue}; use hyper::service::service_fn; +use hyper::Method; use hyper::StatusCode; use hyper::{Request, Response}; use hyper_util::rt::TokioIo; @@ -376,7 +377,34 @@ impl ProxyServer { ), ); + // Explicit method allow-list. GPA is an HTTP/1.1 forward proxy for a + // fixed set of fabric endpoints, so it must reject: + // * CONNECT — accepting it would turn GPA into a generic outbound TCP + // tunnel and bypass the per-endpoint authorization + signing logic. + // * TRACE — GPA injects an HMAC `x-ms-azure-host-authorization` + // signature and `x-ms-azure-host-claims` before forwarding; a TRACE + // would cause the upstream to echo those secrets back to the local + // (potentially unprivileged) caller. + // * Any non-standard verb — defense in depth against request smuggling + // and unexpected upstream behavior on unknown tokens. + // Reject *before* any auth/upstream lookup so attackers cannot pivot + // through unsupported methods, and respond with an explicit 405 + Allow + // (RFC 7231 §6.5.5) so clients and pen-test harnesses see a real status + // line instead of a silent RST. + if !Self::is_method_allowed(&http_connection_context.method) { + let bad_method = http_connection_context.method.to_string(); + self.log_connection_summary( + &mut http_connection_context, + StatusCode::METHOD_NOT_ALLOWED, + false, + format!("Rejected unsupported HTTP method '{bad_method}'"), + ) + .await; + return Ok(Self::method_not_allowed_response()); + } + if http_connection_context.contains_traversal_characters() { + // If the proxied request contains traversal characters, we will return 404 Not Found to avoid potential security issues. self.log_connection_summary( &mut http_connection_context, StatusCode::NOT_FOUND, @@ -878,6 +906,48 @@ impl ProxyServer { response } + /// Methods that GPA's HTTP/1.1 forward-proxy will accept and try to route + /// to a fabric endpoint. Anything else (CONNECT, TRACE, made-up verbs) + /// is rejected with 405 by `handle_new_http_request` BEFORE any + /// authorization or upstream lookup happens. + const ALLOWED_METHODS: &'static [Method] = &[ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::HEAD, + Method::OPTIONS, + Method::PATCH, + ]; + const ALLOW_HEADER_VALUE: &'static str = "GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH"; + + fn is_method_allowed(method: &Method) -> bool { + Self::ALLOWED_METHODS.iter().any(|m| m == method) + } + + /// Build an explicit `405 Method Not Allowed` response with an `Allow` + /// header listing the supported verbs and a short text body so that + /// clients (and pen-test harnesses) can see a real HTTP status line + /// instead of a silent connection close. + fn method_not_allowed_response() -> Response> { + let body = Full::new(Bytes::from_static(b"Method Not Allowed")) + .map_err(|never| match never {}) + .boxed(); + let mut response = Response::new(body); + *response.status_mut() = StatusCode::METHOD_NOT_ALLOWED; + let headers = response.headers_mut(); + headers.insert( + hyper::header::ALLOW, + HeaderValue::from_static(Self::ALLOW_HEADER_VALUE), + ); + headers.insert( + hyper::header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + headers.insert(hyper::header::CONNECTION, HeaderValue::from_static("close")); + response + } + async fn handle_request_with_signature( &self, mut http_connection_context: HttpConnectionContext, @@ -1139,4 +1209,62 @@ mod tests { // stop the listener cancellation_token.cancel(); } + + #[tokio::test] + async fn unsupported_methods_return_405() { + // GPA must reject CONNECT, TRACE, and any unknown verb with an + // explicit 405 + Allow header rather than silently closing the + // connection. Regression test for pen-test finding A4. + // We use std::net + spawn_blocking because this crate doesn't enable + // tokio's `io-util` feature. + use std::io::{Read, Write}; + use std::net::TcpStream as StdTcpStream; + use std::time::Duration as StdDuration; + + let host = "127.0.0.1"; + let port: u16 = 8092; // distinct from other tests + let shared_state = shared_state::SharedState::start_all(); + let cancellation_token = shared_state.get_cancellation_token(); + let proxy_server = proxy_server::ProxyServer::new(port, &shared_state); + + tokio::spawn({ + let proxy_server = proxy_server.clone(); + async move { + proxy_server.start().await; + } + }); + tokio::time::sleep(Duration::from_millis(100)).await; + + async fn first_status_line(host: &'static str, port: u16, raw: &'static str) -> String { + tokio::task::spawn_blocking(move || { + let mut stream = StdTcpStream::connect((host, port)).expect("connect"); + stream + .set_read_timeout(Some(StdDuration::from_secs(2))) + .expect("set_read_timeout"); + stream.write_all(raw.as_bytes()).expect("write"); + let mut buf = [0u8; 256]; + let n = stream.read(&mut buf).unwrap_or(0); + let text = String::from_utf8_lossy(&buf[..n]).to_string(); + text.lines().next().unwrap_or("").to_string() + }) + .await + .expect("spawn_blocking") + } + + for raw in [ + "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", + "TRACE / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + "WIDGET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ] { + let status_line = first_status_line(host, port, raw).await; + assert!( + status_line.starts_with("HTTP/1.1 405"), + "expected 405 status line for raw request {:?}, got {:?}", + raw, + status_line + ); + } + + cancellation_token.cancel(); + } } diff --git a/proxy_agent/src/proxy_agent_status.rs b/proxy_agent/src/proxy_agent_status.rs index aec85804..5bed94db 100644 --- a/proxy_agent/src/proxy_agent_status.rs +++ b/proxy_agent/src/proxy_agent_status.rs @@ -297,6 +297,16 @@ impl ProxyAgentStatusTask { )) .await; } else { + #[cfg(not(windows))] + { + proxy_agent_shared::linux::set_file_permissions(&full_file_path, 0o640) + .unwrap_or_else(|e| { + logger::write_error(format!( + "Failed to set status.json file permission to 640 with error: {e}" + )); + }); + } + // need overwrite the status message to indicate the status file is written successfully self.update_agent_status_message(format!( "Aggregate status written to status file: {}", diff --git a/proxy_agent/src/service.rs b/proxy_agent/src/service.rs index c6d74b77..31468d6b 100644 --- a/proxy_agent/src/service.rs +++ b/proxy_agent/src/service.rs @@ -11,11 +11,9 @@ use crate::redirector::{self, Redirector}; use crate::shared_state::SharedState; use proxy_agent_shared::current_info; use proxy_agent_shared::hyper_client::HostEndpoint; -use proxy_agent_shared::logger::rolling_logger::RollingLogger; -use proxy_agent_shared::logger::{logger_manager, LoggerLevel}; +use proxy_agent_shared::logger::logger_manager; use proxy_agent_shared::proxy_agent_aggregate_status; use proxy_agent_shared::telemetry::event_logger; -use std::path::PathBuf; #[cfg(not(windows))] use std::time::Duration; @@ -42,7 +40,20 @@ pub async fn start_service(shared_state: SharedState) { if log_folder == proxy_agent_shared::misc_helpers::empty_path() { println!("The log folder is not set, skip write to GPA managed file log."); } else { - setup_loggers(log_folder, config::get_file_log_level()); + proxy_agent_shared::logger::init_loggers( + log_folder, + &[ + (logger::AGENT_LOGGER_KEY, "ProxyAgent.log"), + ( + ConnectionLogger::CONNECTION_LOGGER_KEY, + "ProxyAgent.Connection.log", + ), + ], + logger::AGENT_LOGGER_KEY, + constants::MAX_LOG_FILE_SIZE, + constants::MAX_LOG_FILE_COUNT as u16, + config::get_file_log_level(), + ); } let start_message = format!( @@ -85,32 +96,6 @@ pub async fn start_service(shared_state: SharedState) { }); } -fn setup_loggers(log_folder: PathBuf, max_logger_level: LoggerLevel) { - let agent_logger = RollingLogger::create_new( - log_folder.clone(), - "ProxyAgent.log".to_string(), - constants::MAX_LOG_FILE_SIZE, - constants::MAX_LOG_FILE_COUNT as u16, - ); - let connection_logger = RollingLogger::create_new( - log_folder.clone(), - "ProxyAgent.Connection.log".to_string(), - constants::MAX_LOG_FILE_SIZE, - constants::MAX_LOG_FILE_COUNT as u16, - ); - let mut loggers = std::collections::HashMap::new(); - loggers.insert(logger::AGENT_LOGGER_KEY.to_string(), agent_logger); - loggers.insert( - ConnectionLogger::CONNECTION_LOGGER_KEY.to_string(), - connection_logger, - ); - logger_manager::set_loggers( - loggers, - logger::AGENT_LOGGER_KEY.to_string(), - max_logger_level, - ); -} - /// Start the service and wait until the service is stopped. /// Example: /// ```rust diff --git a/proxy_agent_extension/Cargo.toml b/proxy_agent_extension/Cargo.toml index a9c5aa05..5256b536 100644 --- a/proxy_agent_extension/Cargo.toml +++ b/proxy_agent_extension/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ProxyAgentExt" -version = "1.0.44" # always 3-number version +version = "1.0.45" # always 3-number version edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/proxy_agent_extension/src/constants.rs b/proxy_agent_extension/src/constants.rs index e66ffd4f..3e7ac9ab 100644 --- a/proxy_agent_extension/src/constants.rs +++ b/proxy_agent_extension/src/constants.rs @@ -13,10 +13,7 @@ pub const EXTENSION_PROCESS_NAME: &str = "ProxyAgentExt"; #[cfg(windows)] pub const EXTENSION_PROCESS_NAME: &str = "ProxyAgentExt.exe"; pub const EXTENSION_SERVICE_DISPLAY_NAME: &str = "Microsoft Azure GuestProxyAgent VMExtension"; -#[cfg(windows)] -pub const PROXY_AGENT_SERVICE_NAME: &str = "GuestProxyAgent"; -#[cfg(not(windows))] -pub const PROXY_AGENT_SERVICE_NAME: &str = "azure-proxy-agent"; +pub use proxy_agent_shared::constants::PROXY_AGENT_SERVICE_NAME; pub const UPDATE_TAG_FILE: &str = "update.tag"; pub const ENABLE_OPERATION: &str = "Enable"; pub const LANG_EN_US: &str = "en-US"; diff --git a/proxy_agent_extension/src/logger.rs b/proxy_agent_extension/src/logger.rs index f12cec8e..9771ad60 100644 --- a/proxy_agent_extension/src/logger.rs +++ b/proxy_agent_extension/src/logger.rs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation // SPDX-License-Identifier: MIT -use proxy_agent_shared::logger::{logger_manager, rolling_logger::RollingLogger, LoggerLevel}; +use proxy_agent_shared::logger::{self, logger_manager, LoggerLevel}; use std::path::PathBuf; + static LOGGER_KEY: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); + pub fn get_logger_key() -> String { LOGGER_KEY .get() @@ -11,15 +13,14 @@ pub fn get_logger_key() -> String { } pub fn init_logger(log_folder: String, log_name: &str) { - let logger = RollingLogger::create_new( + logger::init_loggers( PathBuf::from(log_folder), - log_name.to_string(), + &[(log_name, log_name)], + log_name, 20 * 1024 * 1024, 30, + LoggerLevel::Trace, ); - let mut loggers = std::collections::HashMap::new(); - loggers.insert(log_name.to_string(), logger); - logger_manager::set_loggers(loggers, log_name.to_string(), LoggerLevel::Trace); if !LOGGER_KEY.initialized() { if let Err(e) = LOGGER_KEY.set(log_name.to_string()) { diff --git a/proxy_agent_setup/Cargo.toml b/proxy_agent_setup/Cargo.toml index 1ce88cfd..8ec5a27a 100644 --- a/proxy_agent_setup/Cargo.toml +++ b/proxy_agent_setup/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxy_agent_setup" -version = "1.0.44" +version = "1.0.45" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/proxy_agent_setup/src/linux.rs b/proxy_agent_setup/src/linux.rs index 70921167..93fb6b07 100644 --- a/proxy_agent_setup/src/linux.rs +++ b/proxy_agent_setup/src/linux.rs @@ -12,16 +12,31 @@ const EBPF_FILE: &str = "ebpf_cgroup.o"; const CONFIG_PATH: &str = "/etc/azure/proxy-agent.json"; const EBPF_PATH: &str = "/usr/lib/azure-proxy-agent/ebpf_cgroup.o"; -pub fn setup_service(service_name: &str, service_file_dir: PathBuf) -> Result { +pub fn setup_service(service_name: &str, service_file_dir: PathBuf) -> Result<()> { copy_service_config_file(service_name, service_file_dir) } -fn copy_service_config_file(service_name: &str, service_file_dir: PathBuf) -> Result { +fn copy_service_config_file(service_name: &str, service_file_dir: PathBuf) -> Result<()> { let service_config_name = format!("{service_name}.service"); let src_config_file_path = service_file_dir.join(&service_config_name); let dst_config_file_path = PathBuf::from(proxy_agent_shared::linux::SERVICE_CONFIG_FOLDER_PATH) .join(&service_config_name); - fs::copy(src_config_file_path, dst_config_file_path).map_err(Into::into) + fs::copy(src_config_file_path, &dst_config_file_path).map_err(|e| { + std::io::Error::other(format!( + "Failed to copy service config file to {dst_config_file_path:?} with error: {e}" + )) + })?; + // set the file permissions to 644 for the service config unit file + proxy_agent_shared::linux::set_file_permissions(&dst_config_file_path, 0o644).map_err(|e| { + std::io::Error::other(format!( + "Failed to set file permissions for {dst_config_file_path:?} with error: {e}" + )) + })?; + + logger::write(format!( + "Copied service config file to {dst_config_file_path:?}" + )); + Ok(()) } fn backup_service_config_file(backup_folder: PathBuf) { @@ -94,7 +109,22 @@ pub fn copy_files(src_folder: PathBuf) { src_folder.join("azure-proxy-agent"), dst_folder.join("azure-proxy-agent"), ); + // set the file permissions to 755 for the azure-proxy-agent binary + proxy_agent_shared::linux::set_file_permissions(&dst_folder.join("azure-proxy-agent"), 0o755) + .unwrap_or_else(|e| { + logger::write_error(format!( + "Failed to set azure-proxy-agent file permission to 755 with error: {e}" + )); + }); + copy_file(src_folder.join(CONFIG_FILE), PathBuf::from(CONFIG_PATH)); + proxy_agent_shared::linux::set_file_permissions(&PathBuf::from(CONFIG_PATH), 0o644) + .unwrap_or_else(|e| { + logger::write_error(format!( + "Failed to set config file permission to 644 with error: {e}" + )); + }); + copy_file(src_folder.join(EBPF_FILE), PathBuf::from(EBPF_PATH)); } diff --git a/proxy_agent_setup/src/logger.rs b/proxy_agent_setup/src/logger.rs index 00db3cc2..dade5483 100644 --- a/proxy_agent_setup/src/logger.rs +++ b/proxy_agent_setup/src/logger.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation // SPDX-License-Identifier: MIT use proxy_agent_shared::{ - logger::{logger_manager, rolling_logger::RollingLogger, LoggerLevel}, + logger::{self, logger_manager, LoggerLevel}, misc_helpers, }; use std::path::PathBuf; @@ -12,10 +12,14 @@ pub fn init_logger() { } fn force_init_logger(log_folder: PathBuf, log_name: &str) { - let logger = RollingLogger::create_new(log_folder, log_name.to_string(), 20 * 1024 * 1024, 30); - let mut loggers = std::collections::HashMap::new(); - loggers.insert(log_name.to_string(), logger); - logger_manager::set_loggers(loggers, log_name.to_string(), LoggerLevel::Trace); + logger::init_loggers( + log_folder, + &[(log_name, log_name)], + log_name, + 20 * 1024 * 1024, + 30, + LoggerLevel::Trace, + ); } pub fn write(message: String) { diff --git a/proxy_agent_setup/src/main.rs b/proxy_agent_setup/src/main.rs index 4a90862e..f7a09cfe 100644 --- a/proxy_agent_setup/src/main.rs +++ b/proxy_agent_setup/src/main.rs @@ -13,6 +13,7 @@ pub mod setup; mod linux; use clap::Parser; +use proxy_agent_shared::constants::{PROXY_AGENT_SERVICE_DISPLAY_NAME, PROXY_AGENT_SERVICE_NAME}; use proxy_agent_shared::current_info; use proxy_agent_shared::misc_helpers; use proxy_agent_shared::service; @@ -20,12 +21,8 @@ use std::process; use std::time::Duration; use std::{fs, path::PathBuf}; -#[cfg(windows)] -const SERVICE_NAME: &str = "GuestProxyAgent"; -const SERVICE_DISPLAY_NAME: &str = "Microsoft Azure Guest Proxy Agent"; - -#[cfg(not(windows))] -const SERVICE_NAME: &str = "azure-proxy-agent"; +const SERVICE_NAME: &str = PROXY_AGENT_SERVICE_NAME; +const SERVICE_DISPLAY_NAME: &str = PROXY_AGENT_SERVICE_DISPLAY_NAME; #[tokio::main] async fn main() { diff --git a/proxy_agent_shared/Cargo.toml b/proxy_agent_shared/Cargo.toml index ad4aa5df..cad312d8 100644 --- a/proxy_agent_shared/Cargo.toml +++ b/proxy_agent_shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxy_agent_shared" -version = "1.0.44" +version = "1.0.45" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -14,7 +14,7 @@ serde = "1.0.152" serde_derive = "1.0.152" serde_json = "1.0.91" # json Deserializer serde-xml-rs = "0.8.1" # xml Deserializer with xml attribute -regex = "1.11" # match file name +regex = "1.11" # match file name thiserror = "1.0.64" tokio = { version = "1", features = ["fs", "rt", "macros", "net", "sync", "time"] } tokio-util = "0.7.11" @@ -61,11 +61,17 @@ features = [ os_info = "3.7.0" # read Linux OS version and arch sysinfo = "0.30.13" # read CPU & RAM information for Linux +[features] +default = [] +# Enables compute_signature (HMAC-SHA256 via OpenSSL on Linux). +# Binaries that don't sign anything (e.g. proxy_agent_setup) should leave this off. +signing = ["dep:openssl"] + # For MUSL targets (Linux MUSL) [target.'cfg(all(target_env = "musl", not(target_os = "windows")))'.dependencies] -openssl = { version = "0.10", features = ["vendored"] } +openssl = { version = "0.10", features = ["vendored"], optional = true } # For GNU Linux (optional, if you want system OpenSSL) [target.'cfg(all(target_env = "gnu", not(target_os = "windows")))'.dependencies] -openssl = "0.10" +openssl = { version = "0.10", optional = true } \ No newline at end of file diff --git a/proxy_agent_shared/src/constants.rs b/proxy_agent_shared/src/constants.rs new file mode 100644 index 00000000..77be38d4 --- /dev/null +++ b/proxy_agent_shared/src/constants.rs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// SPDX-License-Identifier: MIT + +//! Workspace-wide constants shared across the agent, extension, and setup crates. +//! +//! This module consolidates values that previously lived in three +//! separate `constants` modules and had silently drifted apart. + +/// The OS service name under which the proxy agent runs. +/// +/// - Windows: registered Service Control Manager name. +/// - Linux: `systemd` unit name (matches the binary installed at +/// `/usr/sbin/azure-proxy-agent` and packaged in the .deb / .rpm). +#[cfg(windows)] +pub const PROXY_AGENT_SERVICE_NAME: &str = "GuestProxyAgent"; +#[cfg(not(windows))] +pub const PROXY_AGENT_SERVICE_NAME: &str = "azure-proxy-agent"; + +/// Human-readable display name for the proxy agent service. +pub const PROXY_AGENT_SERVICE_DISPLAY_NAME: &str = "Microsoft Azure Guest Proxy Agent"; diff --git a/proxy_agent_shared/src/error.rs b/proxy_agent_shared/src/error.rs index b933a999..13c51635 100644 --- a/proxy_agent_shared/src/error.rs +++ b/proxy_agent_shared/src/error.rs @@ -16,7 +16,7 @@ pub enum Error { #[error("Hex encoded key '{0}' is invalid: {1}")] Hex(String, hex::FromHexError), - #[cfg(not(windows))] + #[cfg(all(not(windows), feature = "signing"))] #[error("ComputeSignature error in {0}: {1}")] ComputeSignature(String, openssl::error::ErrorStack), #[cfg(windows)] diff --git a/proxy_agent_shared/src/hyper_client.rs b/proxy_agent_shared/src/hyper_client.rs index fb9e2744..e63827e5 100644 --- a/proxy_agent_shared/src/hyper_client.rs +++ b/proxy_agent_shared/src/hyper_client.rs @@ -6,7 +6,6 @@ use super::error::{Error, HyperErrorType}; use super::misc_helpers; use super::result::Result; -use http::request::Builder; use http::request::Parts; use http::Method; use http_body_util::combinators::BoxBody; @@ -274,8 +273,8 @@ pub fn build_request( endpoint: &HostEndpoint, headers: &HashMap, body: Option<&[u8]>, - key_guid: Option, - key: Option, + _key_guid: Option, + _key: Option, ) -> Result>> { let mut request_builder = Request::builder() .method(method) @@ -301,19 +300,22 @@ pub fn build_request( request_builder = request_builder.header(key, value); } - if let (Some(key), Some(key_guid)) = (key, key_guid) { - let body_vec = body.map(|b| b.to_vec()); - let input_to_sign = request_to_sign_input(&request_builder, body_vec)?; - let authorization_value = format!( - "{} {} {}", - AUTHORIZATION_SCHEME, - key_guid, - misc_helpers::compute_signature(&key, input_to_sign.as_slice())? - ); - request_builder = request_builder.header( - AUTHORIZATION_HEADER.to_string(), - authorization_value.to_string(), - ); + #[cfg(feature = "signing")] + { + if let (Some(key), Some(key_guid)) = (_key, _key_guid) { + let body_vec = body.map(|b| b.to_vec()); + let input_to_sign = request_to_sign_input(&request_builder, body_vec)?; + let authorization_value = format!( + "{} {} {}", + AUTHORIZATION_SCHEME, + key_guid, + misc_helpers::compute_signature(&key, input_to_sign.as_slice())? + ); + request_builder = request_builder.header( + AUTHORIZATION_HEADER.to_string(), + authorization_value.to_string(), + ); + } } let boxed_body = match body { @@ -407,7 +409,11 @@ pub fn as_sig_input(head: Parts, body: Bytes) -> Vec { data } -fn request_to_sign_input(request_builder: &Builder, body: Option>) -> Result> { +#[cfg(feature = "signing")] +fn request_to_sign_input( + request_builder: &http::request::Builder, + body: Option>, +) -> Result> { let mut data: Vec = match request_builder.method_ref() { Some(m) => m.as_str().as_bytes().to_vec(), None => { diff --git a/proxy_agent_shared/src/lib.rs b/proxy_agent_shared/src/lib.rs index 6549eb75..3d10695e 100644 --- a/proxy_agent_shared/src/lib.rs +++ b/proxy_agent_shared/src/lib.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT pub mod common_state; +pub mod constants; pub mod current_info; pub mod error; #[cfg(windows)] diff --git a/proxy_agent_shared/src/linux.rs b/proxy_agent_shared/src/linux.rs index b09cbe0b..8e765c2d 100644 --- a/proxy_agent_shared/src/linux.rs +++ b/proxy_agent_shared/src/linux.rs @@ -5,8 +5,11 @@ use crate::logger::logger_manager; use crate::misc_helpers; use crate::result::Result; use once_cell::sync::Lazy; +#[cfg(feature = "signing")] use openssl::hash::MessageDigest; +#[cfg(feature = "signing")] use openssl::pkey::PKey; +#[cfg(feature = "signing")] use openssl::sign::Signer; use os_info::Info; use serde_derive::{Deserialize, Serialize}; @@ -115,6 +118,7 @@ pub fn get_cgroup2_mount_path() -> Result { )) } +#[cfg(feature = "signing")] pub fn compute_signature(hex_encoded_key: &str, input_to_sign: &[u8]) -> Result { match hex::decode(hex_encoded_key) { Ok(key) => { @@ -174,9 +178,18 @@ pub fn read_proc_memory_status(pid: u32) -> Result { Ok(MemStatus { vmrss_kb, vmhwm_kb }) } +/// Set the file permissions for a file or directory. +pub fn set_file_permissions(path: &PathBuf, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let permissions = fs::Permissions::from_mode(mode); + fs::set_permissions(path, permissions)?; + Ok(()) +} + #[cfg(test)] mod tests { use crate::misc_helpers; + use std::os::unix::fs::PermissionsExt as _; #[test] fn get_os_version_tests() { @@ -228,4 +241,15 @@ mod tests { } }; } + + #[test] + fn set_file_permissions_test() { + let test_file_path = "/tmp/test_file_permissions.txt"; + std::fs::write(test_file_path, "test").unwrap(); + let path = std::path::PathBuf::from(test_file_path); + super::set_file_permissions(&path, 0o644).unwrap(); + let metadata = std::fs::metadata(test_file_path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o644); + std::fs::remove_file(test_file_path).unwrap(); + } } diff --git a/proxy_agent_shared/src/logger.rs b/proxy_agent_shared/src/logger.rs index e5933f00..d8171e9b 100644 --- a/proxy_agent_shared/src/logger.rs +++ b/proxy_agent_shared/src/logger.rs @@ -2,12 +2,50 @@ // SPDX-License-Identifier: MIT use crate::misc_helpers; +use std::collections::HashMap; +use std::path::PathBuf; pub mod logger_manager; pub mod rolling_logger; pub type LoggerLevel = log::Level; +/// This function is a convenience wrapper that sets up the logging infrastructure for a given role. +/// +/// # Arguments +/// * `log_folder` — directory the rolling logs are written to. +/// * `files` — `(logger_key, file_name)` pairs; each pair becomes one +/// `RollingLogger`. The keys are what call sites use later via +/// `logger_manager::log(key, …)`. +/// * `primary_key` — the default logger key (must appear in `files`). +/// * `max_file_size` — per-file size budget passed to `RollingLogger`. +/// * `max_file_count` — number of rolled files retained. +/// * `max_level` — maximum file-log level. +/// +/// # Panics +/// `logger_manager::set_loggers` panics if `primary_key` is not present in `files`. +/// this function preserves that contract. +pub fn init_loggers( + log_folder: PathBuf, + files: &[(&str, &str)], + primary_key: &str, + max_file_size: u64, + max_file_count: u16, + max_level: LoggerLevel, +) { + let mut loggers = HashMap::with_capacity(files.len()); + for (key, file_name) in files { + let logger = rolling_logger::RollingLogger::create_new( + log_folder.clone(), + (*file_name).to_string(), + max_file_size, + max_file_count, + ); + loggers.insert((*key).to_string(), logger); + } + logger_manager::set_loggers(loggers, primary_key.to_string(), max_level); +} + const HEADER_LENGTH: usize = 34; pub fn get_log_header(level: LoggerLevel) -> String { get_log_header_with_length( diff --git a/proxy_agent_shared/src/logger/logger_manager.rs b/proxy_agent_shared/src/logger/logger_manager.rs index 4d3bf188..88eb21f8 100644 --- a/proxy_agent_shared/src/logger/logger_manager.rs +++ b/proxy_agent_shared/src/logger/logger_manager.rs @@ -210,18 +210,13 @@ mod tests { fn setup() { TEST_INIT.call_once(|| { - // Setup logger_manager for unit tests - let logger = crate::logger::rolling_logger::RollingLogger::create_new( + // Setup logger_manager for unit tests via the shared one-shot helper. + crate::logger::init_loggers( get_temp_test_dir(), - "test.log".to_string(), + &[(TEST_LOGGER_KEY, "test.log")], + TEST_LOGGER_KEY, 200, 6, - ); - let mut loggers = std::collections::HashMap::new(); - loggers.insert(TEST_LOGGER_KEY.to_string(), logger); - crate::logger::logger_manager::set_loggers( - loggers, - TEST_LOGGER_KEY.to_string(), LoggerLevel::Trace, ); diff --git a/proxy_agent_shared/src/misc_helpers.rs b/proxy_agent_shared/src/misc_helpers.rs index 9d121c0d..dfe4172d 100644 --- a/proxy_agent_shared/src/misc_helpers.rs +++ b/proxy_agent_shared/src/misc_helpers.rs @@ -249,8 +249,18 @@ pub fn json_read_from_file(file_path: &Path) -> Result where T: DeserializeOwned, { - let file = File::open(file_path)?; - let obj: T = serde_json::from_reader(file)?; + // Read the whole file to bytes so we can transparently skip an optional + // UTF-8 BOM (EF BB BF). serde_json does not strip a BOM and would otherwise + // fail the parse with "expected value at line 1 column 1" for any file + // produced by editors / tools that default to BOM-prefixed UTF-8 (e.g. + // Windows PowerShell 5.1's `Set-Content -Encoding UTF8`, Notepad, VS Code's + // "UTF-8 with BOM"). + let bytes = fs::read(file_path)?; + let payload = match bytes.as_slice() { + [0xEF, 0xBB, 0xBF, rest @ ..] => rest, + rest => rest, + }; + let obj: T = serde_json::from_slice(payload)?; Ok(obj) } @@ -515,7 +525,7 @@ pub fn resolve_env_variables(input: &str) -> String { /// let input_to_sign = b"Sample input data"; /// let signature = misc_helpers::compute_signature(hex_encoded_key, input_to_sign).unwrap(); /// ``` -#[cfg(not(windows))] +#[cfg(all(not(windows), feature = "signing"))] pub use linux::compute_signature; #[cfg(windows)] pub use windows::compute_signature; @@ -587,6 +597,53 @@ mod tests { _ = fs::remove_dir_all(&temp_test_path); } + #[test] + fn json_read_from_file_skips_utf8_bom_test() { + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct Small { + name: String, + value: u32, + } + + let mut temp_test_path = env::temp_dir(); + temp_test_path.push("json_read_from_file_skips_utf8_bom_test"); + _ = fs::remove_dir_all(&temp_test_path); + super::try_create_folder(&temp_test_path).unwrap(); + + let body = r#"{"name":"hello","value":42}"#; + let expected = Small { + name: "hello".to_string(), + value: 42, + }; + + // 1. BOM-less file parses (regression guard for existing behavior). + let no_bom = temp_test_path.join("no_bom.json"); + fs::write(&no_bom, body.as_bytes()).unwrap(); + assert_eq!( + super::json_read_from_file::(&no_bom).unwrap(), + expected + ); + + // 2. UTF-8 BOM-prefixed file parses (the actual fix). + let with_bom = temp_test_path.join("with_bom.json"); + let mut bytes = Vec::with_capacity(3 + body.len()); + bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); + bytes.extend_from_slice(body.as_bytes()); + fs::write(&with_bom, &bytes).unwrap(); + assert_eq!( + super::json_read_from_file::(&with_bom).unwrap(), + expected + ); + + // 3. A bare BOM (no payload) still surfaces as a parse error rather + // than being silently treated as an empty document. + let bom_only = temp_test_path.join("bom_only.json"); + fs::write(&bom_only, &[0xEF, 0xBB, 0xBF]).unwrap(); + assert!(super::json_read_from_file::(&bom_only).is_err()); + + _ = fs::remove_dir_all(&temp_test_path); + } + #[test] fn path_to_string_test() { let path = "path_to_string_test"; @@ -767,6 +824,7 @@ mod tests { assert_eq!(expected, resolved, "resolved string mismatch"); } + #[cfg(feature = "signing")] #[test] fn compute_signature_test() { let hex_encoded_key = "4A404E635266556A586E3272357538782F413F4428472B4B6250645367566B59"; diff --git a/proxy_agent_shared/src/windows.rs b/proxy_agent_shared/src/windows.rs index dd49d5d1..29c7ed36 100644 --- a/proxy_agent_shared/src/windows.rs +++ b/proxy_agent_shared/src/windows.rs @@ -46,7 +46,7 @@ use windows_sys::Win32::System::Threading::{ use winreg::enums::*; use winreg::RegKey; -fn read_reg_int(key_name: &str, value_name: &str, default_value: Option) -> Option { +pub fn read_reg_int(key_name: &str, value_name: &str, default_value: Option) -> Option { let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); match hklm.open_subkey(key_name) { Ok(key) => match key.get_value(value_name) { @@ -63,7 +63,7 @@ fn read_reg_int(key_name: &str, value_name: &str, default_value: Option) -> default_value } -fn read_reg_string(key_name: &str, value_name: &str, default_value: String) -> String { +pub fn read_reg_string(key_name: &str, value_name: &str, default_value: String) -> String { let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); if let Ok(key) = hklm.open_subkey(key_name) {