toby-bridges/api-relay-audit
toby-bridges/api-relay-audit从 DeepSeek Harness 对 AI API 中转站和 LLM 代理运行本地安全审计,生成 Markdown 报告,覆盖提示词注入、模型替换信号、工具调用改写、错误泄漏、流完整性和按 profile 启用的 Web3 风险。
DSH 插件安全扫描器:只读静态审计(执行、凭据、外传、混淆、安装脚本、bundle 清单),输出 0-100 风险分。
Read-only security, supply-chain, and health scanner for DeepSeek Harness plugins
Heuristic rules, AST/taint analysis, package quarantine, dependency intelligence, SBOM export, SARIF, and CI policy enforcement—without executing scanned code.
Node.js ^22.18.0 or >=24.11.0 · Static analysis primary; Phase B dynamic analysis is explicit opt-in and network-denied · MIT
English · 中文 · Rules · Architecture · Roadmap
dsh-sentinel gives DeepSeek Harness (DSH) plugins an X-ray before you trust or install them. It is a read-only static security scanner for plugin source trees, published npm packages, DSH profiles, and CI pipelines.
The scanner looks for command execution, dynamic code evaluation, credential access, data exfiltration, obfuscation, unsafe lifecycle scripts, persistence, native binaries, manifest escape paths, and package drift. It combines 51 heuristic rules with AST-based taint analysis, bounded module and cross-file analysis, supply-chain metadata, and explicit scan-coverage reporting.
Every scan produces a structured report with a 0–100 risk score, a
safe / review / risky / dangerous verdict, evidence for each finding, and
actionable remediation guidance. The same engine is available as:
sentinel_scan, sentinel_scan_profile, and
sentinel_audit_package;deepseek-harness-sentinel, executable
dsh-sentinel;Security disclaimer: heuristic static analysis is not a proof of safety. A finding means “review this evidence,” not “this plugin is malicious.” A clean report means that enabled checks found no issue; it does not guarantee safety.
DSH plugins may run with the same filesystem, network, credential, and process permissions as the user or agent that loads them. A small install script, hidden download-and-execute chain, unsafe tool argument, or escaped manifest path can therefore become a supply-chain incident.
dsh-sentinel is designed for two audiences:
require, import,
eval, or spawn target code.npm install. It downloads a tarball,
verifies integrity, extracts it into quarantine, scans it, and cleans up.--dynamic adds a separate Phase B layer and never changes the static result.unavailable rather than executing a package.| Area | What it does |
|---|---|
| Heuristic rules | 51 rules across execution, credentials, exfiltration, obfuscation, install scripts, filesystem, network, manifests, Agent Tools, taint, supply chain, binaries, and persistence. |
| Agent Tool analysis | Traces defineTool inputs such as args.* into shell, filesystem, network, and dynamic-code sinks. Handles aliases, computed properties, optional chaining, variable propagation, and bounded cross-function flows. |
| Module and cross-file analysis | Builds a bounded JS/TS module graph across ESM and statically provable CommonJS require / require.resolve edges, including constant string concatenation and TypeScript .js specifiers that map to .ts sources. Cross-file taint follows ESM imports and destructured CommonJS exports; unprovable dynamic module specifiers are reported instead of guessed. TypeScript parser limits are reported as a capability boundary instead of falsely failing every TS scan. |
| Language-aware coverage | JS-family files receive semantic analysis. TypeScript is conservatively degraded where syntax exceeds the parser boundary. Python and PowerShell files are not incorrectly fed into the JS parser and still receive applicable heuristic scanning. |
| DSH manifest validation | Audits dsh.bundle, cordis.patch.yml, package entry contracts, and lexical/realpath/symlink containment. Escaping paths trigger SEN-MAN-009. |
| Scan modes | source skips generated build trees by default; package includes distributable output such as dist and build; profile discovers and audits installed DSH plugins. |
| Pre-install quarantine | Safely extracts npm tarballs while blocking traversal, absolute paths, drive paths, symlink/hardlink entries, and tar bombs. Verifies sha512 integrity and cleans up all artifacts. |
| Supply-chain analysis | Inspects lifecycle scripts, npm and pnpm v9 normalized lockfile graphs, package metadata, optional OSV advisories, optional provenance, and source-versus-package drift. Unsupported lockfile formats remain explicit warnings. |
| Native artifacts | Audits .wasm, .exe, .dll, .so, .node, and related files using magic bytes, size, SHA-256, entropy, and printable strings. Binaries are never executed. |
| Professional report layers | Stable report schema v2 with module graph, dependency graph, capability graph, SBOM, provenance, attack chains, coverage, and failure metadata. |
| Output and CI | Text, JSON, SARIF 2.1.0, standalone HTML, CycloneDX, and SPDX output; stable fingerprints, baselines, threshold exits, and incomplete-scan enforcement. |
| Privacy | Automatic secret redaction, optional path anonymization with --redact-paths, and no hidden ignore/skip behavior. |
| Dynamic analysis (Phase B) | Explicit opt-in, static-preflight-gated, network-denied Docker/Podman runner with immutable image and staged-input contracts. The current release remains unavailable until a scanner-owned image is compiled/deployed. |
Scan completeness distinguishes security-critical coverage from optional enrichment:
scanComplete=false;Static completeness and dynamic completeness are separate signals. The static
scan remains the primary verdict and continues to report summary.scanComplete.
When --dynamic is requested, a dedicated analysisLayers.dynamic record
reports the deep-analysis state instead of changing or hiding static coverage.
Phase B is an explicit opt-in release gate layered after static analysis. It is
not a claim that a finite runtime exercise proves a plugin safe. Static
preflight remains authoritative for the static verdict, while
analysisLayers.dynamic reports the independent dynamic state. A scan without
--dynamic does not probe Docker or Podman and does not start a runner.
The production resolver accepts only a scanner-owned image compiled/deployed at
an immutable digest and a local Docker or Podman capability probe. If the image
is absent, mutable, remote, or unhealthy, the dynamic layer is unavailable
with a fixed reason such as trusted-image-unavailable; it does not pull,
build, execute on the host, or silently fall back. The current package ships no
approved image, so normal CLI/API use remains safely unavailable until the
release deployment supplies one.
Static analysis always runs first. Phase B refuses execution when preflight finds high-risk native executables or native Node modules selected for execution, container-control or escape signals, artifact limits, unresolved entrypoints, incomplete core traversal, or an unavailable isolation backend. Refusal is reportable and never erases static findings.
The dynamic status is one of not-requested, unavailable, refused,
complete, or incomplete:
| Status | Meaning |
|---|---|
not-requested | No --dynamic request was made. No engine probe occurs. |
unavailable | The local, immutable Phase B prerequisites are not present; the target was not run. |
refused | Static preflight rejected the target as outside the Phase B threat boundary. |
complete | All allowlisted stages produced bounded evidence and exact cleanup completed. |
incomplete | A stage, evidence parser, timeout, cancellation, resource limit, or cleanup path was uncertain. |
With --fail-on-incomplete or --strict-exit-codes, requested
unavailable/refused/incomplete dynamic results return exit code 3.
Without those strict controls, the result remains visible and the static exit
policy is unchanged. complete means only that the supplied stages and stimuli
completed; it is not a proof of safety, and no observed traffic means only that
these bounded stages did not trigger traffic.
The supported release gate is a protected, administrator-managed Linux
self-hosted runner labeled self-hosted, linux, and dsh-sentinel-phase-b,
with a local Docker or rootless Podman engine and a preloaded scanner-owned image
referenced by a full sha256 digest. The protected environment is named
dynamic-analysis-protected. The image must already be present: Phase B uses
--pull=never and performs no image build. Engine selection is the fixed
docker|podman allowlist; the accepted image form is
^[a-z0-9](?:[a-z0-9._/-]{0,254})@sha256:[a-f0-9]{64}$. Remote endpoints and
contexts are not accepted. Before either engine command, the gate enumerates
exported environment names, canonicalizes them to uppercase, and rejects any
non-empty casing variant of DOCKER_HOST,
DOCKER_CONTEXT, CONTAINER_HOST, CONTAINER_CONNECTION, DOCKER_CONFIG,
CONTAINERS_CONF, CONTAINERS_STORAGE_CONF, PODMAN_CONNECTIONS_CONF, or
XDG_CONFIG_HOME, then unsets those selectors/configuration variables. It uses
only /usr/bin/docker or /usr/bin/podman, a controlled empty environment and
/ as the working directory, with Docker bound to
--host=unix:///var/run/docker.sock or Podman bound to
--url=unix:///run/user/<uid>/podman/podman.sock. An unsafe selector fails
closed before image inspection; the inspect command has a fixed 15-second host
timeout with TERM and 5-second kill-after bounds, while the smoke run has a
fixed 90-second host timeout. The engine socket is never mounted into the
container.
Each allowlisted stage uses a fresh, short-lived runner with the following fixed properties:
The read-only staging mount is the only package input available to the runner; the original workspace is never mounted.
--network=none; Phase B has no public or private egress and does not provide
a gateway or network observation path;no-new-privileges;preinstall, install,
postinstall, or prepare.The staging lifecycle resolves and contains the scan root, copies only approved
regular files into a scanner-owned temporary snapshot, excludes links/devices,
sockets, VCS metadata, worktrees, and escape paths, mounts that snapshot
read-only, and removes it idempotently. Cleanup is ownership-scoped to the
current run. Any cleanup uncertainty is reported as incomplete; a report
must not expose raw engine diagnostics, full request bodies, host absolute paths,
or secret values.
Phase B intentionally stops at network denial. It binds the selected local
Docker/Podman endpoint and fixed harness executable through scanner-owned
arguments; callers cannot replace the endpoint, executable, image, mount, or
namespace policy. Trusted image ownership belongs to the scanner release, while
the staged package remains untrusted input. Gateway routing, DNS/HTTP/TCP/UDP
observation, Node preload probes, canary correlation, and any explicitly
allowlisted replay network belong to Phase C and are not implemented or implied
by a Phase B complete result.
The Linux-only CI gate is opt-in and runs only on that protected self-hosted
runner. It skips with an explicit unavailable reason when the protected
immutable image digest is absent or the exact image is not locally preloaded.
It has fixed two-minute job and 90-second host-command timeouts and never changes
the default static workflow. See .github/workflows/dynamic-smoke.yml for the
release contract.
The four opt-in controls are:
| Option | Phase B behavior |
|---|---|
--dynamic | Opt in to Phase B after static preflight; no request means no engine probe. |
--dynamic-backend <auto|docker|podman> | Select a local engine; Phase B still requires the immutable scanner-owned image and network denial. |
--dynamic-profile observe | Select the bounded Phase B observation contract; Phase C gateway/probe behavior is not included. |
--dynamic-timeout <ms> | Request a bounded timeout. Default: 15000; values are clamped to the enforced 1000–30000 ms range. |
An unavailable, refused, or incomplete requested deep scan exits with code 3
only when --fail-on-incomplete or --strict-exit-codes is set. Without either
strict flag, the deep-layer result remains visible but does not by itself fail
CI. This policy applies independently of static completeness.
dsh-sentinel supports the pnpm v9 lockfile layout as a read-only, normalized
dependency graph. It parses importers, packages, and snapshots with a
standards-aware YAML parser, preserves peer-suffixed package instances, resolves
workspace: / link: importer relationships, and keeps npm aliases attached to
the installed package identity. Workspace importer paths are containment-checked;
malformed YAML, path escapes, unresolved references, oversized lockfiles, and
unsupported pnpm lockfile versions are reported as explicit dependency-layer
failures rather than converted into guessed nodes.
The graph separates root direct dependencies from transitive package instances. The dependency summary uses this normalized graph for pnpm v9, so its direct and transitive counts are reproducible from the lockfile rather than inferred from indentation or regular-expression matches. If normalization fails, the summary returns zero counts with an explicit incomplete reason; it never presents a partial count as exact.
pnpm's requiresBuild: true field is retained as build evidence. It means pnpm
recorded that the package requires a build step in the install plan; it does not
prove that a specific lifecycle script is present, and dsh-sentinel deliberately
does not label it as a known install script. Each such package can include a
shortest root-to-package dependency path in the report. The same evidence is
carried into CycloneDX and SPDX exports, with unique component references for
peer-suffixed instances. SBOM output remains a serialization of observed graph
metadata, not a claim that package code was executed or installed.
The current exact lockfile graph support is pnpm v9. Yarn and Bun lockfiles are recognized and reported with an explicit unsupported/degraded state; they are not counted as if their formats were fully normalized.
The npm package is deepseek-harness-sentinel; it installs the dsh-sentinel
executable.
# Run without a global install
npx deepseek-harness-sentinel ./path/to/plugin
# Or install it as a project dependency
npm install --save-dev deepseek-harness-sentinel
npx dsh-sentinel ./path/to/plugin
Common workflows:
# Canonical JSON report
npx deepseek-harness-sentinel ./plugin --json --out sentinel.json
# Scan published/build output and create SARIF
npx deepseek-harness-sentinel ./plugin \
--mode package \
--format sarif \
--out sentinel.sarif
# Fail CI on high-or-critical findings or incomplete coverage
npx deepseek-harness-sentinel ./plugin \
--fail-on high \
--fail-on-incomplete \
--strict-exit-codes
# Request the opt-in Phase B layer. Without a deployed scanner-owned image it
# reports unavailable; it never runs the target on the host.
npx deepseek-harness-sentinel ./plugin \
--dynamic \
--dynamic-backend auto \
--dynamic-profile observe \
--dynamic-timeout 15000
# Audit a package before installation; no lifecycle script is executed
npx deepseek-harness-sentinel audit-install some-plugin@1.2.3
# Compare a source tree with a published npm package
npx deepseek-harness-sentinel diff ./plugin some-plugin@1.2.3
# Print the complete rule catalog
npx deepseek-harness-sentinel --rules
# Local checkout
dsh plugin --profile web add ./dsh-sentinel
# GitHub repository
dsh plugin --profile web add github:Eligahyu/dsh-sentinel-scanner
# npm package
dsh plugin --profile web add deepseek-harness-sentinel
dsh --profile web
You can then ask the agent to:
Use sentinel_scan to inspect ~/Downloads/some-plugin.
Use sentinel_scan_profile to audit third-party plugins in my web profile.
Use sentinel_audit_package to inspect some-plugin@1.2.3 before installation.
name: Plugin security scan
on:
pull_request:
push:
permissions:
contents: read
security-events: write
jobs:
sentinel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Scan plugin
uses: Eligahyu/dsh-sentinel-scanner@v0.4
with:
path: .
mode: source
fail-on: high
fail-on-incomplete: true
- name: Upload SARIF
if: always() && hashFiles('sentinel.sarif') != ''
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: sentinel.sarif
The Action installs only its own dependencies under ${{ github.action_path }}
with lifecycle scripts disabled. It never runs npm install or npm ci in the
repository being scanned. See the
GitHub Action integration guide.
dsh-sentinel <path> scan a directory or a single file
dsh-sentinel --profile <name> audit third-party plugins in a DSH profile
dsh-sentinel npm:<pkg>[@version] pre-install quarantine audit
dsh-sentinel audit-install <pkg> same as npm:<pkg>
dsh-sentinel diff <dir> <pkg> compare source with a published package
dsh-sentinel --rules print the rule catalog
| Option | Purpose |
|---|---|
--mode source|package|profile | Select source, distributable-package, or profile behavior. |
--format text|json|sarif|html|cyclonedx|spdx | Select report format. |
--out <file> | Write the full report to a file and keep a summary on stdout. |
--baseline <file> | Compare against a previous report using stable fingerprints. |
--fail-on <severity> | Exit 1 when a finding reaches critical, high, medium, or low. |
--fail-on-incomplete | Exit 3 when coverage is incomplete. |
--strict-exit-codes | Preserve distinct threshold, runtime, and incomplete-scan exits. |
--dynamic | Request the opt-in Phase B layer after static preflight; no request keeps scans static-only. |
--dynamic-backend <auto|docker|podman> | Select the local Docker/Podman backend; it still requires a scanner-owned immutable image. |
--dynamic-profile observe | Select the bounded Phase B profile; gateway/probe observation is deferred to Phase C. |
--dynamic-timeout <ms> | Bounded deep-analysis timeout; default 15000, clamped to 1000–30000 ms. |
--max-files, --max-plugins, --max-bytes | Set bounded resource limits. |
--config <file> | Load sentinel.config.json; CLI values override config values. |
--redact-paths | Replace absolute paths with shareable workspace labels. |
--advisories | Query OSV with package name and version only; disabled by default. |
--provenance | Read npm provenance attestations; disabled by default. |
Exit codes:
| Code | Meaning |
|---|---|
0 | Scan completed and the configured policy threshold was not exceeded. |
1 | Risk or policy threshold exceeded. |
2 | Usage or runtime error. |
3 | Static scan incomplete, or requested dynamic analysis unavailable/refused/incomplete, only when --fail-on-incomplete or --strict-exit-codes is enabled. |
| Severity | Weight | Typical evidence |
|---|---|---|
critical | 50 | Remote download-and-execute, private credential reads, strong exfiltration evidence, destructive home-directory commands, unsafe tar paths. |
high | 20 | Dynamic evaluation, hard-coded secrets, credential environment access, missing entry contracts, suspicious binary strings. |
medium | 8 | Shell execution requiring review, network access, lifecycle scripts, high-entropy binaries, persistence behavior. |
low | 3 | Suspicious encoding combinations, hard-coded public IPs, package-health metadata issues. |
info | 0 | Informational native binary or WASM presence. |
The score is capped at 100:
0–19 safe
20–49 review
50–79 risky
80–100 dangerous
Scoring is separated from report display:
maxFindings limits the
number of entries returned;main, exports, bin, or a patch;A low score is meaningful only when scan coverage is understood. Reports expose
scanComplete, incompleteReasons, discovered/analyzed file counts, large-file
handling, binaries, parser boundaries, ignored paths, and hard skips.
File/plugin limits, files above the hard size ceiling, binary sampling limits, and
core module/cross-file analysis failures can make a scan incomplete. TypeScript
syntax outside the current parser capability is explicitly degraded and recorded;
ordinary .ts, .tsx, or .d.ts presence is not an automatic scan failure.
Dynamic import() / `requ