CharlotteN7/dsh-netguard0

dsh-netguard

Egress policy for DeepSeek Harness: a host allowlist on web_fetch and web_search enforced at connect time, audit-mode by default, with OCSF Network Activity records

包名
dsh-netguard
版本
0.1.0
许可证
MIT
最近更新
2026年8月16日

安装

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:CharlotteN7/dsh-netguard

Configuration

- id: dsh-netguard
  name: 'dsh-netguard'
  config:
    mode: audit                          # or enforce
    allow: ['**.github.com', '*.example.com', 'registry.npmjs.org:443']
    deny: ['*.internal.example']
    allowPrivateAddresses: []            # CIDR blocks; see below
    policyFile: ./.dsh-netguard.yml      # optional, lowest trust
    spoolPath: /var/log/dsh/netguard.ocsf.jsonl    # absolute; two sidecars sit beside it
    hostMemoryPath: /var/log/dsh/netguard.hosts    # absolute; default .hosts
    fetchProviderId: dsh-netguard        # what web.fetchProvider has to name
    searchProviderId: dsh-netguard       # what web.searchProvider would have to name
    vendorName: dsh-security-plugins     # metadata.product.vendor_name
    extension:
      name: dsh                          # keys the extension-owned attributes object
      placement: unmapped                # or `attribute`, which puts it at the top level
      uid: 999                           # omit until the OCSF registry assigns you one
    fetch:
      enabled: true
      timeoutMs: 30000
      maxRedirects: 5
      maxResponseBytes: 5000000
      maxBodyChars: 100000
      maxUrlLength: 2048
      userAgent: 'dsh-netguard/0.1.0 (+https://github.com/CharlotteN7/dsh-netguard)'
    search:
      enabled: true
      maxQueryLength: 2048               # past this a query is denied unscanned
      delegate:                          # absent = the search provider stays unusable
        module: '@deepseek-ai/dsh-web-search-exa'
        export: 'ExaSearchProvider'
        options: { apiKey: '...', baseURL: 'https://api.exa.ai', searchType: auto, highlightsPerResult: 1 }
    hmacKey: { source: ephemeral }       # or { source: env, variable: NETGUARD_KEY }
    fleet:
      tenantUid: acme
      labels: [prod]
      tags: { team: security }           # metadata.tags[]
      installUid: laptop-7               # skips the sidecar entirely when you set it
      installUidPath: /var/log/dsh/netguard.install-uid   # absolute; default .install-uid

Every path is required to be absolute. A relative one resolves against the process's working directory, which for dsh is the workspace — the same directory the repo-local policy tier is defended against — so a relative spoolPath puts the audit trail somewhere the agent it records can rewrite. A relative path fails the mount.

Two sidecar files sit beside the spool, both created on first use:

FileHoldsMatters because
.hostsevery host seen, with first/last sighting and countsis_alert on a first-seen host, and report --suggest
.install-uidone minted UUIDdevice.uid, which is stable across a rename and unique across a fleet imaged from one template

Point logrotate at the spool only. The two sidecars are rewritten in place rather than appended to, and rotating them costs the installation its host memory and its device.uid:

/var/log/dsh/netguard.ocsf.jsonl {
  weekly
  rotate 8
  compress
  missingok
  notifempty
  copytruncate
}

Set fleet.installUid yourself and the uid sidecar is never written. A spool directory this process cannot write is reported on stderr and the logger and then continues with an in-memory uid, because losing a stable device.uid is a smaller loss than refusing to mount.

The pattern grammar

Codex's semantics, which are the only unambiguous ones in the prior art:

PatternMatches
example.comthat host, and nothing else
*.example.comsubdomains only — never the apex
**.example.comthe apex and every subdomain
*everything; accepted in allow only
example.com:8443that host on that port only
[::1], [::1]:443an IPv6 literal, always bracketed

A deny match wins over every allow match, across every configuration source. An empty allow list denies everything, and that is what ships.

A pattern that could be read two ways is refused at load rather than widened. Refused: a prefix wildcard (prod*.blob.core.windows.net — that namespace is self-service, so the pattern matches names an attacker can register), a wildcard anywhere but at the front (a*b.example.com, *.*.internal.example, *.internal.*), a wildcard over a top-level domain (*.com), a wildcard over a common public suffix (*.co.uk), an unbracketed IPv6 literal, a URL, a path, or credentials. Only a leading *. or **. is a wildcard; anything else is a load-time error, in a deny list as much as in an allow list.

The public-suffix check is an approximation and is one on purpose: shipping a full public suffix list would put a 15,000-line data file that goes stale into the trusted computing base of a security control. It rejects the spellings that are both easy to write and catastrophic. A wildcard over a self-service namespace it does not know — *.github.io, *.blob.core.windows.net — is accepted, and is your risk.

Hosts are compared canonically on both sides. 2130706433, 0x7f000001, 127.1 and 017700000001 are all 127.0.0.1 because the hostname is read from url.hostname and WHATWG URL has already normalised them; [::ffff:127.0.0.1], the [::ffff:7f00:1] spelling URL leaves behind, the deprecated [::127.0.0.1] form and the NAT64 prefix [64:ff9b::7f00:1] are all unwrapped to 127.0.0.1 by this package. A name is IDNA-normalised and a trailing dot is dropped.

Addresses that are never reachable

Every resolved address is checked against a fixed table before the socket opens, and one refused address refuses the whole answer — a name with a public A record and an internal AAAA record reaches the internal host on any client that prefers IPv6, and picking the "good" one would make the outcome depend on address selection order rather than on policy.

Refused: 0.0.0.0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.0.0/24, 192.168/16, 198.18/15, 224/4, 240/4, ::/128, ::1/128, fc00::/7, fe80::/10, ff00::/8, and the cloud metadata endpoints 169.254.169.254, 169.254.170.2, 168.63.129.16, fd00:ec2::254.

allowPrivateAddresses opens named blocks for a deployment that genuinely needs an internal service — ['10.0.0.0/8'] for a corporate wiki, ['127.0.0.1/32'] for a local fixture. harden-runner allowlists RFC1918 by default; for an agent on a developer's own machine or a build host that is the wrong call, so nothing is reachable here unless you name it. The cloud metadata endpoints and the whole link-local range cannot be opened at all: an entry that overlaps them is a load-time error, because an agent that can reach 169.254.169.254 holds the host's cloud role.

Configuration trust ranking

RankSourceMay
1invariants compiled into the packageeverything; not configurable
2cordis.yml / bundle patch configset every field
3policyFile — a repo-local YAML filetighten only

Rank 3 is attacker-controlled: a hostile repository ships one, and a prompt-injected agent can write one. It may add deny patterns and raise audit to enforce. That is all:

v: 1
addDeny: ['*.internal.example', 'paste.example']
enforce: true

There is no allow, no way to open an address range, no way to name the spool, and enforce: false is an error rather than an ignored key. Any other key, and any downgrade, makes the whole file invalid: it is reported on process.stderr and the deployment's logger, then ignored, never obeyed in part. A missing file is not an error — the recommended policyFile is workspace-relative, so failing the mount would stop dsh from starting in every repository without one, and would let a hostile repository remove the control by shipping a broken file.

The file is parsed with js-yaml under JSON_SCHEMA, so a !!js/function tag is a parse error rather than code execution, and it never goes near the Cordis loader.

The harness takes the same line: packages/boot/app-boot/src/index.ts:111 forbids a repo-local .env from setting HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY.


Configuration trust ranking

RankSourceMay
1invariants compiled into the packageeverything; not configurable
2cordis.yml / bundle patch configset every field
3policyFile — a repo-local YAML filetighten only

Rank 3 is attacker-controlled: a hostile repository ships one, and a prompt-injected agent can write one. It may add deny patterns and raise audit to enforce. That is all:

v: 1
addDeny: ['*.internal.example', 'paste.example']
enforce: true

There is no allow, no way to open an address range, no way to name the spool, and enforce: false is an error rather than an ignored key. Any other key, and any downgrade, makes the whole file invalid: it is reported on process.stderr and the deployment's logger, then ignored, never obeyed in part. A missing file is not an error — the recommended policyFile is workspace-relative, so failing the mount would stop dsh from starting in every repository without one, and would let a hostile repository remove the control by shipping a broken file.

The file is parsed with js-yaml under JSON_SCHEMA, so a !!js/function tag is a parse error rather than code execution, and it never goes near the Cordis loader.

The harness takes the same line: packages/boot/app-boot/src/index.ts:111 forbids a repo-local .env from setting HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY.