The agent
It has to be inside to be useful, so it comes on a short leash
A webhook can only reason about the text of an alert that already fired. An agent can query the reject table and read the mapping — which is why it ships with hard limits rather than promises.
Why it has to be inside
The evidence that settles an ETL investigation is not in your alert payload. It is in tables and log files that never leave your network.
Consider what the worked investigation actually needed:
a count from a reject table grouped by reason, the modify_date of a target
object, a file manifest against a trailing median, and the contents of a mapping file as
deployed. None of that is in a webhook. An alert says row count below threshold; it
cannot tell you where the rows went, because the thing that knows is a table on a server in
your estate.
So the choice is between a tool that reasons about alert text — which is to say, produces the plausible prose this product exists to replace — and one that can read. There is no third option, and pretending otherwise by calling it "agentless" would just move the same access somewhere less inspectable.
Which makes the interesting question not whether something runs inside, but what it is structurally incapable of doing. The rest of this page is that answer.
Network posture
The agent initiates every connection. There is no inbound port, no listening socket, and no mechanism by which we contact your network — including for support. If your egress policy allows one hostname on 443 through a proxy, that is the entire network requirement.
| Surface | Position | Detail |
|---|---|---|
| Inbound ports | None | No listening socket. Nothing dials into your network, including us. |
| Outbound | 443/TCP to one hostname | Proxy-aware; pins our certificate. Allow-list a single destination. |
| Initiation | Always the agent | It polls for work. There is no channel for us to push a command. |
| Database access | Read-only | Explicit DENY on write verbs, in addition to withheld grants. |
| Filesystem | Read-only mounts | Log directories and config paths you name. Nothing else is visible. |
| Data at rest | Nothing persisted locally | Evidence is redacted, transmitted, and dropped. No local evidence store. |
The query catalogue
A file on your host listing every statement the agent may execute. Not a filter over arbitrary SQL — the arbitrary path does not exist.
This is the control that matters most, so it is worth being precise about how it works. The agent does not receive SQL and check it against rules. It receives an identifier, looks that identifier up in a local file, and executes the pinned statement with bound parameters. A statement absent from the file is not rejected; it is unreachable.
# Every query the agent may run is declared here, reviewed by you, and
# pinned by hash. A statement not in this file cannot execute — the agent has
# no code path that sends arbitrary SQL.
- id: reject-profile-by-reason
purpose: Group reject rows by reason and hour to spot a new failure mode.
engine: mssql
parameters: [schema, table, since_utc, until_utc]
max_rows: 5000
timeout_seconds: 30
statement: |
SELECT Reason, COUNT(*) AS Rows,
MIN(RejectedAtUtc) AS FirstSeen,
MAX(RejectedAtUtc) AS LastSeen
FROM {{schema}}.{{table}}
WHERE RejectedAtUtc >= @since_utc AND RejectedAtUtc < @until_utc
GROUP BY Reason
ORDER BY Rows DESC;
- id: object-modify-date
purpose: Establish when a table was last altered, to test schema-change causes.
engine: mssql
parameters: [object_name]
max_rows: 1
timeout_seconds: 10
statement: |
SELECT name, modify_date
FROM sys.objects
WHERE name = @object_name AND type = 'U';
Every entry carries a purpose in plain language, because a reviewer approving
this file should not have to infer intent from SQL. Parameters are bound, never interpolated
— the identifiers in braces are validated against the schema before substitution rather than
pasted in. max_rows and timeout_seconds are mandatory, which is as
much about protecting your production instance from a pathological query as about limiting
what leaves.
Adding a query is a pull request against a file on your infrastructure. That is deliberately the same friction as any other production change, and it means the answer to "what could this thing read?" is a file your team owns rather than an assurance we give.
Exactly which grants it needs
Written out in full, because 'read-only access' is not a specification.
Most of what an investigation needs is metadata — what objects exist, when they changed, what a procedure references, which tables took writes. That requires catalog permissions, not data permissions. Row access is needed in a small number of named places: the reject table, the intake counts, and column-scoped counting on fact tables.
-- The whole grant set for a SQL Server instance. No db_datareader on user
-- data: the agent reads catalog metadata plus the specific tables you list.
CREATE LOGIN decim_agent WITH PASSWORD = '...';
CREATE USER decim_agent FOR LOGIN decim_agent;
-- Catalog metadata: topology discovery and schema-change tests.
GRANT VIEW DEFINITION TO decim_agent; -- sys.sql_modules, sys.objects
GRANT VIEW DATABASE STATE TO decim_agent; -- sys.dm_db_index_usage_stats
-- Scheduler metadata, on msdb only.
USE msdb;
GRANT SELECT ON dbo.sysjobs TO decim_agent;
GRANT SELECT ON dbo.sysjobsteps TO decim_agent;
GRANT SELECT ON dbo.sysjobhistory TO decim_agent;
-- Reconciliation tables, named explicitly. Nothing wildcarded.
USE Sales;
GRANT SELECT ON dbo.IntakeBatch TO decim_agent;
GRANT SELECT ON dbo.RejectedTransactions TO decim_agent;
-- Counts only from the fact table: a column-scoped grant, not the rows.
GRANT SELECT ON dbo.[Transaction](BatchId, LoadedAtUtc) TO decim_agent;
DENY ALTER, DELETE, INSERT, UPDATE, EXECUTE TO decim_agent;
Note the last two statements. GRANT SELECT ON dbo.[Transaction](BatchId, LoadedAtUtc)
is a column-scoped grant: enough to count rows per batch and establish when they landed, and
structurally incapable of reading a transaction amount or a customer identifier. The explicit
DENY is redundant against grants never given, and it is there anyway — a
permission the agent must never hold is worth stating twice, because the second statement
survives someone later adding a role membership by mistake.
| Category | Scope | Note |
|---|---|---|
| Log lines | Matched against the incident window | Whole files are never shipped — only lines the window and pattern select |
| Query results | From the catalogue only, row-capped | Aggregates and counts wherever they answer the question |
| Catalog metadata | Object, column and module definitions | Structure, not contents |
| Scheduler entries | Jobs, steps, history | From msdb, cron, systemd, Quartz, Hangfire |
| Configuration | Named files, redacted | The highest-value source and the likeliest to hold secrets |
| Commit metadata | Hashes, authors, changed paths | File contents fetched only for a path already implicated |
Redaction, and where it happens
At the agent, before transmission. Redacting on ingest would mean the data had already left, which is the event you were trying to prevent.
This distinction is the whole control. A vendor that scrubs data when it arrives has, by definition, received it unscrubbed — the secret crossed your boundary and existed in their systems, however briefly, and no retention policy undoes that. Redaction here runs in your network, in the agent process, before anything is queued to send.
# Redaction runs at the agent, before anything is queued for transmission.
# Rules are yours to extend; the built-ins cannot be switched off.
builtin:
- connection_strings # Password=, Pwd=, User ID= in any config value
- bearer_tokens # Authorization: Bearer, api_key=, x-api-key
- private_keys # PEM blocks, PKCS#8, PuTTY .ppk
- pan # 13–19 digit runs passing a Luhn check
custom:
- id: customer-email
applies_to: [log_lines, query_results]
match: '[\w.+-]+@[\w-]+\.[\w.]+'
replace: '<email:{{sha256:8}}>' # stable hash — still correlatable
- id: national-insurance
applies_to: [query_results]
match: '\b[A-CEGHJ-PR-TW-Z]{2}\d{6}[A-D]\b'
replace: '<redacted:ni>'
on_rule_error: drop_field # never transmit a field a rule failed to process Built-in rules cover the categories that cause the most damage and are the least likely to be anticipated: connection strings in configuration, bearer tokens in log lines, private key blocks, and card numbers validated by Luhn rather than matched by length alone. They cannot be disabled.
Custom rules are yours. The replace template is worth noticing — substituting a
stable truncated hash rather than a fixed placeholder keeps a value correlatable
without being readable. If the same customer appears in a log line and a query result, an
investigation can still establish they are the same subject, which is often the analytical
point, while nothing recoverable leaves your network.
on_rule_error: drop_field is the default and the right one. A rule that throws is
a rule whose output nobody can reason about, and the cost of losing a field is always lower
than the cost of shipping the thing it existed to remove.
Deploying it
Windows Service, systemd unit, Docker or Kubernetes. It runs where your pipelines already do.
One agent per network segment that needs reaching, identified by a site id. Enrolment is a one-time token; after that the agent holds its own credential and rotates it.
services:
decim-agent:
image: ghcr.io/ryware/decim-agent:1.x
restart: unless-stopped
environment:
DECIM_ENROLMENT_TOKEN: ${DECIM_ENROLMENT_TOKEN}
DECIM_SITE_ID: sql-prod-weu
DECIM_EGRESS_PROXY: http://proxy.internal:3128
volumes:
- ./catalogue.yaml:/etc/decim/catalogue.yaml:ro
- ./redaction.yaml:/etc/decim/redaction.yaml:ro
- /var/log/etl:/var/log/etl:ro # read-only log mount
cap_drop: [ALL]
read_only: true
user: "10001:10001" The container drops all capabilities, runs as a non-root user with a read-only root filesystem, and mounts log directories read-only. Nothing here is unusual — it is the configuration you would require of any third-party agent, written down so your review does not have to ask.
What gets recorded
Every collection, on both sides.
The agent writes a local audit line for each query it executes — catalogue id, parameters, row count, duration, and the investigation that requested it — to your own logging, where your SIEM can see it. The same record exists server-side and is visible in the investigation timeline.
Two copies is the point. A record that only we hold is a record you have to take on trust; one written into your logging is one you can reconcile against independently. If those two ever disagree, that is a finding, and you should be able to detect it without asking us.
Common questions
Can the agent run arbitrary SQL if you push it a new instruction?
Does it need db_datareader?
VIEW DEFINITION and VIEW DATABASE STATE for metadata, read on the scheduler tables in msdb, and SELECT on the specific reconciliation tables you name. Fact tables are usually granted at column scope for counting, not row scope. A blanket db_datareader grant is the thing this design exists to avoid.What happens if a redaction rule has a bug?
on_rule_error: drop_field is the default and we would push back on changing it — a rule that throws is a rule whose output you cannot reason about, and losing one field costs far less than shipping the thing it was meant to remove.Can we run it in a network with no internet access at all?
How much does it cost to run?
Who can see the evidence once it leaves?
Related: security for the threat model and data handling; evidence for how collected artefacts are cited; and pipeline topology for what the read-only metadata is used to build.
Get started
Security review is the first conversation, not the last
We would rather hand your team the agent's permission model up front than discover an objection three months in.