One OCSF Network Activity (4001) record per decision, one JSON object per line in
spoolPath, with the security_control profile declared.
| Outcome | activity_id |
action_id |
disposition_id |
severity_id |
|---|---|---|---|---|
| allowed | 1 Open | 1 Allowed | 1 Allowed | 1 Informational |
denied, mode: enforce |
5 Refuse | 2 Denied | 2 Blocked | 3 Medium |
denied, mode: audit |
1 Open | 1 Allowed | 17 Logged | 3 Medium |
The audit row is worth saying out loud: in audit mode the connection was made, so the record
says Open and Allowed. disposition_id: 17 (Logged) and unmapped.dsh.enforced: false are what
tell a SOC that the policy would have refused it. A record claiming Refuse for a request that
completed would be a false negative in the only direction that matters.
A first-seen host for this installation, any denial audit mode let through, and a session that
has issued alerts.distinctUrlsPerHost or more distinct URLs against one host all set
is_alert: true. The last of those is a signal a host allowlist cannot produce on its own; the
count rides on every fetch record as distinct_urls, and what it does and does not catch is in
the configuration reference.
Nothing is ever appended to the session log. Session.append() offers no way to set the
envelope’s ignorable flag, so an out-of-repo event type is written without it and the user’s
next resume throws SessionFormatUnsupportedError and refuses the whole session. This package
is read-side with respect to the log, and an E2E assertion checks that no row in it carries one
of our types.
Each record therefore carries its own identity. metadata.correlation_uid = <session>:<callId>
is dsh-ocsf-forwarder’s key unchanged, and it is the reason to run the two packages together:
the forwarder already emits Process Activity 1007 for every tool call, so the same key on a 4001
record answers which tool call opened this connection — at tool-call granularity, which no other
harness can do.
metadata.uid is deliberately not the forwarder’s key. It is <session>:netguard:<seq>, where
seq counts this package’s decisions in this process; a decision with no session behind it uses
dsh-netguard in the first slot. The forwarder’s is <session>:<seq> over the session log’s own
event sequence. Both start near 1 in the same session, so an un-namespaced key would make
session-88:4 the identity of two unrelated records, and a SIEM following both READMEs —
“deduplicate on metadata.uid”, “records from both packages can sit in one index” — would drop the
netguard one as a duplicate. The namespace sits in this package rather than in the forwarder’s key
because the forwarder is the established emitter with records already in indexes; see ADR.md §18.
When the join hits. The provider is handed { url } and nothing else, so the call id comes
from the tool guard, which notes url → identity for the provider to look up moments later, and
turn/step come from the tool/call session event. Both maps are bounded and lossy on
purpose — an unbounded map costs the agent its memory. A record the join missed carries
dst_endpoint, the verdict and the digests, but no correlation_uid and no turn; it is a
connection without a named cause rather than a wrong one.
Verbatim: a validated hostname, port, resolved IP address, verdict, matched rule id, tool
name. Digested: the full URL, any search query, and any string that was supposed to be a
hostname and is not — as HMAC-SHA256(key, value) truncated to 128 bits, with the length beside
it. The digest is stable, so a SIEM can still join on it; nobody reading the spool learns the
value. This mirrors dsh-ocsf-forwarder’s lane rule exactly, so records from both packages can
sit in one index without one of them being the leak — and metadata.uid is namespaced so that
sharing an index does not make one package’s records look like duplicates of the other’s.
“Validated” is the whole point of the word. dst_endpoint.hostname, observables[].value and
message only ever carry a plain host spelling ([a-z0-9.:_-]) or one of these markers, and
the value a marker stands in for rides as a digest in the extension attributes:
| Marker | Stands in for |
|---|---|
(query) |
a decision about a search query rather than about one target |
(non-string-argument) |
a url or query argument that was not a string |
(unparsed-url) |
text that is not a URL with a host this package can decide |
(unparsed-source) |
a vendor search result whose source URL does not parse |
(unrecordable-host) |
a hostname carrying characters a verbatim field may not hold |
That last one exists because WHATWG URL keeps ', ", a backtick, $, ;, , and { in a
hostname. report --suggest applies the same rule again on the way out: it writes a host into
YAML only when it matches [a-z0-9._-]+, so nothing a vendor or a model chose can add a line to
the allow list you paste into cordis.yml.
{
"class_uid": 4001, "category_uid": 4, "type_uid": 400105,
"activity_id": 5, "action_id": 2, "disposition_id": 2, "severity_id": 3, "status_id": 2,
"is_alert": false, "time": 1755300000000,
"message": "netguard refused paste.example: blocked-by-denylist",
"metadata": {
"product": { "name": "dsh-netguard", "vendor_name": "dsh-security-plugins", "version": "0.1.0" },
"version": "1.9.0", "profiles": ["security_control", "host"],
"log_provider": "deepseek-harness", "log_name": "netguard",
"uid": "session-88:4", "correlation_uid": "session-88:call-2", "sequence": 4,
"logged_time": 1755300000000
},
"dst_endpoint": { "hostname": "paste.example", "port": 443, "svc_name": "https" },
"connection_info": { "direction_id": 2, "protocol_name": "tcp" },
"firewall_rule": { "uid": "deny:paste.example", "name": "blocked-by-denylist" },
"observables": [{ "name": "dst_endpoint.hostname", "type_id": 1, "value": "paste.example" }],
"unmapped": { "dsh": {
"v": 1, "kind": "fetch", "mode": "enforce", "verdict": "denied", "enforced": true,
"reason": "blocked-by-denylist", "rule": "deny:paste.example", "tool": "web_fetch",
"session_id": "session-88", "call_id": "call-2", "root_call_id": "call-2",
"turn": 1, "step": 0, "decision_id": "netguard-…", "first_seen_host": false,
"url_digest": "hmac-sha256:9f2a…", "url_length": 63, "has_query": true, "hop": 0,
"distinct_urls": 4
} }
}
device and src_endpoint are left out of that listing only for length; every record carries
both. firewall_rule appears only when a pattern decided the request, and its name is the
same reason the attributes carry — an allowlist denial names no rule, so it has no
firewall_rule at all.
A spool write failure is reported on process.stderr and ctx.logger, then swallowed: the
spool is evidence, not enforcement, and letting a full disk turn every request into a refusal
would trade an egress control for an availability outage. Both channels are used because
ctx.logger’s default exporter is an in-memory ring buffer that no shipped bundle drains, so a
message sent only there is invisible on a stock install.
dsh-netguard report # everything in $DSH_HOME/netguard/decisions.ocsf.jsonl
dsh-netguard report --since 24h # or an ISO timestamp
dsh-netguard report --session <id>
dsh-netguard report --suggest # a ready allow: block from the observed hosts
dsh-netguard report --spool /var/log/dsh/netguard.ocsf.jsonl
It imports nothing from the harness, so it runs wherever the package is installed, with no
profile and no dsh on the path. A plugin installed into a profile puts its bin in that
profile’s node_modules/.bin, which is not on PATH:
"$DSH_HOME/profiles/<name>/node_modules/.bin/dsh-netguard" report
The spool is append-only and a run can be interrupted mid-append, so a line that does not parse as a record is counted and reported rather than trusted.