Skip to content

Policies

Policies define which subnets, hosts, user agents, SNI patterns, or other request attributes are permitted, denied, warned about, or shown an Acceptable Use Policy gate before they reach the proxy upstream. They are the primary mechanism by which operators control web traffic in EnforceGate vX.

Policies are ordered sets of rules. The fine-grained control they provide protects endpoints and infrastructure against a wide range of threats — C2 channels, malware downloads, data exfiltration, and other malicious activity.

The policy file format closely resembles JavaScript Object Notation (JSON) but is significantly more permissive: it supports inline and block comments, multi-line values, and unquoted keys and string values.

Rule files

Policy rules are defined in .policy files under /etc/enforcegate/rules.d/ inside the standalone container. The directory holds an arbitrary number of files; they are loaded in lexicographical order and the engine assigns rule ids in load order. The lowest-id matching rule wins on every match path — so the conventional two-digit precedence prefix (e.g. 05-, 40-, 90-) controls precedence by way of controlling the id. A rule in 10-allow-internal.policy always wins over a conflicting rule in 90-block-categories.policy. This approach is modelled on the popular udevd rules directory and significantly simplifies policy management.

Policies

A rule file consists of one or more policy blocks. Each block must contain a name and at least one match attribute.

10-packages-repo.policy
# Allow Package Mirrors

accept-ubuntu-initseven: {
    match-uri: https://mirror.init7.net/ubuntu/      # (1)!
    action: permit
    description: Local Ubuntu Mirror
}

accept-mirror-switch: {
    match-uri-regex: ^(https?:\/\/)?.*mirror\.switch\.ch.*   # (2)!
    action: permit
    description: SWITCH mirror
}
  1. Match all URIs that start with https://mirror.init7.net/ubuntu/.
  2. Match all URIs (HTTP or HTTPS, with or without scheme) whose domain is mirror.switch.ch. Regular-expression syntax — see the Squid connector reference for the full set of request attributes available to match against.

Both policies above use URI matching. The first uses a literal match-uri prefix; the second uses match-uri-regex (a regular expression). The action: permit allows the request to proceed. The scheme (http / https) is implicit in the URL itself — a separate application: attribute is not required and would produce a load-time Warning as of 2026.46.0.

No-match verdict — [policy].default_action

Requests that no rule matches receive the engine's synthesised default verdict, set by [policy].default_action. The shipped default is "permit" — non-disruptive for new deployments. To flip to a strict default-deny posture, set default_action = "deny" in engine.conf; for warn-by-default or AUP-gate-by-default, set "warn" or "aup". The verdict is engine-synthesised — it does not occupy a rule id, so it can never shadow a rule in [policy].shared_path. The verdict pages and show policy match report the synthesised rule name as default-permit / default-deny / default-warn / default-aup.

Pre-2026.32.0 deployments shipped a 99-default-permit.policy catch-all rule to encode the no-match verdict. That file is retired in 2026.32.0 — see the changelog.

Comment lines or blocks

.policy files support comments in three styles:

  • # this is a comment — line comment.
  • // this is also a comment — line comment.
  • /* this is a block comment */ — supports multi-line.

Available match attributes

The full match-attribute reference is part of the policy DSL specification (currently proprietary). The most commonly used:

Attribute Matches
match-uri URI literal prefix.
match-uri-regex URI regular expression.
match-domain-list Path to a file containing one domain per line. The file must end in .list — the engine rejects any other extension at policy load. The engine generates one host-anchored regex per line, with dots auto-escaped.
match-url-list Path to a file of literal URLs, one per line (# comments and blanks skipped — URLhaus / OpenPhish native format). The file must end in .list. Matched exactly after canonicalization (http/https unified, default ports stripped, dot-segments resolved, percent-encoding normalised; query kept, fragment dropped, trailing slash significant). Semantics changed in 2026.50.0 — lines were previously treated as left-anchored regexes; that behaviour moved to match-uri-regex-list below. A line that looks like a regex (leading ^ or a backslash) is skipped with a Warning.
match-urls Inline literal-URL array (match-urls: [ "https://evil.example/dropper.exe" ], 256-entry cap). Same canonical exact-match semantics as match-url-list. New in 2026.50.0.
match-uri-regex-list Path to a file of left-anchored regexes, one per line — the behaviour match-url-list had before 2026.50.0, under an honestly-named attribute. Use for pattern lists; use match-url-list for literal IOC feeds.
match-url-prefix-list Path to a file of literal URL prefixes, one per line — each entry covers its whole path subtree: evil.com/malware matches evil.com/malware and evil.com/malware/dropper.exe but not the sibling evil.com/malware-tools (boundaries are path segments). Query dropped and trailing slash stripped at canonicalization; a host-only entry (no path) is skipped — host-level blocking stays match-domain-list's job. New in 2026.50.0.
match-url-prefixes Inline prefix array (256-entry cap). Same subtree semantics as match-url-prefix-list. New in 2026.50.0.
match-client-ip Client source IP — single IPv4 / IPv6 address or CIDR (e.g. 10.1.65.9, 10.20.30.0/24). An inline array is accepted for a small set: match-client-ip: [ "10.1.65.0/24", "10.2.0.5" ]. Enforced in 2026.46.0+.
match-user-agent HTTP User-Agent regular expression. Canonical spelling as of 2026.46.0; historical match-useragent still accepted as a legacy alias.
match-method HTTP method (GET, POST, …), case-insensitive. Enforced in 2026.46.0+.
match-sni TLS SNI literal — a single hostname (match-sni: bank.example.com) or an inline array (match-sni: [ "a.com", "b.com" ]). Enforced on the SSL-bump peek path from 2026.47.5 onwards. Requires a pin: verdict — a match-sni on an action: rule (rather than pin:) is rejected at reload; SNI matching only exists on the bump path. Host-suffix walking, same as match-domains (bank.example.com matches login.bank.example.com too). See Pinned destinations.
match-sni-regex TLS SNI regular expression. Roadmap — the pin index is a host-suffix exact map, not a regex engine; the literal match-sni above covers the common case via the suffix walk.
match-category One or more categories from the Exosys Curated Domain Lists feed — a bareword (match-category: gambling) or a quoted array (match-category: [ "social", "streaming" ]). See Managed categories.

Multiple match-* attributes on the same rule are AND-combined — the rule matches only when the URL / domain dimension and every other match-* gate (client IP, method, user-agent, time window) hold. For an OR, split into separate rules. match-client-ip, match-method, match-user-agent, and time-window: are secondary gates in the verdict path: a URL-matched rule is accepted only if the request's client IP, method, User-Agent, and time-of-day satisfy every gate the rule specifies; otherwise the request falls through to the next lowest-id rule.

match-url-list changed meaning in 2026.50.0

Before 2026.50.0, each line of a match-url-list file was treated as a left-anchored regex (metacharacters live). From 2026.50.0 each line is a literal URL, matched exactly after canonicalization — so ? and . no longer act as regex operators, and lists pulled from threat feeds (URLhaus, OpenPhish) work verbatim without escaping. Operators who relied on the regex behaviour should rename the attribute to match-uri-regex-list — the file format and the matching are identical to the old code. The engine flags likely-regex lines (leading ^ or a backslash) in a literal list with a load-time Warning naming the fix.

Hand-written regex pitfalls

Operators writing match-uri-regex by hand should remember to escape ., anchor with ^https?:// or ^(https?://)?, and end with (/|$) to avoid sub-string matches against unrelated hosts. See troubleshooting for the canonical pitfalls list. match-domain-list does these transformations automatically. For literal URL lists (threat-feed IOCs), skip regex entirely — match-url-list matches exactly with no escaping needed.

Time-scheduled rules

Any rule can carry an optional time-window: attribute. The rule only matches during the window; outside it, the rule behaves as if it were not loaded and the request falls through to the next rule. A rule with no time-window: is always active — the attribute is purely additive, and every existing policy keeps working unchanged.

The grammar is time-window: [<days>] HH:MM-HH:MM:

Token Meaning
<days> (optional) One of daily (default), weekdays (Mon–Fri), weekend (Sat + Sun), or a comma-separated list of 3-letter day names: mon, tue, wed, thu, fri, sat, sun (any subset, any order).
HH:MM-HH:MM 24-hour clock range. If start > end, the window wraps past midnighttime-window: daily 22:00-06:00 is active from 22:00 until 06:00 the next morning. start == end is rejected as ambiguous.
40-block-social-media.policy
block-social-business-hours: {
    match-domain-list: /etc/enforcegate/rules.d/lists/social-media.list
    action: deny
    description: No social media during work hours
    time-window: weekdays 08:00-18:00
}

time-window: works on every rule shape — match-uri, match-uri-regex, match-domain-list, match-url-list / match-urls, match-url-prefix-list / match-url-prefixes, match-uri-regex-list, match-category, match-sni, match-client-ip, match-user-agent, match-method.

Fall-through, not flip — the important mental model

When a rule's window is closed, the engine treats the rule as non-existent for that request. The match falls through to the next-applicable rule, and ultimately to the catch-all baseline. A closed window does not turn a permit into a deny (or vice versa).

This composes with the lowest-id-wins precedence operators already know. Worked example:

10-allow-vendor.policy   permit  time-window: weekdays 08:00-18:00
90-deny-vendor.policy    deny
  • During weekday business hours: the id-10 permit wins (lowest id), vendor traffic is allowed.
  • Outside that window: the id-10 rule does not match, so the id-90 deny takes over.

A time-limited permit placed above a broad deny therefore gives "allow only during these hours, deny the rest of the time" with no extra rules. A time-limited deny placed above a permit gives the inverse.

The engine re-evaluates every request — there is no "session already allowed" carry-over. When the clock crosses the window edge, the next request gets the new verdict immediately.

Timezone

Windows evaluate against the engine's local wall clock by default. The [policy].time_window_tz knob in engine.conf switches this to UTC — see [policy] reference. On a local deployment, keep NTP in sync: a wrong system clock makes time-windows fire at the wrong times. Confirm what "local" means on the engine with show clock.

Validation

  • A malformed time-window: in a hand-authored [policy].path file fails the reload loudlyegpolicy load exits non-zero and a live request policy reload reports failure, naming the file, the rule, and the problem. The previous good policy stays live (same strict-file behaviour as any other parse error).
  • A malformed time-window: in a toolbox shared-dir file ([policy].shared_path) is not fatal — that one rule loads always-active with a Warning, and the rest of the load proceeds.

Scope of the 2026.31.0 implementation

The time-window: attribute described above is shipped and supported as of 2026.31.0 — author it directly in any .policy file under [policy].path or [policy].shared_path and the engine enforces it. Two adjacent capabilities other gateway products expose are deferred to a future release and are not yet available:

  • Absolute one-shot windows — a specific calendar date range (Cisco IOS absolute / Palo Alto non-recurring). Today's time-window: is recurring weekly/daily only.
  • Named, reusable time-range objects — defining BUSINESS-HOURS once and referencing it from many rules. Today the window is inline per rule; if two rules need the same hours, each spells out its own time-window:.

Pinned destinations

Certificate-pinned destinations — Windows Update, Apple MDM, mobile banking, several enterprise SaaS clients — refuse the forged leaf certificate Squid mints during bump-mode SSL inspection and fail closed. The historical workaround was a static bypass file outside the policy system; from 2026.35.0 onwards, pinned destinations are handled by policy, using the same match-* attributes and the same loader as block / permit rules.

A pin rule carries a pin: attribute instead of action::

20-pinned.policy
pin-microsoft-update: {
    match-domain-list: /etc/enforcegate/rules.d/lists/pinned-microsoft.list
    pin: splice
    description: Microsoft cert-pinned endpoints
}

deny-pinned-app: {
    match-domain-list: /etc/enforcegate/rules.d/lists/blocked-pinned.list
    pin: terminate
    description: Block this pinned app entirely
}

The three pin: verdicts:

Verdict What Squid does at the peek step
splice Pass the connection through unchanged — hostname-only visibility, no content inspection. The right answer for "let it work without breaking pinning."
bump Inspect as normal. Only useful for destinations that don't actually pin (or where the operator has installed the bump CA into the client trust store).
terminate Refuse the connection at the TLS handshake. The client sees a connection failure; no captive-portal verdict page is rendered (the TLS tunnel is never established).

Matching is suffix-walk, identical to match-domain-list: for action: rules. A list entry windowsupdate.com pins *.windowsupdate.com; push.apple.com pins only *.push.apple.com, not apple.com. A host with no matching pin: rule defaults to bump — the engine inspects as normal.

pin: rules are orthogonal to action: rules. The pin: verdict is consulted at the SslBump peek step, before any certificate is forged; action: is consulted post-bump against the inspected request. A destination can be pinned (splice) and also have a permit / deny action: rule — the splice decides whether Squid sees the cleartext, the action decides what the engine does with it. For a host pinned to splice, the engine sees the SNI hostname only and any action: rule must match on that.

Pinning trades visibility for compatibility

A pinned destination cannot be content-inspected — splice lets it through with hostname-only visibility; terminate blocks it at the handshake. This is a property of how certificate pinning works, not a limitation of EnforceGate vX — every gateway product faces the same constraint. The value of the policy-based approach is that the engine's policy decides which destinations are pinned (not a static bypass file outside the policy system), so pin decisions are reloaded, snapshotted, git-tracked, and visible in show policy list alongside every other rule.

The connector's [ssl_bump_acl].fail_action knob controls what happens when the connector can't reach the engine for a peek-step verdict (engine down, session timeout). Default splice for availability — pinned apps keep working through an engine blip instead of breaking under a forced bump. Switch to bump for inspection-first deployments or terminate for fail-closed postures.

Pin rules are introspectable through the same verbs as action: rules — see show policy match, show policy list, and show policy summary for the operator-side views.

Available actions

Action Effect
permit Allow the request to proceed unchanged.
deny Block the request. The captive portal renders the block page.
warn Block the request with a Proceed-anyway CTA. The captive portal renders the warn page.
aup Show an Acceptable Use Policy gate. Visitor must accept before proceeding.

A deny is terminal unless the rule carries an override code, which lets a visitor who knows the PIN or passphrase through. The same attribute turns a warn or aup rule's open "Proceed anyway" into a code-gated proceed.

warn and aup verdicts require the engine to see a usable client IP — that is, request_line_format = 1 (QF-3) on the connector side, since only QF-3 carries the client IP all the way through to the engine. With request_line_format = 0, warn/aup degrade to a terminal block page with no actionable CTA. See Squid connector — [redirector].

Override codes

Any rule may carry an override-code attribute — a PIN or passphrase that lets a visitor who lands on that rule's portal page proceed by entering it. Use it for the blocks that need a documented, auditable exception: a category blocked for everyone but the staff who occasionally need it, a site reachable only with a supervisor present.

{
  name: block-social
  action: deny
  match-category: [ social_networks ]

  override-code: "4823"
  ack_scope: domain
  ack_duration: 1h
  override-prompt: "Staff bypass code"
}
Attribute Required Effect
override-code yes The PIN or passphrase. Its presence is what enables the override on the rule.
override-kind no pin, passphrase, or auto to infer. Forces how the portal renders the field. Defaults to auto when omitted.
override-prompt no Replaces the portal's label for the code field. Rendered verbatim and untranslated — supply your own wording only if the built-in translations do not suit.

A successful override grants exactly the same scoped session a warn or aup "Proceed" grants, so ack_scope: and ack_duration: control how far the bypass reaches and how long it lasts — they are not override-specific attributes. Set them deliberately: ack_scope: policy unlocks every host the rule covers.

On warn and aup, an override code tightens the rule

The effect depends on the action, and one direction surprises people:

Action Without override-code With override-code
deny Terminal — no way through Overridable — the code is the only way through
warn / aup Open proceed — anyone may click through Supervised proceed — the code is now required

So adding a code to a deny rule relaxes it, while adding one to a warn or aup rule restricts it: the open "Proceed anyway" button becomes a gate. That is deliberate — a supervised proceed is the point — but if you attach a code to a warn rule expecting the button to keep working for everyone, it will not.

How the code is rendered

The engine infers the widget from the code itself: an all-digit code of length 4 or 6 renders as PIN boxes, anything else as a masked passphrase field. Those two lengths are fixed and not configurable. Writing override-code: "4823" therefore gets a four-box PIN with no extra configuration. Set override-kind: explicitly when inference would guess wrong — a passphrase that happens to be all digits, for example. Either way the visitor confirms with an explicit Unlock button; nothing submits on the last keystroke, so a mistyped digit does not burn an attempt.

Brute-force protection

A four-digit PIN is only ten thousand guesses, so the engine — not the portal — counts failures. After override_max_attempts wrong submissions (default 5) that (client, rule) pair is locked out for override_lockout_s (default 15 minutes), and the portal tells the visitor how long is left. A wrong code does not consume the visitor's one-time token, so honest typos are retryable; only a correct code consumes it.

The protection is capped per source address, but not globally — worth understanding before choosing a code:

  • Per address, the cap is hard. The lockout is held against the (client address, rule) pair itself, independently of any redirect. Requesting a fresh block page does not buy a fresh budget: the new redirect meets the same counter. So one address gets override_max_attempts tries per override_lockout_s, and no more. Guessing even a four-digit PIN from a single address is impractical.
  • Across addresses, it is not. There is no engine-wide limit spanning clients, so a visitor whose address genuinely changes — a device moving between networks, a short DHCP lease — starts a fresh budget. An attacker able to rotate through many source addresses erodes the cap in a way a single client cannot.

Guessing is also not an open door in the first place: submitting any code requires a current, unused engine-issued redirect for that rule, so an attacker has to trigger the block and work inside its freshness window, and each redirect is worth at most a handful of tries before the lockout trips or the redirect expires.

Prefer six digits or a passphrase over a four-digit PIN wherever the code protects something that matters — the longer code moves guessing out of reach even for someone rotating addresses.

The code is hashed at policy load under a per-rule random salt and the plaintext is discarded — it is never written to a log and never leaves the engine in the clear. Both outcomes are audited: an override-ok or override-fail event is written for every attempt, recording the rule and client but never the code.

A rejected code disables the override without failing the reload

Two conditions cause the engine to drop an override at policy load: a code shorter than override_min_length (default 4), or a failure to generate the rule's random salt. Either way the engine logs a warning and the rule then offers no override at all — a deny stays terminal, a warn keeps its open proceed.

The reload still succeeds, so nothing fails loudly and the rule itself keeps working. Unless you read the engine log, the first sign is a visitor reporting there is no code field. Check the log after adding or changing a code.

One case that does not disable: override-kind: pin with a code containing non-digits still warns but renders digit boxes anyway.

Because the code lives in plaintext in the .policy file, treat those files as secrets: keep them out of world-readable paths and out of any repository you would not put a password in.

Managed categories

Managed-category feed

The match-category: attribute draws on the Exosys Curated Domain Lists feed, which ships in every edition. An engine running on the reduced-capability floor (see never bricks on a license lapse) has the feed disabled, and match-category rules will not match until a valid license is restored.

Exosys Curated Domain Lists are a signed, on-box managed-category feed — 60+ maintained categories (security threats, adult content, gambling, social networks, streaming, business & general-interest, and more; see the full category list) that operators enforce with ordinary .policy rules. Unlike the community script ecosystem (where the operator sources and refreshes their own lists), the curated feed is maintained by Exosys and shipped as a single sealed file.

A rule gates on one or more categories with match-category::

30-managed-categories.policy
deny-malware-and-phishing: {
    match-category: [ "malware", "phishing" ]
    action: deny
    description: Known-bad — blocked by Exosys curated threat feed
}

warn-gambling: {
    match-category: gambling
    action: warn
    description: Gambling — proceed with caution
}

match-category: composes with every other rule dimension — time-window:, match-client-ip:, match-method:, ack_scope: — and all four actions, inside the usual lowest-id-wins precedence. Matching is a single bounded host-suffix walk per request (host, then parent labels down to the registrable domain, Public-Suffix-List-aware so shared-host tenants stay isolated), then a bitwise category test — independent of the taxonomy size, so a rule against many categories costs no more than a rule against one.

Per-rule confidence and freshness thresholds

Every category entry in the feed carries a confidence score and an age (time since it was classified). The [feed] block sets the deployment-wide floor for both; a rule can tighten either with min-confidence: or max-age: — never loosen below the deployment floor.

Attribute Range Meaning
min-confidence: 03 Reject a categorisation below this confidence bucket: 0 = 0.80–0.90, 1 = 0.90–0.95, 2 = 0.95–0.99, 3 = 0.99+.
max-age: 05 Reject a categorisation older than this age bucket: 0 = under 1 day, 1 = under 7 days, 2 = under 30 days, 3 = under 90 days, 4 = under 365 days, 5 = any known age (default).
20-high-confidence-gambling.policy
deny-gambling-strict: {
    match-category: gambling
    min-confidence: 2
    max-age: 2
    action: deny
    description: Only act on gambling classifications we're most sure of and were made recently
}

Omitting either attribute inherits the [feed] global for that threshold; the two are independent, so tightening one does not require setting the other. An invalid value (out of range, or not a number) is ignored with a warning at policy load — the rule falls back to the [feed] global rather than failing the reload. See Curated feed for a walkthrough with worked examples.

Offline by design. The feed is a single file (/var/lib/enforcegate/managed.egsf by default) downloaded once at install and loaded at engine boot — no cloud lookup on the request path, and the deployment stays fully air-gapped after the initial download. Operators who opt in can pull incremental feed generations from the Exosys CDN (the feed publishes several times a day); a new generation is applied with request feed reload without an engine restart. The feed and its refresh cadence are configured in the [feed] block of engine.conf; the loaded generation and category counts are visible with show feed.

Managing policies

Two operator interfaces author and load policies:

  • eghost policy — host-side operator commands. The recommended path for day-to-day work. Wraps file authoring (new, edit, show, list, remove) plus the compile-and-reload cycle, with $EDITOR integration. new, edit and remove recompile the policy set and tell the engine to reload on save / confirm — no separate reload step needed.
  • egpolicy — the engine compilation utility that eghost policy calls. Use it directly only for bind-mount or docker cp edits, CI lints, and offline rebuilds.

For the online policy-reload verb operators run from the egctl REPL, see the egctl reference.