100% free and open source · every tool, every host, no licence key · read the licence
Secronyx

Security

Network and path policy

How the outbound probe policy vets connectivity targets and how the path policy decides which caller-supplied files may be read, including symlink handling and the deny list.

Two tool families in Secronyx are gated by a dedicated policy that runs before any network or filesystem access: get_network_latency opens connections to caller-supplied targets, and get_app_config together with the lock-file readers open caller-supplied files. This page documents both policies as implemented in internal/netconfig/probepolicy.go and internal/pathpolicy/pathpolicy.go.

Outbound probe policy

get_network_latency (scope network) is the only tool that connects to a target chosen by the caller. The targets argument is a list of strings, each in one of three forms:

Form Probe What is sent
host or ip icmp one ICMP echo per attempt via the ping binary (ping -c 1 -W 2 <ip> on Linux)
host:port tcp one TCP connect to ip:port per attempt
http://host[:port]/... or https://... http one TCP connect to ip:port per attempt (80 or 443 when the URL has no port)

Each target is attempted three times (packets_sent is 3 in every probe) and the result reports the minimum, maximum and average latency of the attempts that succeeded, plus the packet loss percentage.

The HTTP form is connect-only. splitProbeURL keeps the host and port and discards the path, query and any credentials in the URL; no request is written, so no redirect can be followed and no response body is read. The MaxRedirects field on the policy is reserved and unused for that reason.

Resolve first, then check, then connect

ProbePolicy.ResolveTarget is applied to every target before any packet:

  1. Trim the target, strip IPv6 brackets and any %zone suffix; an empty result is refused.
  2. Refuse the cloud metadata hostnames metadata.google.internal, metadata.azure.internal, metadata, instance-data and instance-data.ec2.internal, or any name ending in . plus one of those. This happens before resolution so a resolver cannot map them elsewhere.
  3. Refuse anything in --probe-deny-hosts by hostname.
  4. If the target is an IP literal, use it. Otherwise require it to pass isSafePingTarget (hostname characters only, at most 253 characters, no leading -) and resolve it with the policy timeout (5 seconds unless a policy sets another).
  5. Check every resolved address. One bad answer refuses the whole target.
  6. Return the first address. The probe connects to that IP, or hands that IP literal to ping, never the name. A DNS answer that changes between check and use therefore cannot redirect the probe.

Address rules

checkIP applies these rules to each address, in order:

Address class Default Override
Cloud metadata: 169.254.169.254, 169.254.170.2, fd00:ec2::254, 100.100.100.200, 192.0.0.192 refused none; refused even in allowlist mode
Listed in --probe-deny-hosts (IP or CIDR) refused none
Unspecified (0.0.0.0, ::) refused none
Multicast or IPv4 broadcast refused none
Loopback (127.0.0.0/8, ::1) refused --probe-allow-loopback
Link-local (169.254.0.0/16, fe80::/10) refused none exposed on the command line
Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7, 100.64.0.0/10) allowed none exposed on the command line
Everything else allowed --probe-allow-hosts switches to allowlist mode

Private ranges are allowed by default because operators probe their own services; there is no flag to refuse them from the command line, though --probe-deny-hosts can carve out specific ranges. Link-local is refused and cannot be enabled from the command line.

--probe-allow-hosts a,b,10.0.0.0/8 switches to allowlist mode: a target must match one entry by hostname, IP or CIDR as well as pass the address rules, and the metadata addresses stay refused. --probe-deny-hosts adds refusals in either mode. Entries are compared case-insensitively; a CIDR matches resolved addresses, an IP matches exactly, anything else is treated as a hostname.

What a refusal looks like

A refused target is never connected to and is never handed to ping, though a hostname is resolved before the address rules run. The tool call itself succeeds; the refusal is reported per probe in its error field, wrapped from ErrProbeRefused as target refused by probe policy: <target> (<reason>). The following is real output from a call with five deliberately bad targets:

169.254.169.254                | icmp | target refused by probe policy: 169.254.169.254 (cloud metadata address)
127.0.0.1                      | icmp | target refused by probe policy: 127.0.0.1 (loopback address; enable with --probe-allow-loopback)
metadata.google.internal       | icmp | target refused by probe policy: metadata.google.internal (cloud metadata hostname)
http://user:pw@[::1]:8080/path | http | target refused by probe policy: ::1 (loopback address; enable with --probe-allow-loopback)
-t                             | icmp | target refused by probe policy: -t (invalid hostname)

The other reason strings are unresolvable address, denied host <entry>, unspecified address, multicast or broadcast address, link-local address, private address, empty target, no addresses and not in --probe-allow-hosts.

# Default policy: private and public allowed, loopback and metadata refused
secronyx

# Let the host probe its own listeners
secronyx --probe-allow-loopback

# Allowlist: only the monitoring subnet and two named hosts
secronyx --probe-allow-hosts 10.20.0.0/16,db.internal,cache.internal

# Deny a range inside the default allowance
secronyx --probe-deny-hosts 10.99.0.0/16

The ICMP form depends on a ping binary being on PATH and permitted to send echo requests; where it is not, the probe reports the lookup or ping exit error in its error field and the TCP and HTTP forms still work.

Path policy

get_app_config (scope software) and the fifteen lock-file tools (get_npm_lock, get_pip_lock, get_cargo_lock, get_go_sum, get_gemfile_lock, get_yarn_lock, get_pnpm_lock, get_poetry_lock, get_composer_lock, get_mix_lock, get_pubspec_lock, get_swift_resolved, get_podfile_lock, get_gradle_lock, get_conda_lock; get_pip_lock called with no path looks for Pipfile.lock and then requirements.txt) are the tools this policy governs. Each goes through the global policy — Check, or CheckWithExtraRoots for a lock reader falling back to the working directory — before any filesystem access, so a refused path is never stat'ed, let alone opened. Redaction of the contents is a second line of defence, not the access control.

One other tool takes a path from the caller: get_app_logs (scope logs) accepts a log file or directory. It is not covered by this policy and not affected by --allowed-paths; the log collector applies its own fixed directory allowlist (/var/log, /var/logs, /tmp, /home, /Users, /Library/Logs, C:\Windows\Logs, C:\ProgramData, C:\Users, C:\inetpub\logs) and rejects traversal separately.

Collectors that read fixed paths (/etc/environment for get_env_vars, /etc/fstab, /proc/<pid>/environ and so on) are not subject to this policy; those paths are hard-coded and cannot be steered by the caller. Registry tools take registry paths, which are a different namespace.

Roots

Reads are permitted only beneath an allowed root. Default() installs:

Platform Roots
Linux and macOS /etc, /opt, /usr/local/etc, /srv, /var/lib, /var/www, /app
Windows C:\ProgramData, C:\inetpub, C:\Program Files, C:\Program Files (x86)

--allowed-paths /etc/myapp,/srv/config replaces the root list. It does not touch the deny list or the size cap, which stay in force under whatever roots you supply. Root comparison is by whole path component, so a root of /etc does not admit /etcetera, and it is case-insensitive on Windows.

A lock-file reader called with no path keeps its historical "look in the current directory" behaviour: the default file name is joined to the working directory, and the working directory is added as an extra root for that one call only. The deny list and size cap still apply, and the global policy is not widened.

Deny globs

Inside an allowed root, a path matching any deny glob is refused. The built-in list:

**/.ssh/**  **/.aws/**  **/.azure/**  **/.gcloud/**  **/.config/gcloud/**  **/.gnupg/**  **/.kube/**
**/.docker/config.json  **/.netrc  **/_netrc  **/.git-credentials  **/.pgpass  **/.my.cnf  **/.npmrc  **/.pypirc
/etc/shadow*  /etc/gshadow*  /etc/security/opasswd  /etc/sudoers*
/etc/ssl/private/**  /etc/pki/**/private/**  /etc/letsencrypt/**/privkey*  /etc/krb5.keytab
**/*.pem  **/*.key  **/*.p12  **/*.pfx  **/*.jks  **/*.keystore  **/*.kdb  **/*.gpg  **/*.asc
**/id_rsa*  **/id_dsa*  **/id_ecdsa*  **/id_ed25519*  **/*.keytab
**/*.tfstate  **/*.tfstate.backup  **/terraform.tfvars  **/secrets.y*ml  **/master.key
**/credentials.json  **/service-account*.json

Glob semantics: ** matches any number of path components, * and ? match within one component, patterns are matched against the slash-separated absolute path, a pattern that does not begin with /, ** or a Windows drive letter may match at any depth, and matching is case-insensitive on Windows. The list is not configurable from the command line; DefaultDenyGlobs() returns a copy for code that builds its own Policy.

The deny list is a list of well-known names, not a classifier. A private key stored under an unusual name inside an allowed root is not caught by it; keep roots narrow rather than relying on the globs.

CheckWithExtraRoots performs the checks in this order and stops at the first failure:

  1. Empty or whitespace path: path is empty.
  2. Clean and make absolute. Check the requested path against the roots (path is outside the allowed directories) and the deny globs (path matches a denied pattern (<glob>)).
  3. Resolve symlinks with filepath.EvalSymlinks. If the resolved path differs, check it against the roots and deny globs again; a failure is reported with the prefix symlink target:. A symlink planted under /etc that points at /root/.ssh/id_rsa is therefore refused, as is one pointing at /etc/shadow.
  4. stat the resolved path; anything that is not a regular file (a directory, a device, a FIFO, a socket) is refused with path is not a regular file.
  5. Refuse files larger than MaxBytes, default DefaultMaxBytes (1 MiB): file exceeds the maximum readable size (<n> bytes, limit <cap>).

Only after all five does the tool open the file. Because the deny check runs before stat, a denied name is refused whether or not the file exists. The following is real output from get_app_config under the default policy, with the tool's readable flag, its error field and redaction_summary.total_redactions:

/etc/shadow               readable=false  path refused by policy: path matches a denied pattern (/etc/shadow*)
/root/.ssh/id_rsa         readable=false  path refused by policy: path is outside the allowed directories
/etc/ssl/private/x.pem    readable=false  path refused by policy: path matches a denied pattern (/etc/ssl/private/**)
/etc                      readable=false  path refused by policy: path is not a regular file
/etc/hostname             readable=true   format=unknown  total_redactions=0
<root>/etc/link.conf -> /etc/passwd, with --allowed-paths <root>
                          readable=false  path refused by policy: symlink target: path is outside the allowed directories

Path traversal through .. is neutralised by the clean-and-absolute step before the root check, so /etc/../root/.ssh/id_rsa is evaluated as /root/.ssh/id_rsa.

For get_app_config a refusal is returned as a normal result with readable: false, not as a JSON-RPC error, so the audit event records a success result for the call; the refusal is visible in the tool output. The lock-file readers behave the same way: the call succeeds and the policy error appears in the result's error field, there without the path refused by policy: prefix, with an empty dependency list.

# Narrow the roots to one application's configuration
secronyx --allowed-paths /etc/myapp

# Several roots; the deny list and 1 MiB cap still apply under each
secronyx --allowed-paths /etc/myapp,/srv/myapp/config,/opt/myapp/etc

Interaction with other controls

  • Both policies are process-global and are installed in main before the tool registry is built, so they also apply to --query mode on the Command line.
  • Neither policy is a substitute for scope. A caller without the network scope cannot reach get_network_latency at all, and one without software cannot reach the file readers; see Scopes and authorization.
  • File contents that pass the path policy are still redacted; see Redaction.
  • Probe and file activity appears in the audit trail as tools/call events with the tool's parameters, including the targets and paths requested; see Audit logging.
  • In containers, the roots refer to the container's filesystem. If host paths are mounted for diagnostics, mount them under an allowed root read-only rather than widening --allowed-paths to the mount's parent; see Docker and Compose.

Built 2026-09-19. Source: levantar-ai/secronyx. Found a mistake? Tell us.