Skip to content

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:

$j = ppee-cli.exe --no-update-check --no-similarity --json --security $f | ConvertFrom-Json
if ($j.security.validity.result -ne 'SIGNED & VERIFIED') { throw "$f`: $($j.security.validity.result)" }

References