Please report security vulnerabilities privately through GitHub's security advisory feature for this repository. Do not open a public issue containing exploit details, credentials, notification URLs, or target information.
EdgeWatch executes Nmap with validated argument arrays and does not expose arbitrary Nmap flags. Treat its configuration, SQLite volume, notification encryption key, and any remaining notification URL file as sensitive. Only configure targets you own or are explicitly authorized to scan.
TCP jobs may use the optional Naabu discovery-to-Nmap pipeline. Both scanners
are fixed, image-bundled executables (/usr/local/bin/naabu and
/usr/bin/nmap); administrators can edit only validated argument arrays and
approved placeholders. EdgeWatch invokes them directly without a shell, so
shell syntax, alternate binaries, arbitrary output paths, and unapproved NSE
scripts are rejected. Naabu discovery is JSONL and Nmap confirmation remains
authoritative for baselines and incidents. EdgeWatch parses Naabu output as a
stream, rejects records for addresses outside the invocation, keeps each
distinct result once, and stops the child on an oversized line or an
implausible number of repeated records. Connect discovery is the least
privileged default; SYN discovery additionally requires the explicitly opted-in
NET_ADMIN and NET_RAW container capabilities.
The final image intentionally retains UID 0 because the supported Docker
capability model does not reliably expose raw packet privileges to an
unprivileged process. Nmap UDP/SYN and Naabu SYN fail closed without those
privileges. The compatibility matrix, bind-mount ownership guidance, and
reconsideration criteria are maintained in
docs/container-hardening.md.
The administration console is bound to a loopback address by default and uses
server-side sessions, CSRF protection, and Argon2id password storage. Keep the
Docker host and any SSH tunnel access restricted to trusted administrators.
When an untrusted tunnel or reverse proxy makes every remote client appear as
the same loopback peer, all login attempts are throttled after five failed
password or TOTP attempts in five minutes with a short two-second retry delay.
A successful sign-in through the peer, with any account, does not reset those
failures; each one expires five minutes after it happened, so signing in
between failed attempts gains no further attempts.
The shared response avoids both a long lockout and revealing account existence,
but cannot provide per-client attribution. The first-run setup, the platform
setup, and account activation through such a shared loopback peer get the
same two-second cooldown after five wrong tokens, instead of the five-minute
block that a hundred failures from one address otherwise cause. Password and
TOTP confirmations of signed-in accounts through such a peer count each
failure against the confirming account only: an account that fails five
confirmations within five minutes is refused for five minutes, while every
other account, in any unit or on the platform, keeps confirming, and the peer
is never blocked after a hundred failed confirmations as an address that is
not loopback is. For per-client rate limits and audit identities, configure
only the actual proxy addresses in web.trusted_proxies and the sanitized
web.forwarded_header.
A client identified by its own address has a budget of five failed sign-ins
in five minutes. Every failed sign-in costs it the same, whether the username
is unknown, the account is disabled or its unit is not active, or the
password, one-time code, or recovery code is wrong. Once it is used, every
sign-in from that client is refused with the same 429 rate_limited answer
for five minutes, whether or not the username exists, so neither the answer
nor the number of attempts left reveals which accounts exist. A refused
sign-in answers without waiting for its auth.rate_limited record, which is
written in the background, so the refusal takes as long for every username.
A successful sign-in does not reset the budget, so a client that holds one
valid account cannot sign in between failed attempts to gain more. Other
clients are not affected; clients that share one address, such as the clients
of an untrusted proxy on another host, share the budget, and they share the
backstop of the setups and activation, which blocks the address for five
minutes after a hundred wrong tokens. EdgeWatch keeps these limits for an
address that is not loopback. When requests come through a proxy that
EdgeWatch does not trust, it logs a warning that recommends
web.trusted_proxies, at most once an hour, and the console shows the
proxy's address to the administrators of a deployment with one unit and on
the platform status page, until a day after its last request. Such a proxy
is a directly connected peer that is not listed in web.trusted_proxies and
sends X-Forwarded-For or Forwarded, such as an unlisted proxy on the
host, which connects from a loopback address; or, behind listed proxies, the
first address from the right of the configured forwarding header that is not
listed, when the header names another client before it, such as an unlisted
proxy on another host in front of the listed proxy on the host. A client can
send these headers itself and have its own address shown, so list an address
only when it is a proxy that you run. The notice never changes the address
that EdgeWatch uses for a client. EdgeWatch also logs a startup warning when
proxy hostnames are approved without trusted client-IP forwarding. Failed
login and TOTP attempts, along with rate-limit events, are written to the
security audit log; they do not currently send notification-channel alerts.
Setting up or replacing an authenticator requires the account password, plus the current authenticator or a recovery code when TOTP is already enabled. The new secret then stays pending for ten minutes and accepts at most five incorrect verification codes. A mistyped code can be retried against the same secret; after the fifth incorrect code, or once the ten minutes pass, the pending secret is discarded and setup must start again. The code that confirms the new secret counts as used for its time step, as a code accepted at sign-in or for a TOTP confirmation does, so neither accepts it again; the first sign-in after enrolment takes the authenticator's next code. A sign-in records its TOTP time step, or marks its recovery code used, in the transaction that creates its session. A sign-in that creates no session, because the security audit or the session cannot be written, the password-check queue is full, or the account's credentials change at the same moment, leaves the code unused, and of two sign-ins with the same code only one gets a session.
Activation and password-reset links are single-use, expire after 30 minutes,
and are stored only as the SHA-256 digest of their token. An account has one
usable link at a time: issuing a new link stops the older ones. A link also
stops working, whoever issued it, when the account's password changes in any
other way (its own change, the host's admin reset-password, or the
redemption of another link), when its role changes, and when the account is
disabled; the links an administrator issued stop when that administrator is
demoted or disabled. A role change stops a pending account's activation link
too, so a link issued for one role, such as a platform administrator's reset
link for a unit administrator, never sets the password of the account in
another role; the account then needs a new link. Each stopped link that could
still have been used is recorded, in the same transaction as the change and
with its actor, as user.activation_revoked, or
platform_admin.activation_revoked for a platform administrator, naming the
account. A browser that holds a session never redeems a link with it: the
console asks the visitor to sign out first and keeps the link until then.
Every installation has at least one business unit, the default unit, which
holds everything that existed before business units. A single unit behaves
as before, apart from the unit audit, the /public/default alias of the
public page, the --tenant option of the host commands, and the platform
setup token that the host can issue.
Business units separate teams that trust the deployment's operators. The
application enforces the separation in the console, the API, the live-update
stream, and the public pages. Units share one process, one SQLite database,
and the notification and authentication keys (notification.key and
auth.key, or the configured key files). Anyone with access to the Docker
host, the container, the host commands, the database file, or a backup can
read and change every unit's data: host commands reach any unit with
--tenant, and a backup or restore covers every unit together. Run separate
deployments for parties that must not trust each other or the operators.
Every API request passes its route's permission check before the handler
runs, and the handler reads and writes through a store bound to the
session's unit; the platform console's handlers use the platform's store and
never a unit's. An ID of another unit's job, scan, host, account, scanner
profile, or notification destination gets a response byte-identical to an
unknown ID's. That is a 404, except where both get another answer:
cancelling a scan answers any scan that is not running in the unit with 409
scan_not_active, and validating or previewing a scanner profile checks only
the submitted definition.
A platform administrator is a separate account without a unit. It manages
the units and their administrators and holds no permission on any unit's
jobs, scans, baselines, incidents, notifications, or public status: every
route of a unit's console refuses it except its own account's password,
TOTP, session, and sign-out routes. Only a platform administrator reaches
the platform console's API, under /api/v1/platform/, and it never returns
a unit's data: a unit appears with its name, slug, state, and counts of its
accounts, administrators, jobs, stored scans, and scan slots in use; its
accounts as summaries without credentials; and its
capacity as numbers. The stored scan count is a number of rows, never a
scan's content. The platform's own notification destinations are
write-only like a unit's, and the platform cannot read, select, or change a
unit's destinations. Usernames stay unique across every unit and the
platform.
Only the host creates the first platform administrator: edgewatch admin platform-setup-token prints a one-time token, valid for 15 minutes, once the
first administrator exists and while no enabled platform administrator does,
at most once a minute, and replaces an unused token only with --force. The
token cannot complete the first setup, and the first setup token cannot
create a platform administrator. The console's setup page redeems it through
POST /api/v1/setup/platform, which checks the browser origin and applies
the first setup's per-client failure budget; a wrong, used, or expired token
gets one generic answer, and each failure is recorded in platform scope.
While the token is valid, /api/v1/setup/status reports
platform_setup_available, which the sign-in page uses to offer the setup.
Once the first administrator exists, the setup page never shows the first-run
form.
An enabled platform
administrator can then invite another after confirming its password; the
invited account stays pending and disabled until it redeems its one-time
link, which expires after 30 minutes. Until then, a platform administrator
can revoke the invitation, which stops the link; a pending account is neither
enabled nor disabled. A platform administrator can also renew a pending
account's invitation, including one that expired or was revoked: the new
one-time link is returned once, and every earlier link stops working. It can
remove a pending account, with its links, so the username can be invited
again; an account that redeemed its link is never removed, only disabled.
A platform administrator can disable or enable another, never its own
account, and disabling one ends its sessions and revokes the links it issued
or received.
A platform administrator invites only unit administrators, and resets only
unit administrators' passwords; a unit's administrators invite and reset the
accounts of their own unit, including its operators and viewers, and cannot
reach another unit's accounts or a platform administrator. The rule follows
the target account's role and unit, not the request. No platform
administrator can reset another through the product; the host commands
admin reset-password and admin disable-totp remain the break-glass path
for every account. Each unit keeps at least one enabled administrator, and
the platform at least one enabled platform administrator, whose role never
changes. The platform console's changes to units, their capacity and
accounts, the platform administrators, and the platform's notifications each
check in their own transaction that the acting account is still an enabled
platform administrator. Creating and renaming a unit and changing its
capacity need only a platform administrator's session.
Disabling, enabling, and deleting a unit, inviting a unit or platform
administrator, renewing or revoking a platform administrator's invitation,
removing a pending platform administrator, issuing a password reset, ending
an account's sessions, enabling or disabling a platform administrator, and
every change to the platform's notification destinations and routing also
require its password, and deleting also the unit's typed name.
The platform administrator is trusted with the units' accounts, not their
data, and the product makes its reach visible rather than impossible.
Inviting a unit administrator and issuing a password reset return the
one-time link to the platform administrator, who could redeem it and sign in
to the unit. Both are recorded in the unit's audit as platform actions, and
an invited account appears among the unit's users. A password reset keeps
the account's TOTP secret and recovery codes, so it lets nobody sign in to an
administrator who has enrolled TOTP without that authenticator; the reset
response reports totp_enrolled. It does not protect an administrator who
has not enrolled yet, because the new password is enough to enrol a new
authenticator. Keep unit administrators on TOTP and have them review the
platform actions in their audit. The platform administrator also sees each
unit's account list, with usernames, display names, roles, TOTP state, and
last sign-in, and every unit's account records in the platform audit,
including the source addresses of sign-in attempts.
Once more than one unit exists, every unit administrator and platform
administrator must use TOTP. Until one without TOTP enrols, its sessions
report totp_enrollment_required and may only use its own account's
settings (password, TOTP enrolment, display name, and sessions) and sign
out; everything else is refused. Units that are disabled or being deleted
count. If the units cannot be counted, the restriction applies. With a
single unit nothing changes.
Each unit's public status page is served at /public/<slug> and
/api/public/v1/dashboard/<slug>, from that unit's published hosts only;
/public keeps serving the default unit's page. An unknown slug, a unit's
page that is not enabled, a paused unit, and a unit being deleted get the
same 404 public_disabled answer as a disabled page, so the address does not
reveal whether a unit has that slug. Each page
has its own per-client rate limit and its own cache: a busy page does not
throttle another, and saving one page does not drop another page's cache.
A platform administrator's action on a unit's account, and a change of the
unit's capacity, is recorded in that unit's security audit with the
platform actor kind, so the unit's administrators see it. The unit's
lifecycle changes, the platform administrator's other actions, sign-in
attempts on its account, and sign-in attempts with a username that no
account has are recorded in platform scope, outside every unit's audit. The
record that a sign-in, password confirmation, or TOTP confirmation became
rate limited follows the same rule: it belongs to the unit of the account it
names, or to platform scope for a platform administrator or an unknown
username. A failed redemption of an activation or password-reset link, and
the record that its redemptions became rate limited, belong to the unit of
the link's account, or to platform scope for a platform administrator's
invitation, where the link's successful redemption is recorded too; that
holds for an expired or used link and a link of a disabled unit. A token that
matches no link names no account, so its records stay in the default unit,
whose console serves activation. Such rate-limit records are coalesced per
client, operation and scope for five minutes, so a client throttled on
accounts of several units gets a record in each of them. A unit's
administrators read their unit's audit, which hides the source address of a
platform administrator's actions; the platform audit shows the records in
platform scope and every unit's account and platform records, never a unit's
data records. Both views are read-only.
Disabling a unit ends its sessions and revokes its open invitations in the
same transaction; from then on its accounts cannot sign in or redeem a link,
and a sign-in gets the answer of a wrong password, whatever one-time code or
recovery code comes with it. The sign-in is refused before it uses up that
code, so a recovery code presented while the unit is disabled still works
once the unit is enabled again. A unit administrator's
account changes, including new invitations and password-reset links, check
again when they are written that the administrator is still an enabled
administrator of an active unit. A request that is still in progress when
the administrator is demoted or disabled, or the unit is disabled, is
refused with 403 forbidden and writes nothing, so no link outlives the
change. Deleting a unit erases its rows in bounded batches with SQLite's
secure_delete on, then compacts the search indexes and truncates the
write-ahead log, both of which may still hold copies of the erased rows.
Between the two it clears the database's free pages, which may hold rows of
the unit that other writers, such as retention, deleted without
secure_delete while it existed, or search terms of it that a retention
merge freed during the deletion: a database with incremental auto-vacuum,
the mode of every database created by v0.18.31 or later, returns them to the
file system, and in a database without auto-vacuum, which edgewatch verify
reports as auto_vacuum none, the purge allocates every free page to a
scratch table and frees them again with secure_delete on, so SQLite
overwrites each of them with zeros. The pages in use are not rewritten, so
stale bytes in their unused space are not covered. The unit stays in the
deleting state until the compaction and the overwrite have finished and a
checkpoint has truncated the log: each purge pass continues them within a
bounded time, and a reader that holds an older snapshot of the database,
such as a running backup, keeps the log from being truncated until it ends,
which the daemon logs as a warning. Deleting a destination removes its
delivery health; the purge also erases the delivery health that earlier
releases kept for deleted destinations, which names no owner and counts in
no unit's totals. Backups taken before the deletion still hold the unit's
data, and the records of platform administrators' actions on it stay in the
platform audit. Releases before schema 55 marked a unit deleted before the
compaction and the truncation had finished, so after such a deletion the
search index segments, which backups copy, and the log may still hold
copies of the erased rows. When the database holds a deleted unit, the
upgrade to schema 55 records a one-time cleanup that the daemon runs in the
background with the same bounded, resumable passes: it compacts every search
index and then truncates the log, again waiting for a reader that holds an
older snapshot, and while a unit is being deleted it leaves the work to that
unit's deletion. edgewatch health reports the cleanup as maintenance
until it has finished, and edgewatch verify lists its
legacy_tenant_purge_maintenance checkpoint. Backups taken before it has
finished may still hold the erased rows of those units. Releases before
schema 56 did not overwrite free pages, so in a database without
auto-vacuum the free pages, which raw copies of ./data include, may still
hold rows of the units they deleted. When such a database holds a deleted
unit, the upgrade to schema 56 records the same cleanup as pending again
from its overwrite of free pages, which then truncates the log without
compacting the indexes again, and sends a deletion that had reached its
log truncation back to the overwrite.
Some signals cross units by design:
- Usernames are unique across the deployment, so inviting a name that another
unit or the platform uses fails with
username is not available, which tells the inviting administrator that the name exists elsewhere. - The RDAP cache is shared. It holds only public registry data, never which
unit observed an address, but a lookup can come back as
cached, with its original fetch time, because another unit opened the same public address first. - The scan slots and the scanning host are shared: a unit's scans can wait while other units' scans hold the slots, and every unit's probes leave from the same host. A slot cap limits a unit but reserves nothing for it.
- Live-update event IDs, the replay window, and the deployment-wide stream limit are shared; see Live-update streams and session revocation.
- The default unit's status reports the size of the whole database, which grows with every unit's data.
- Anonymous callers of
/api/v1/setup/statuscan tell fromplatform_setup_availablewhether a platform setup token is waiting to be used. - Behind a proxy that is not listed in
web.trusted_proxies, the shared sign-in cooldown applies to the accounts of every unit. Password and TOTP confirmations there are limited per account, so they do not cross units. - A redemption with a token that matches no link, such as a mistyped link of any unit or of a platform administrator's invitation, is recorded in the default unit's audit with its source address, because the token names no account.
Other signals are closed. Once more than one unit exists, a unit's status leaves out the deployment-wide live-update counters, and it leaves them out too when the units cannot be counted. A unit's status counts, notification totals, and telemetry cover its own rows only, its scan slots and probe budgets are its own limits, the Hosts view keeps each unit's newest observation of an address apart, and a public slug does not reveal whether a unit has it. The inactive YAML jobs in config.yaml belong to the default unit, so only its status names them.
The authenticated live-update stream (/api/v1/stream) is authorized to the
specific browser session that opened it. Two browser sessions for the same
account are independent: revoking one session does not grant, revoke, or close
the other. Disabling an account, changing its role, changing its password
(including by redeeming an administrator-issued password-reset link), or
changing its TOTP settings revokes the affected sessions, and a stream stops
delivering once its next authorization check observes that revocation.
The stream rechecks its session before the initial response, before delivering events, and on its 25-second heartbeat. To avoid a database lookup for every event, successful checks are cached for at most two seconds. Consequently, a quiet stream can remain connected until its next heartbeat, while activity causes a revoked stream to close within the two-second authorization-cache bound. Stream connections, reconnects, heartbeats, and subscriber-limit responses use read-only authentication and do not extend the session's idle timeout. Security mutations handled by the running web process cancel matching streams immediately and invalidate their cached authorization; a TOTP change that deliberately preserves the current browser session leaves only that session's stream connected. Revocations performed by another process (such as host recovery tooling) use the bounded revalidation fallback. Server shutdown closes all live streams. These bounds are a security property, not a replacement for revoking a compromised account or session.
Each live update has one audience, and replay after a reconnect is filtered
the same way. A business unit's streams receive only that unit's updates: its
jobs, scans, incidents, baselines, scan cycles, scanner profiles and
notification destinations, and the unit's own copy of an update alert. The
notice that the application update status changed carries no unit's data and
reaches every stream. Platform administrators hold no stream permission and
receive no live updates; the platform's copy of an update alert is addressed
to platform streams only, and a platform stream would never receive a unit's
update. Disabling or deleting a unit through the running daemon ends the
unit's open streams at once; the change also ends the unit's sessions, so a
disable from another process takes effect through the revalidation fallback.
Once more than one unit exists, each unit (and the platform) may hold at most
64 of the 256 streams that the deployment allows, so one unit cannot lock the
others out; a stream over either limit receives the in-band stream_limit
backoff. With a single unit only the deployment-wide limit applies. Event IDs
and the in-memory replay window are shared by every unit: a unit can tell
from gaps in its event IDs that other units received updates, but not what
they were, and a burst in another unit can shorten its replay window, after
which a reconnecting browser receives a full-refresh marker instead. The
deployment-wide replay counters (live_updates in /api/v1/status) are
left out of a unit's status once more than one unit exists, and when the
units cannot be counted.
Other authenticated API reads, including the status and page-polling requests, also validate sessions without refreshing their idle timestamp. Actual browser pointer, keyboard, click, or scroll input and authorized state-changing requests refresh the idle timestamp through a CSRF-protected activity path. Refreshes are coalesced to at most one database write per session every five minutes. The activity write has a short timeout so SQLite writer contention cannot delay normal read-only requests. A session with no real activity expires after 24 hours; polling in an unattended tab does not keep it alive. The absolute session lifetime remains 30 days. The daemon removes sessions past either limit when it starts and once a day. An account keeps at most 20 sessions: a sign-in beyond that ends the account's least recently used session, whose live-update stream stops at its next authorization check.
Web-managed Shoutrrr destinations are write-only through the API. Their URLs
are encrypted at rest with AES-256-GCM; the key is stored in
./data/notification.key unless notifications.encryption_key_file is
configured. The default key always sits next to the database file, also when
database is a file: URI. Protect that key as a credential, keep it mode
0600, and include it in backups of the corresponding SQLite database. An explicitly configured
key is checked at startup and must be present, valid, and owner-readable.
Do not report notification URLs or key material in issues, logs, screenshots,
or audit records.
Notification secrets now live encrypted in the database. The
notifications.urls and notifications.urls_file keys in config.yaml are
deprecated: on its first start, the daemon imports each configured URL once as
an encrypted web-managed destination, in one transaction that also moves the
job routing, update-alert routing, queued alerts, and delivery health to it.
The import uses the same key, and creates the default key exactly as the
first web-managed destination does. After the import, EdgeWatch no longer
reads those URLs for delivery; remove them, and any mounted URL file, from the
deployment, because the file keeps a second plaintext copy of the credentials.
Back up notification.key with the database: without it, the imported
destinations are locked and cannot be recovered from config.yaml. An import
that cannot complete, for example because the key is missing, unreadable, or
cannot decrypt the existing destinations, imports nothing and leaves delivery
on the configured URLs. Logs, the edgewatch health warning, and the
notifications.config_imported audit record contain only counts, destination
IDs, and a bounded reason, never a URL or its digest. While a URL file is
still configured, it is checked at startup, must be a regular file with mode
0400 or 0600, and is capped at 1 MiB. A later release will refuse to start
while either key is set.
Notification delivery health is exposed only as named-destination counts and timestamps. A unit sees the health of its own destinations only, and the platform console that of the platform's own destinations only. Terminal drops store a stable destination/error fingerprint and a bounded error code; raw provider responses, URLs, and credentials are not included in API responses, logs, or system events. Once a destination's URL replacement is saved, an alert raised afterwards is sent only to the new URL, even while a delivery worker is still reading the destinations from before the replacement.
Optional TOTP seeds are encrypted independently with AES-256-GCM. The default
authentication key is ./data/auth.key; set web.auth_key_file for a separate
mode-0600 mount. Back up that key with the database. If it is unavailable,
TOTP verification fails closed while password reset or the host recovery
command can still disable TOTP and invalidate sessions.
If the key is lost or replaced, web-managed destinations become unavailable;
they cannot be recovered from the database alone. Restore the original key and
database together, or delete and recreate the affected destinations after
confirming that the old credentials are revoked. After restoring a key, run
notify test: it fails while any enabled web-managed destination of any unit
or of the platform is still locked, whichever unit --tenant selects, and
reports that count as deployment_locked, never a URL. The console
notification test covers only the unit's own destinations, so a unit's
administrators learn nothing about another unit's or the platform's. A
database upgraded to schema 61 must not be opened by an older EdgeWatch
binary; downgrade by restoring the complete pre-upgrade ./data backup
before starting the old version. The
daemon and the host commands that write to the database, including backup,
refuse a schema newer than the binary supports before they write anything.
Only the daemon migrates. The host commands that act on business units or
accounts (admin, scan, status, history, baseline, and notify test) refuse a schema that the daemon has not upgraded yet, such as a
restored backup of an older release, before they read or write anything, so
account recovery never reports an existing account as missing. The daemon
checks web.auth_key_file, notifications.encryption_key_file, and the
notification URLs in config.yaml before it opens the database, so a start
that these refuse never migrates it; config validate runs the same checks.
Schema 51 keeps every security audit record, attributes it to the default
tenant, and adds a category derived from its action. Update alert routing and
the public status publication move to the default tenant unchanged: alerts go
to the same destinations, and the public page still shows only the explicitly
published hosts. Schema 52 rebuilds the users, jobs, scanner profiles, and
notification destinations tables with the tenant that owns each row, and
removes the legacy administrator row. Sign-in, password and TOTP
confirmation, first setup, and the host recovery commands use only the users
table, so a leftover legacy row can no longer authenticate or recreate an
administrator. Schema 53 attributes each scan, event, and notification
delivery to the tenant of its job, and database triggers refuse a scan or
event in another tenant than its job, a new scan or event for a tenant that
is being deleted, a later change of that tenant, and a published host whose
job belongs to another tenant than the public page. An update alert has one
copy for the platform, which belongs to no tenant, and one for each active
tenant; each copy and its deliveries belong to their owner, reach only the
owner's destinations, and appear only in the owner's history. Schema 54
keeps the newest observation of an address per tenant, and a database
trigger refuses a latest-host row in another tenant than its scan or for a
tenant that is being deleted. Schema 55 changes no table: when a tenant has
been deleted, it records the one-time cleanup after deleted tenants
described under Public pages, audit, and deletion,
and sends a deletion that was already compacting the search indexes back to
the start of its compaction. Schema 56 changes no table either: in a
database without auto-vacuum that holds a deleted tenant, it records that
cleanup as pending again at its overwrite of free pages, and it sends a
deletion that had reached its log truncation back to that overwrite. Schema
57 records whether a unit has a high-cost grant. A unit without one, as
every new unit starts, keeps its probe budgets for a job approved for
high-cost work, whatever config.yaml sets; earlier releases stored the
budgets in force at the unit's creation as its ceiling, which let such an
approval raise the budgets once config.yaml lowered them. The upgrade removes that ceiling from every unit
other than the default one whose capacity no platform administrator has
saved, and a database check keeps a unit without a grant from holding a
ceiling. A ceiling that a platform administrator saved stays a grant, so
review the ceiling of those units after the upgrade. Back up the complete
./data directory before the upgrade.
Recovery codes are stored in the salted v2 representation. Schema 38 removes
legacy unsalted SHA-256 recovery-code digests and records only their count in
the security audit; generate new recovery codes from the Security page after
an upgrade. The old plaintext cannot be recovered or safely re-hashed.
Single-file restores create a new notification epoch. Pending deliveries are
quarantined by default so alerts from the backup cannot be replayed; operators
may explicitly choose --pending-deliveries discard or
--pending-deliveries preserve when running the host restore command. The
choice and a bounded count are recorded in a redacted audit event. Quarantined
payloads remain in the restored database but are never claimed by the delivery
worker. A restore also clears the daemon and scan leases copied from the
backup, because no process runs on the restored copy. It still refuses to
replace a database whose own daemon heartbeat is recent, unless the operator
passes the emergency --allow-active-daemon override.
A backup can hold sessions, activation and password-reset links, and a setup
or platform setup token that were revoked, redeemed, or replaced after it was
taken. In the same transaction that clears the leases, a restore therefore
deletes the copied sessions and marks every unused link and setup token in
the copy as used, so none of them works again; the dry run does the same to
its private copy. Administrators issue new links after the restore, the host
prints a new platform setup token with admin platform-setup-token, and
while no administrator exists the daemon prints a new setup token at startup.
Host CLI commands that change state (scan, baseline approve and reset,
notify test, backup, restore, and administrator recovery) record a
security audit entry with the actor host-cli. The details are bounded to job
IDs, file base names, and outcomes, and for admin reset-password and
admin disable-totp the username and ID of the account changed with its unit,
or the platform; they never include scan targets, notification URLs, full
paths, passwords, or TOTP secrets. A CLI scan uses the same
scan.run_requested action as a run started from the console. Read-only
commands, including restore --dry-run, write no audit entries.
By default, EdgeWatch checks the latest stable release on GitHub at startup and
every three hours. This outbound request reveals the Docker host's public IP
and the EdgeWatch user agent to GitHub; set updates.enabled: false for
isolated or privacy-sensitive deployments.
Retention pruning deliberately keeps the security audit log indefinitely. Only completed scans, historical events, sent or terminally failed outbox deliveries, and superseded job revisions are eligible for automatic removal; active baselines, current revisions, and pending deliveries are protected.