Changelog
v0.3.0 (2026-10-28)
- Changed: a prefix
locationand a^~location with the same path in one site are now rejected at load as a duplicate, as nginx rejects them. Before, both were accepted and the first one written was used. A configuration that has such a pair must keep one of them. An=location may still share its path with a prefix location.
v0.2.0 (2026-10-27)
- Added:
check_request_with, the library form ofcheck_requestthat takes an already loadedConfig, so a caller that checks many requests loads the configuration once. - Added: the
$query_shapelog variable and aquery_shapefield in the built-injsonformat. It records the names of the query parameters and the kind of each value (p=<num>&s=<text>), never the values, so a log shows which kinds of requests a site receives. See Logging. - Added:
vigilant-heron suggest-skip-rules <access-log>reads ajsonaccess log and suggests skip rules for request shapes that the model judged harmless many times, from many clients, over several days, and never blocked. It only prints the rules for review. See Learn skip rules from the access log. - Added: each GitHub Release has Linux binaries for x86_64 and aarch64 (glibc 2.39 or newer) and a
SHA256SUMSfile. See Download a release binary. - Fixed:
docker build -t vigilant-heron .without--targetbuilt the e2e stub decision backend instead of the router. The router is now the Dockerfile's last stage. - Fixed: a request path with a NUL byte could select a different
locationfor the block response, the headers and the WebSocket tunnel than for the limits, because the decoded path kept the NUL byte and the other readings did not. NUL bytes are now removed from every reading, so/%00adminselects the same location everywhere. - Fixed: the
suggest-skip-rulesreport now says that a learned rule forwards matching requests without the model, so the model no longer judges their headers or body. Block rules, built-in and your own, still run first.
v0.1.0 (2026-10-26)
Added:
vigilant-heron test-request <file> [--client <address>]reports what the router would do with one raw HTTP/1.1 request before deployment: the site, the access list,deny_path, the rules that match, and whether the request would go to the decision backend. It runs the server's own checks, never calls the upstream or the backend, and does not simulatelimit_reqorlimit_conncounts. See Check a request with test-request.Fixed: a stale pid file could make
-s,stop,restartandstartact on an unrelated process. When a router died without removing its pid file and the OS gave its pid to another process,-s stoporstopsignalled that process, andstartrefused with "already running". A running router now holds an exclusive lock on<pid file>.lock, and those commands treat a pid file as running only while the lock is held. A stale file is never signalled. Also, two routers with the samepidpath no longer overwrite each other's pid file: the second exits withanother router is using the pid file <path>.Changed: releases now run the tests and the advisory check (
cargo denyandpnpm audit) in a job without write access, and the publishing job keeps no credential in its checkout. The version tag is created through the REST API.Fixed: the opt-in virtual patches
vh-cve-2026-9103(/api/v1/login/auto_login) andvh-cve-2026-8481(/api/v1/validate/code) compared the path case-sensitively, so a request such as/API/V1/LOGIN/AUTO_LOGINgot past them. Both rules now match the path in any letter case. Which requests they block is otherwise unchanged.Security: more credentials are kept out of the decision backend. Headers named with
signature(for examplex-hub-signature-256,svix-signatureandx-slack-signature), withticketorauthenticationas a word, or ending inkey,authkeyorsessionkey(for examplex-functions-key,x-master-keyandx-admin-key) are now dropped. Body fields withsignaturein their name are redacted. The rules forticket,authenticationand thekeysuffix apply to header names only, so an ordinarykeyorticketbody or query field is still sent. Multipart parts whosenameis sensitive have their values replaced, and XML elements such as<password>and<wsse:Password>have their text replaced. Form bodies split on;as well as&, soa=1;password=xhides the password. A file part is redacted by its field name only. Its content is not scanned.Fixed: a
proxy_websockettunnel was read as if it were HTTP requests. After the101 Switching Protocols, a tunnelled chunk of more than 8 KiB without a line break (a binary WebSocket frame, for example) was answered with 414 and the connection was closed, and a tunnel open longer thanclient_header_timeout(60s by default) received a 408 while it was still active. Tunnelled bytes are now copied as they arrive, with no header limits and no header timer.Fixed: a zone keyed on a long client-chosen value, such as a request header, could use far more memory than its declared size, because each state stored the whole key. States now store a fixed-size digest of the key, so the declared size bounds the memory again.
Added: decision backend
strands(decision.backend strands;) for a self-hosted Strands Decider server, which speaks the System One protocol.modeldefaults tostrands-decider,urlis required, andapi_token_envis optional: without it, noAuthorizationheader is sent.Behaviour change: a client that holds its
max_concurrency_per_clientshare of decision calls is now answered 429 with an empty body, and the backend is not called. Before, that excess followedon_rate_limit, which defaults toallow, so it was forwarded without the model. The new site directiveon_client_busy allow|block;(defaultblock) controls it, andon_rate_limitno longer affects it. Seton_client_busy allowto keep the old forwarding. Shadow mode records the 429 as the would-have action. The decision logreasonisclient_busy_allowedorclient_busy_blocked.The query string sent to the decision backend is redacted. A parameter whose decoded name is sensitive, by the same rule as body fields, or named in
redact_field, has its value replaced by[REDACTED]. Other parameters are sent as the client sent them.;separates parameters as&does, sopassword=x;id=1hides the password and sendsid=1.Security (F2): short sensitive parts no longer match inside ordinary names.
ssn,card,cvvandpwdnow match only as whole words, soclass_name,business_name,process_name,address_numberanddiscardare sent to the decision backend unredacted.card_id,user_ssn,userSSN,cvvandnew_pwdare still redacted.creditcardandcardnumbermatch as parts of a name.Security (F1): the commands that signal the router (
-s,start,stopandrestart) refuse a pid file in a directory that other users can write, such as/tmp, when another user owns the file. A pid that is not a positive decimal number, such as0or-1, is refused with an error. Before,-1could signal every process of the user. The host guide now writes the pid file to a relative path,pid vigilant-heron.pid;, which is resolved from the directory the command runs in.Headers with a credential-like name are no longer sent to the decision backend, for example
x-csrf-token,x-xsrf-token,x-amz-security-token,x-access-tokenandx-session-id.host,user-agent,accept,referer,origin,x-forwarded-forand the other headers the model uses are still sent.A configuration reload keeps the decision slots. Calls admitted before a reload still count against
max_concurrencyandmax_concurrency_per_client, which a reload used to exceed. The slots are reused while their limit is unchanged. A changed limit gets new slots, and calls in flight keep the slots they hold.startno longer signals a process it has already reaped when the router exits while starting. The pid of a reaped process can belong to another process.The pid file's temporary file and the startup log are created exclusively. A file planted at those paths, such as a symlink in a shared directory like
/tmp, is no longer written through.The docs said that a request whose
Hostmatches no site is always 403. It getsdefault_status(421 by default) with reasonunknown_host, unless the top-level access list denies it. The docs now say so. The security notes also used the wrong namefail_closed_paths; the directive isfail_closed_path.stopwaitsworker_shutdown_timeoutplus 5 seconds, or 35 seconds when the directive is not set. The docs now give that figure.start,stopandrestartrun the router in the background on a host.startchecks the file first, refuses when a router already runs, and printsstarted (pid N).stopsends the graceful shutdown and waits until the process has exited, and printsnot runningwhen none runs.restartchecks the file first, so an invalid file leaves the running router alone. Under Docker or systemd, run the router in the foreground, as before.The shutdown signals (SIGINT, SIGTERM, SIGQUIT) are registered before the pid file is written. A stop sent right after the pid file appears now runs the cleanup that removes it, where it used to end the process without that cleanup.
docs/install.mdbuilds from source and from the Dockerfile. It no longer mentions a published image, and it has a systemd unit example.SECURITY.mdlists the reporting channels: email and a GitHub security advisory.Command line, as nginx's:
-h/--helpand-v/--version,-c <path>as a short form of--config,-T(test, then print the configuration with every include expanded under its# configuration file <path>:header), and-s reload|reopen|stop|quit.-ssends SIGHUP, SIGUSR1, SIGTERM or SIGQUIT to the pid in the file thepiddirective names. An unknown option prints the usage and exits 1.stopandquitare both graceful in this version.pid <path>;directive (default/run/vigilant-heron.pid). The router writes its process id there once it is listening and removes it on exit. A write failure is logged and the router keeps running. SIGQUIT now shuts down gracefully, like SIGTERM and SIGINT.docs/cli.mddescribes the command line, andpidis documented indocs/configuration.md. The migration notes from the README moved todocs/configuration.md.README rewritten as a short introduction with install, a quick start, the command line, the order of checks, and links to the documentation. The reference stays in
docs/.Removed
rustfmt.toml, which only repeatededition = "2024"fromCargo.toml.cargo fmtoutput is unchanged.enable_rule <id> ...;at the top level and in asiteturns on built-in rules that are off by default. A built-in rule written withdefault off;runs only where it is enabled. The nearest level that names a rule decides, a site's lists before the top level's, anddisable_rulewins overenable_rulewithin one level.default off;is valid only in the built-in rules.Opt-in virtual patches
vh-cve-2026-9103(IBM Langflow OSS/api/v1/login/auto_login, CVE-2026-9103) andvh-cve-2026-8481(IBM Langflow OSSPOST /api/v1/validate/code, CVE-2026-8481). Both are off by default. IBM's fix for CVE-2026-9103 is Langflow 1.10.1; for CVE-2026-8481, upgrade past 1.10.0 and check the bulletin. The docs page "From CVE advisories to rules" describes how to write, roll out and remove such rules, and the policy for shipped ones.Host matching as in nginx
server_name: exact names,*.example.com,example.*,.example.comand~regexnames in asite, with exact, then leading wildcard, then trailing wildcard, then regex precedence.default_server;marks the one site that takes hosts matching no site (at most one per configuration). A name claimed by two sites is still a configuration error.proxy_set_header <name> <value>;at the top level, in asiteand in alocation, with nginx's replace-per-level and empty-removes rules. Values take$client_ip,$remote_addr,$host,$site,$method,$path,$request_idand$http_<name>. The default headers (Host kept,X-Forwarded-Forappended) are unchanged. Hop-by-hop headers cannot be set.SIGHUPreloads the configuration likenginx -s reload. A valid file is applied to new requests, while requests in flight finish with the old one. A broken file is logged at error level with its file and line, and the old configuration keeps serving. Zones keep their counts when the name and size match. Listen address,worker_threads,max_connectionsand connection timers apply only at startup (a warning is logged). Theerror_loglevel and destination follow a reload: a destination that cannot be opened fails the reload.SIGUSR1still reopens the log files.vigilant-heron -t [--config <path>]checks the configuration likenginx -t: it validates the whole file (includes, rules, block pages, token variables), reports the result on stderr and exits 0 or 1. It never binds or serves.worker_shutdown_timeout <time>;(top level, startup only, unset by default). A shutdown now waits for open WebSocket tunnels as well as in-flight requests. Without the directive it waits indefinitely, as nginx does; with it, every connection and tunnel still open is closed when the time expires.proxy_websocket on|off;in asiteand alocation(default off). Withon, an HTTP/1.1 Upgrade handshake is screened like any request, then forwarded; a101from the upstream is spliced into a bidirectional tunnel that closes afterproxy_read_timeoutof inactivity, and counts againstlimit_conn. Only the handshake is judged, not the frames.block_status <code>;(4xx or 5xx, or 444 to close the connection without a response) andblock_page <file>;(HTML, at most 64 KiB, read at load time) for blocked requests, at the top level, in asiteand in alocation. Limit refusals and access-list denials (403) keep their statuses.Request-rate and connection limits, with nginx's
limit_reqandlimit_connsemantics:limit_req_zoneandlimit_conn_zoneat the top level,limit_req,limit_conn, their status, dry-run and log-level directives at the top level, in asiteand in alocation. Limits run before the access list, and a refused request gets its status (default 503) without reaching the backend. New access-log variables$limit_req_statusand$limit_conn_status.locationblocks insidesiteselect a path with nginx's matching rules.Breaking: the access list and the rules run before the decision backend, and a rule block now also comes from the built-in set. Existing configurations keep their behavior except for requests the built-in rules match, which are now blocked without a backend call. Turn them off with
builtin_rules off;, or disable single ids withdisable_rule.Access list, as in nginx:
allowanddenywith an address, a CIDR network orall, at the top level and in asite(a site's list replaces the top-level one). The first match decides, and the client address comes from the realip settings. A denied client gets 403 with reasonaccess_denied, before the body is read; on a shadow site it is forwarded and recorded withwould_have=block.Rules in nginx syntax:
rule <id> { description; action block|skip; ... }at the top level (all sites, or the sites named bysite) and inside asite. Conditions:method,path(prefix,=,^~,~,~*),query,header,body(the inspected prefix),anyandclient. Regexes use theregexcrate (linear time), with a 512-byte source and 1 MiB compiled size limit, and at most 1000 rules per configuration. Ids are unique across files andvh-is reserved.Built-in rules (
src/rules/builtin.conf, idsvh-<category>-<nnn>) for SQL injection, XSS, path traversal, command injection, file inclusion, SSRF, template injection, XXE and known scanner user agents. They are written to miss an attack rather than block code pastes, prose about SQL or HTML, base64 blobs or Japanese text.builtin_rulesanddisable_rulecontrol them, at the top level and per site.Logs:
$rule_idis the id of the rule that decided the request. The reasonsaccess_denied,ruleandskip_ruleare new. The built-injsonformat has arule_idfield.eval/rules.pymeasures the built-in rules on the labelled dataset and the combined pipeline with the saved model results.eval/run.pyruns with the built-in rules off, so it still measures the model alone.Breaking:
decision.max_bodyis replaced by the top-levelclient_max_body_size, which takes nginx's units and defaults to1m(max_bodydefaulted to 10m).client_max_body_size 0is unlimited. A body over the limit gets 413, before it is read whenContent-Lengthshows it. Uploads between 1 and 10 MB needclient_max_body_size 10m;.max_bodyis now a configuration error that names its replacement.Breaking: request bodies are streamed. Only the first
inspect_bodybytes are read for the decision, and the rest is forwarded as it arrives, so memory per request no longer grows with the body (up to 10 MB before).Connection timers with nginx's names and defaults:
client_header_timeout,client_body_timeout,send_timeout,keepalive_timeout(0 disables keep-alive) andkeepalive_requests. The connection timers and header limits are top-level only; the body timer andclient_max_body_sizecan be set per site.Lingering close, as nginx's
lingering_close: after an early response (413, 403, 408, 400, 414) the router half-closes and discards the client's remaining bytes, so a client that is still sending reads the response instead of a reset.lingering_close on|always|off(defaulton),lingering_time(30s) andlingering_timeout(5s) are top-level only. Lingering is bounded by both times.large_client_header_buffers: a request line over one buffer gets 414, a header field or a header section over its limit gets 400. A request header that stops arriving gets 408; a silent connection is closed.Upstream timers with nginx's names and defaults, per site:
proxy_connect_timeout,proxy_read_timeoutandproxy_send_timeout. A connect, read or send timeout before the response gets 504; a connection failure keeps 502.Access log variables
$upstream_statusand$upstream_error. A timer or limit that answers the client is logged with its status and$reason.Breaking: the configuration is nginx-style (
vigilant-heron.conf), not TOML. Each TOML key has a directive, times and sizes take units, andincludesplits files. The default path is/etc/vigilant-heron/vigilant-heron.conf. There is no automatic converter; see the README for the mapping.Breaking: the
vigilant_heron::decisiontracing event is replaced by access logs: one line per request to the destinations named byaccess_log, withlog_formatand the built-injsonandmainformats.error_logcontrols operational messages. Default:access_log stdout json;.client_ip_headeris replaced byreal_ip_from,real_ip_headerandreal_ip_recursive, with nginx semantics: the header is believed only from a trusted proxy.Defaults:
decision.backendis"jev"(with its URL, model and token variable),timeout_msis 300, site thresholds are 0.87 and 0.8. Configs that used the JSON protocol must setbackend = "http".Failure settings per site:
on_rate_limit(HTTP 429 and "no slot in time") andon_decision_error(everything else), each"allow"or"block". The decision log reportsreasonanddecision_error.fail_closed_pathwins over both.Concurrency:
max_connections,worker_threadsanddecision.max_concurrencybound client connections, runtime threads and decision-backend calls.decision.queue_timeout_ms(default 0) bounds the wait for a slot; a request that does not get one in that window is alwaysbackend_busy, a rate limit.request_id_headerlogs a request id with each decision event.The example config ships a list of commonly probed deny paths.
Evaluation harness (
eval/), a load tool, and the first Clef-flash and Jev measurement.Host-based router with deny rules, static skip, a decision backend over HTTP, block/redirect/shadow policy and fail-open/fail-closed paths
Docker image (
runtimetarget) and Compose-based e2e suiteCloudflare Clef decision backend (
decision.backend = "clef"), with the API token read from an environment variable and https supportSecurity (F10, F11):
escape jsonnow writes every control character (C0, DEL and the C1 range) and U+2028/U+2029 as\uXXXX, so a log line cannot carry terminal control sequences or be split by a Unicode line separator. The built-inmainformat quotescategory,retry_afterandrequest_id, so a client-chosen request id with spaces and=cannot add fields. Log lines in themainformat change for those three values (their values are now quoted).Security (F3): every line of
real_ip_headeris read, in order, and the lines are joined with commas before the right-to-left choice. Previously only the first line was read, so a trusted proxy that appended its own line (HAProxy'soption forwardfor) could not stop a client from choosing its address. A malformed line makes the whole header ignored, as before.Security (F6): a denial-of-service (DoS) attack against the decision backend, which keeps the concurrency slots full, could make requests skip the model. The defaults of
on_rate_limitandon_decision_errorstayallow; an earlier change that made themblockis reverted. The newdecisionmax_concurrency_per_clientdirective (default 8, at mostmax_concurrency) limits the calls in flight for one client address, keyed by the realip address. A client beyond its share is refused at once withdecision_error=client_busy, in the rate-limit group, so only that client followson_rate_limit, and other clients keep being judged. A DoS attack from many addresses is not prevented. Sites that prefer safety over availability can seton_rate_limit block.Security (F8): the lexical redaction of JSON-like bodies computed the end of every value, including the end of an unclosed container, for every key. A body of
"":{repeated filled the inspected prefix and took time quadratic in its length. The end is now computed only for a value that is replaced or copied, so the scan is linear. The output is unchanged; a property test compares it with the previous implementation.Security (F2): the sensitive field names now include
pass,passphrase,passcode,pin,otp,credential,auth,session,private_key,access_key,secret_key,jwtandcookie. A Drupal login (name=alice&pass=...) no longer sends its password to the decision backend. Short words (pass,pin,otp,auth,session,cookie,jwt) match a whole word of the field name, soauthor,shippingandpassengerare not redacted. The known names now also match with_,-or.removed (api_key,API-KEY). Newredact_field <name> ...;directive in thedecisionblock adds field names, matched exactly and case-insensitively. The expectationsession=1in the lexical test becamesession=[REDACTED], because the list now includessession(the previous expectation encoded the shorter list).Security (F7): the decision event's
meta.hostis the name of the matched site (its first name, or the pattern of a wildcard or regex site), never the client'sHost. The client'sHoststays in theuntrustedheaders only. AHostwhose name has characters other than letters, digits,.,-and_(and is not a bracketed IPv6 literal, with an optional digits-only port) now gets 400 with reasoninvalid_host, before the access list. Underscores are allowed, since real deployments use them. A request with noHostheader is routed as before.Security (F1, F4, F5, F9): the security checks read the request path in six ways (decoded; with
;parameters cut per segment; with\as/; both; and the raw path with%2Fand%5Ckept inside their segments, plain and with the first two).deny_path,fail_closed_pathand a block rule'spathcondition match when any reading matches. A skip rule'spathcondition matches only when every reading does. Limits apply to everylocationany reading selects. A static skip needs every reading to end in a static extension with no earlier script extension, the raw path free of;,\,%2F,%5Cand%3B, and nofail_closed_pathmatch. The request is still forwarded unchanged. New directivestatic_location <prefix> ...;in asiterestricts the static skip to some prefixes.Built-in rule
vh-cmdi-001no longer matches a query parameter whose name starts with a command name followed by-(such as&id-from=3,&cat-name=xor&nc-mode=1). The command must now be followed by whitespace, the end of the value, or a shell separator. The positive examples still match.