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.
# 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
}
- Match all URIs that start with
https://mirror.init7.net/ubuntu/. - 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 midnight — time-window: daily 22:00-06:00 is active from 22:00 until 06:00 the next morning. start == end is rejected as ambiguous. |
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:
- During weekday business hours: the id-10
permitwins (lowest id), vendor traffic is allowed. - Outside that window: the id-10 rule does not match, so the id-90
denytakes 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].pathfile fails the reload loudly —egpolicy loadexits non-zero and a liverequest policy reloadreports 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'stime-window:is recurring weekly/daily only. - Named, reusable time-range objects — defining
BUSINESS-HOURSonce and referencing it from many rules. Today the window is inline per rule; if two rules need the same hours, each spells out its owntime-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::
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 getsoverride_max_attemptstries peroverride_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::
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: |
0–3 |
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: |
0–5 |
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). |
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$EDITORintegration.new,editandremoverecompile the policy set and tell the engine to reload on save / confirm — no separate reload step needed.egpolicy— the engine compilation utility thateghost policycalls. Use it directly only for bind-mount ordocker cpedits, CI lints, and offline rebuilds.
For the online policy-reload verb operators run from the egctl REPL, see the egctl reference.