Policy Gates¶
ppee-policy.sh¶
A reusable gate script built on ppee-cli --json and jq. Download ppee-policy.sh
| Option | Fails when |
|---|---|
--require-signature | No Authenticode signature, or the embedded digest ≠ Authentihash (modified after signing) |
--require-hardening | ASLR (DYNAMIC_BASE), DEP (NX_COMPAT) or CFG (GUARD_CF and a non-empty CF function table) is missing |
--forbid-wx | Any section is both writable and executable |
--forbid-admin | The manifest requests requireAdministrator |
--forbid-import NAME | NAME (a function or DLL, case-insensitive) is imported, statically or delay-loaded. Repeatable |
--denylist FILE | The SHA-256 is listed in FILE |
--report FILE | (Not a check) writes a JSON Lines summary per file |
Exit codes: 0 all pass, 1 at least one violation, 2 a file couldn't be analyzed (the other files are still checked).
$ ppee-policy.sh --require-signature --require-hardening --forbid-wx --forbid-admin \
--forbid-import WriteProcessMemory --forbid-import wininet.dll --report ppee-report.jsonl dist/*.exe dist/*.dll
PASS dist/app.exe
FAIL dist/helper.dll: not signed;CFG off
FAIL dist/updater.exe: forbidden imports: wininet.dll
$ echo $?
1
Each line of the --report output looks like this:
{"path":"/src/dist/app.exe","sha256":"F31AF0B4…","signed":true,"intact":true,"signer":"Contoso Ltd",
"aslr":true,"dep":true,"cfg":true,"wxSections":0,"requireAdmin":false,"forbiddenImports":[]}
To run it through Docker instead of a local ppee-cli, set PPEE_CLI:
PPEE_CLI="docker run --rm -v $PWD:$PWD:ro -w $PWD ppee-cli" ./ppee-policy.sh --require-hardening dist/*.exe
Full source of ppee-policy.sh
#!/usr/bin/env bash
# ppee-policy.sh -- release gate for Windows PE build artifacts, built on ppee-cli --json.
#
# ppee-policy.sh [options] FILE...
# --require-signature fail if a file has no Authenticode signature, or was modified after signing
# --require-hardening fail unless ASLR (DYNAMIC_BASE), DEP (NX_COMPAT) and CFG (GUARD_CF + CF table) are on
# --forbid-wx fail on any section that is both writable and executable
# --forbid-admin fail on requestedExecutionLevel=requireAdministrator in the manifest
# --forbid-import NAME fail if NAME (function or DLL, case-insensitive) is imported; repeatable
# --denylist FILE fail if a file's SHA-256 is listed in FILE (one hash per line)
# --report FILE also write one JSON Lines summary per input file to FILE
#
# Environment: PPEE_CLI (default: ppee-cli) -- e.g. PPEE_CLI="docker run --rm -v $PWD:$PWD:ro -w $PWD ppee-cli"
# Exit code: 0 all files pass, 1 at least one policy violation, 2 usage or analysis error.
set -uo pipefail
PPEE_CLI="${PPEE_CLI:-ppee-cli}"
SIG=0 HARD=0 WX=0 ADMIN=0 DENY="" REPORT=""
IMPORTS=()
FILES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--require-signature) SIG=1 ;;
--require-hardening) HARD=1 ;;
--forbid-wx) WX=1 ;;
--forbid-admin) ADMIN=1 ;;
--forbid-import) IMPORTS+=("$2"); shift ;;
--denylist) DENY="$2"; shift ;;
--report) REPORT="$2"; shift ;;
-h|--help) sed -n '2,15p' "$0"; exit 0 ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*) FILES+=("$1") ;;
esac
shift
done
[[ ${#FILES[@]} -gt 0 ]] || { echo "usage: $0 [options] FILE..." >&2; exit 2; }
[[ -n "$REPORT" ]] && : > "$REPORT"
forbidden_json=$(printf '%s\n' "${IMPORTS[@]+"${IMPORTS[@]}"}" | jq -R . | jq -s 'map(select(length > 0) | ascii_downcase)')
failed=0 errors=0
for f in "${FILES[@]}"; do
if ! json=$($PPEE_CLI --no-update-check --no-similarity --json \
--headers --sections --hashes --imports --delay-imports --security --loadconfig --appmanifest "$f" 2>/dev/null); then
echo "ERROR $f: ppee-cli could not analyze the file" >&2
errors=1
continue
fi
summary=$(jq -c --argjson forbidden "$forbidden_json" '
def hex: ascii_downcase | explode | reduce .[] as $c (0; . * 16 + (if $c >= 97 then $c - 87 else $c - 48 end));
def bit($n): (. / $n | floor) % 2 == 1;
(.headers["OptionalHeader.DllCharacteristics"] | hex) as $dc
| ([.sections | to_entries[] | select(.key | endswith(".Characteristics")) | .value | hex
| select(bit(2147483648) and bit(536870912))] | length) as $wx
| ([.imports[], .delayImports[] | .name as $m | ($m | ascii_downcase), (.functions[] | .name // empty | ascii_downcase)]) as $imp
| {
path: .path,
sha256: .fileInfo.sha256,
signed: .security.present,
intact: ([.security.signatures[] | .embeddedDigest == .authentihash] | all),
signer: (.security.signatures[0].signerCertificate.subjectName // null),
aslr: ($dc | bit(64)), dep: ($dc | bit(256)),
cfg: (($dc | bit(16384)) and ((.loadConfig.guardCFFunction.entries // []) | length > 0)),
wxSections: $wx,
requireAdmin: ([.appManifest.rows[]? | select(.member | test("Level")) | .value] | any(. == "requireAdministrator")),
forbiddenImports: [$forbidden[] as $x | select($imp | index($x)) | $x]
}' <<<"$json")
[[ -n "$REPORT" ]] && echo "$summary" >> "$REPORT"
reasons=()
j() { jq -r "$1" <<<"$summary"; }
if (( SIG )); then
[[ $(j .signed) == true ]] || reasons+=("not signed")
[[ $(j .signed) == true && $(j .intact) != true ]] && reasons+=("modified after signing")
fi
if (( HARD )); then
[[ $(j .aslr) == true ]] || reasons+=("ASLR off")
[[ $(j .dep) == true ]] || reasons+=("DEP off")
[[ $(j .cfg) == true ]] || reasons+=("CFG off")
fi
(( WX )) && [[ $(j .wxSections) != 0 ]] && reasons+=("$(j .wxSections) W+X section(s)")
(( ADMIN )) && [[ $(j .requireAdmin) == true ]] && reasons+=("requireAdministrator manifest")
bad_imports=$(j '.forbiddenImports | join(",")')
[[ -n "$bad_imports" ]] && reasons+=("forbidden imports: $bad_imports")
if [[ -n "$DENY" ]] && grep -qix "$(j .sha256)" "$DENY"; then reasons+=("SHA-256 on denylist"); fi
if [[ ${#reasons[@]} -eq 0 ]]; then
echo "PASS $f"
else
echo "FAIL $f: $(IFS=';'; echo "${reasons[*]}")"
failed=1
fi
done
(( errors )) && exit 2
exit $failed
Individual checks¶
Each check below is a standalone one-liner. The hex/bit helpers are defined inline.
Require a signature¶
ppee-cli --json --security --no-similarity "$f" | jq -e '
.security.present and ([.security.signatures[] | .embeddedDigest == .authentihash] | all)' > /dev/null \
|| { echo "::error file=$f::unsigned or modified after signing"; exit 1; }
Hardening flags¶
ppee-cli --json --headers --loadconfig --no-similarity "$f" | jq -e '
def hex: ascii_downcase | explode | reduce .[] as $c (0; . * 16 + (if $c >= 97 then $c - 87 else $c - 48 end));
def bit($n): (. / $n | floor) % 2 == 1;
(.headers["OptionalHeader.DllCharacteristics"] | hex) as $dc
| ($dc | bit(64)) and ($dc | bit(256)) and ($dc | bit(16384))
and (.loadConfig.guardCFFunction.entries | length > 0)' > /dev/null \
|| { echo "$f: ASLR/DEP/CFG missing"; exit 1; }
No requireAdministrator¶
ppee-cli --json --appmanifest --no-similarity "$f" \
| jq -e '[.appManifest.rows[] | select(.member|test("Level")) | .value] | index("requireAdministrator") | not' > /dev/null \
|| { echo "$f requests elevation"; exit 1; }
No new imports since the last release¶
imports() { ppee-cli --json --imports --delay-imports --no-similarity "$1" \
| jq -r '.imports[], .delayImports[] | .name as $m | .functions[] | "\($m)!\(.name // "#\(.ordinal)")"' | sort -u; }
comm -13 <(imports previous/app.exe) <(imports dist/app.exe) | tee new-imports.txt
[ ! -s new-imports.txt ] || { echo "New imports; review them"; exit 1; }
Known-bad hash¶
sha=$(ppee-cli --json --hashes --no-similarity "$f" | jq -r .fileInfo.sha256)
grep -qix "$sha" denylist.txt && { echo "$f is on the denylist"; exit 1; }
Windows runners
On Windows, ppee-cli.exe --security also reports security.validity.result. Require "SIGNED & VERIFIED" to check chain trust as well as integrity:
References¶
- /DYNAMICBASE, address space layout randomization (Microsoft): the linker option behind the DYNAMIC_BASE flag.
- Data Execution Prevention (Microsoft): what the NX_COMPAT flag turns on.
- Control Flow Guard (Microsoft): what the CFG flag and Guard tables protect.
- Get-AuthenticodeSignature (Microsoft PowerShell): Windows' own signature check, for comparison in scripts.
- jq manual: the filter language used in the JSON examples on this page.