Skip to content

audit(security): add 5 missing threat-model audit checks#36307

Open
Techris93 wants to merge 5 commits intoopenclaw:mainfrom
Techris93:audit/security-audit-gap-checks
Open

audit(security): add 5 missing threat-model audit checks#36307
Techris93 wants to merge 5 commits intoopenclaw:mainfrom
Techris93:audit/security-audit-gap-checks

Conversation

@Techris93
Copy link

@Techris93 Techris93 commented Mar 5, 2026

Fixes #16, #17, #18, #19, #20

While reviewing the threat model in openclaw/trust, I noticed several
threats listed in threats.yaml that the security audit doesn't flag.
This adds 5 new checks:

checkId Threat What it flags
gateway.token_no_expiry T-PERSIST-004 Token with no expiry/rotation
fs.config.inside_git_repo T-ACCESS-003 Config dir inside a git repo
env.dangerous_vars_set T-DISC-004 Dangerous env vars like LD_PRELOAD
tools.web_fetch.no_url_allowlist T-EXFIL-001 No URL restrictions on web_fetch
gateway.no_message_rate_limit T-IMPACT-002 No per-sender rate limiting

All of these have mitigations: None or weak coverage in the threat model,
so seemed worth surfacing in the audit.

18 new tests in audit-gaps.test.ts, all passing. Existing 91 tests
unaffected.

Add the following audit findings:
- gateway.token_no_expiry (T-PERSIST-004)
- fs.config.inside_git_repo (T-ACCESS-003)
- env.dangerous_vars_set (T-DISC-004)
- tools.web_fetch.no_url_allowlist (T-EXFIL-001)
- gateway.no_message_rate_limit (T-IMPACT-002)

17 new tests, 91 existing tests pass (0 regressions).

Fixes openclaw#16, openclaw#17, openclaw#18, openclaw#19, openclaw#20
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 5, 2026

Greptile Summary

This PR adds five new threat-model-driven audit checks to audit.ts, covering token expiry (T-PERSIST-004), config-in-git-repo (T-ACCESS-003), dangerous env vars (T-DISC-004), web_fetch URL allowlisting (T-EXFIL-001), and per-sender message rate limiting (T-IMPACT-002). The implementation is generally solid and the env-var tiering (high-risk warn vs language-tooling info) is a thoughtful improvement over the original flat list.

  • NODE_OPTIONS is placed in HIGH_RISK_ENV_VARS (always "warn"), but it is routinely set by common Node.js tooling such as ts-node, monorepo memory configs, and test harnesses. The test file itself demonstrates the false-positive: NODE_OPTIONS: "--max-old-space-size=4096" is expected to trigger a warn. Unlike LD_PRELOAD, the danger is value-dependent, not presence-dependent. Consider moving it to LANG_TOOLING_ENV_VARS or using value-based detection for injection flags like --require/--loader.
  • The detail message for the high-risk env-var finding states all listed variables "can be used for arbitrary code injection", but SSLKEYLOGFILE is a TLS key-disclosure risk, not a code-injection vector. The message should be split or qualified to accurately describe both threat classes.
  • (Previously flagged) isInsideGitRepo uses the synchronous existsSync inside an otherwise-async filesystem walker, blocking the event loop during directory traversal.
  • (Previously flagged) The tokenRotation existence check in gateway.token_no_expiry creates a false negative when tokenRotation: { enabled: false } is set.

Confidence Score: 3/5

  • Safe to merge after addressing the NODE_OPTIONS false-positive and the SSLKEYLOGFILE detail-text inaccuracy; the two previously flagged issues (sync existsSync, tokenRotation false negative) also remain open.
  • The five new checks are well-motivated and generally correct. However, NODE_OPTIONS in the high-risk tier will generate persistent warn-level noise for any Node.js developer running openclaw (a Node.js tool), and the SSLKEYLOGFILE detail text is factually wrong about its threat class. Combined with the two still-open issues from prior review threads, there are enough outstanding correctness concerns to warrant a revisit before merging.
  • src/security/audit.ts — specifically the HIGH_RISK_ENV_VARS list (NODE_OPTIONS placement) and the env.dangerous_vars_set detail message (SSLKEYLOGFILE description).

Last reviewed commit: 88c9fd6

Comment on lines +207 to +220
function isInsideGitRepo(startDir: string): string | null {
let dir = path.resolve(startDir);
while (true) {
if (existsSync(path.resolve(dir, ".git"))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return null;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronous existsSync inside async filesystem walker

isInsideGitRepo uses the synchronous existsSync from node:fs and can traverse many directory levels before reaching the filesystem root. This blocks the Node.js event loop for the entire traversal. Every other FS operation in collectFilesystemFindings uses the async fsp variants. Consider using fsPromises.access in an async version of this helper so it stays consistent with the rest of the function:

import { access as fspAccess } from "node:fs/promises";

async function isInsideGitRepo(startDir: string): Promise<string | null> {
  let dir = path.resolve(startDir);
  while (true) {
    try {
      await fspAccess(path.resolve(dir, ".git"));
      return dir;
    } catch {
      // not found at this level
    }
    const parent = path.dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  return null;
}

collectFilesystemFindings is already async, so this is a non-breaking change.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/security/audit.ts
Line: 207-220

Comment:
**Synchronous `existsSync` inside async filesystem walker**

`isInsideGitRepo` uses the synchronous `existsSync` from `node:fs` and can traverse many directory levels before reaching the filesystem root. This blocks the Node.js event loop for the entire traversal. Every other FS operation in `collectFilesystemFindings` uses the async `fsp` variants. Consider using `fsPromises.access` in an async version of this helper so it stays consistent with the rest of the function:

```ts
import { access as fspAccess } from "node:fs/promises";

async function isInsideGitRepo(startDir: string): Promise<string | null> {
  let dir = path.resolve(startDir);
  while (true) {
    try {
      await fspAccess(path.resolve(dir, ".git"));
      return dir;
    } catch {
      // not found at this level
    }
    const parent = path.dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  return null;
}
```

`collectFilesystemFindings` is already `async`, so this is a non-breaking change.

How can I resolve this? If you propose a fix, please make it concise.

@Techris93 Techris93 force-pushed the audit/security-audit-gap-checks branch from 1fb0f9e to 34fcc7b Compare March 5, 2026 15:07
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34fcc7b04e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1338 to +1341
const webFetchPolicy = asRecord(cfg.tools)?.webFetch ?? asRecord(cfg.tools)?.web_fetch;
const webFetchEnabled = webFetchPolicy !== false && webFetchPolicy?.enabled !== false;
const webFetchAllowlist = Array.isArray(webFetchPolicy?.allowedUrls)
? webFetchPolicy.allowedUrls

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Read web_fetch settings from canonical tools.web.fetch path

This check reads cfg.tools.webFetch/cfg.tools.web_fetch and allowedUrls, but the validated config only exposes tools.web.fetch and is strict (src/config/zod-schema.agent-runtime.ts), so tools.web.fetch.enabled=false is ignored and users still get tools.web_fetch.no_url_allowlist. In non-loopback deployments this produces a persistent false warning with remediation pointing to unsupported keys, so the audit cannot be satisfied through valid configuration.

Useful? React with 👍 / 👎.

Comment on lines +651 to +652
!asRecord(cfg.gateway?.auth)?.tokenExpiry &&
!asRecord(cfg.gateway?.auth)?.tokenRotation

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid referencing unsupported token expiry config fields

The token-expiry warning is gated on gateway.auth.tokenExpiry/gateway.auth.tokenRotation, but gateway.auth is strict and only accepts mode, token, password, allowTailscale, rateLimit, and trustedProxy (src/config/zod-schema.ts:613-645). Any token-auth deployment will therefore always trigger this finding and the suggested remediation cannot be applied in a valid config.

Useful? React with 👍 / 👎.

Comment on lines +744 to +745
!asRecord(asRecord(cfg.gateway)?.rateLimit)?.enabled &&
!asRecord(cfg.gateway)?.messageRateLimit

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop checking nonexistent gateway message rate-limit keys

This condition looks for gateway.rateLimit.enabled or gateway.messageRateLimit, but neither key exists in the strict gateway schema (src/config/zod-schema.ts:587-760), so non-loopback gateways will always be flagged regardless of real settings. Because the remediation tells operators to set unsupported keys, this creates an unsatisfiable audit warning.

Useful? React with 👍 / 👎.

@Techris93
Copy link
Author

@greptile review

Comment on lines +1299 to +1315
const DANGEROUS_ENV_VARS = [
"NODE_OPTIONS",
"GLIBC_TUNABLES",
"JAVA_TOOL_OPTIONS",
"JDK_JAVA_OPTIONS",
"LD_AUDIT",
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"PYTHONPATH",
"PYTHONSTARTUP",
"RUBYOPT",
"PERL5OPT",
"BASH_ENV",
"SSLKEYLOGFILE",
];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PYTHONPATH, RUBYOPT, PERL5OPT, JAVA_TOOL_OPTIONS, and JDK_JAVA_OPTIONS are routinely injected into the process environment by ordinary language tooling — Python virtualenvs (virtualenv, conda, Poetry), JetBrains/Eclipse IDEs, Ruby bundler wrappers, etc. Treating these identically to LD_PRELOAD, LD_AUDIT, and DYLD_INSERT_LIBRARIES (which are near-universal native code-injection vectors) will produce persistent false positives.

In practice, a Python developer who opens a terminal with an active virtualenv will always get this warning for PYTHONPATH, regardless of threat exposure. The remediation text "Unset these variables unless intentionally configured" compounds the issue — following that advice for a virtualenv-managed PYTHONPATH would actively break the user's Python development environment.

Consider splitting the list into tiers and only surfacing the high-risk tier as "warn":

// Always high-risk: native shared library / linker injection
const HIGH_RISK_ENV_VARS = [
  "LD_AUDIT",
  "LD_PRELOAD",
  "LD_LIBRARY_PATH",
  "DYLD_INSERT_LIBRARIES",
  "DYLD_LIBRARY_PATH",
  "SSLKEYLOGFILE",
];

// Moderately risky but routinely set by language tooling
const MEDIUM_RISK_ENV_VARS = [
  "NODE_OPTIONS",
  "GLIBC_TUNABLES",
  "JAVA_TOOL_OPTIONS",
  "JDK_JAVA_OPTIONS",
  "PYTHONPATH",
  "PYTHONSTARTUP",
  "RUBYOPT",
  "PERL5OPT",
  "BASH_ENV",
];

Emit "warn" only for HIGH_RISK_ENV_VARS and "info" (or suppress) for MEDIUM_RISK_ENV_VARS to preserve the T-DISC-004 signal without alert-fatiguing developers on every audit run.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/security/audit.ts
Line: 1299-1315

Comment:
`PYTHONPATH`, `RUBYOPT`, `PERL5OPT`, `JAVA_TOOL_OPTIONS`, and `JDK_JAVA_OPTIONS` are routinely injected into the process environment by ordinary language tooling — Python virtualenvs (virtualenv, conda, Poetry), JetBrains/Eclipse IDEs, Ruby bundler wrappers, etc. Treating these identically to `LD_PRELOAD`, `LD_AUDIT`, and `DYLD_INSERT_LIBRARIES` (which are near-universal native code-injection vectors) will produce persistent false positives.

In practice, a Python developer who opens a terminal with an active virtualenv will **always** get this warning for `PYTHONPATH`, regardless of threat exposure. The remediation text *"Unset these variables unless intentionally configured"* compounds the issue — following that advice for a virtualenv-managed `PYTHONPATH` would actively break the user's Python development environment.

Consider splitting the list into tiers and only surfacing the high-risk tier as `"warn"`:

```ts
// Always high-risk: native shared library / linker injection
const HIGH_RISK_ENV_VARS = [
  "LD_AUDIT",
  "LD_PRELOAD",
  "LD_LIBRARY_PATH",
  "DYLD_INSERT_LIBRARIES",
  "DYLD_LIBRARY_PATH",
  "SSLKEYLOGFILE",
];

// Moderately risky but routinely set by language tooling
const MEDIUM_RISK_ENV_VARS = [
  "NODE_OPTIONS",
  "GLIBC_TUNABLES",
  "JAVA_TOOL_OPTIONS",
  "JDK_JAVA_OPTIONS",
  "PYTHONPATH",
  "PYTHONSTARTUP",
  "RUBYOPT",
  "PERL5OPT",
  "BASH_ENV",
];
```

Emit `"warn"` only for `HIGH_RISK_ENV_VARS` and `"info"` (or suppress) for `MEDIUM_RISK_ENV_VARS` to preserve the T-DISC-004 signal without alert-fatiguing developers on every audit run.

How can I resolve this? If you propose a fix, please make it concise.

Techris93 and others added 2 commits March 5, 2026 20:56
Split DANGEROUS_ENV_VARS into HIGH_RISK (warn) and LANG_TOOLING (info).
Native-injection vars like LD_PRELOAD stay warn; language vars like
PYTHONPATH that are routinely set by virtualenvs become info-only.
@Techris93
Copy link
Author

@greptile review

// T-DISC-004: check for dangerous env vars in current environment
// High-risk: native code injection vectors — always warn
const HIGH_RISK_ENV_VARS = [
"NODE_OPTIONS",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NODE_OPTIONS causes false positives for Node.js developers

NODE_OPTIONS is placed in the HIGH_RISK_ENV_VARS tier (always "warn"), but it is routinely set by normal Node.js development tooling — e.g. ts-node sets NODE_OPTIONS='--loader ts-node/esm', monorepo runners set memory limits like NODE_OPTIONS='--max-old-space-size=4096', and various test harnesses use it for experimental features. The test at audit-gaps.test.ts:137 even demonstrates this exact false-positive scenario: NODE_OPTIONS: "--max-old-space-size=4096" is explicitly expected to trigger a "warn" finding.

Unlike LD_PRELOAD or BASH_ENV, whose presence alone is a strong signal, the danger from NODE_OPTIONS is value-dependent (--require/-r loads arbitrary code; --max-old-space-size does not). JAVA_TOOL_OPTIONS — which similarly enables agent injection via -javaagent: — was moved to the lower-risk LANG_TOOLING_ENV_VARS tier, so the asymmetry here seems unintentional.

Consider either moving NODE_OPTIONS to LANG_TOOLING_ENV_VARS, or applying value-based detection:

// In HIGH_RISK_ENV_VARS check, also scan NODE_OPTIONS value for injection patterns
const nodeOpts = env["NODE_OPTIONS"] ?? "";
if (/(?:^|\s)(?:-r|--require|--loader|--import)\s/i.test(nodeOpts)) {
  highRiskSet.push("NODE_OPTIONS");
} else if (nodeOpts.trim().length > 0) {
  langToolingSet.push("NODE_OPTIONS");
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/security/audit.ts
Line: 1301

Comment:
**`NODE_OPTIONS` causes false positives for Node.js developers**

`NODE_OPTIONS` is placed in the `HIGH_RISK_ENV_VARS` tier (always `"warn"`), but it is routinely set by normal Node.js development tooling — e.g. `ts-node` sets `NODE_OPTIONS='--loader ts-node/esm'`, monorepo runners set memory limits like `NODE_OPTIONS='--max-old-space-size=4096'`, and various test harnesses use it for experimental features. The test at `audit-gaps.test.ts:137` even demonstrates this exact false-positive scenario: `NODE_OPTIONS: "--max-old-space-size=4096"` is explicitly expected to trigger a `"warn"` finding.

Unlike `LD_PRELOAD` or `BASH_ENV`, whose presence alone is a strong signal, the danger from `NODE_OPTIONS` is value-dependent (`--require`/`-r` loads arbitrary code; `--max-old-space-size` does not). `JAVA_TOOL_OPTIONS` — which similarly enables agent injection via `-javaagent:` — was moved to the lower-risk `LANG_TOOLING_ENV_VARS` tier, so the asymmetry here seems unintentional.

Consider either moving `NODE_OPTIONS` to `LANG_TOOLING_ENV_VARS`, or applying value-based detection:

```ts
// In HIGH_RISK_ENV_VARS check, also scan NODE_OPTIONS value for injection patterns
const nodeOpts = env["NODE_OPTIONS"] ?? "";
if (/(?:^|\s)(?:-r|--require|--loader|--import)\s/i.test(nodeOpts)) {
  highRiskSet.push("NODE_OPTIONS");
} else if (nodeOpts.trim().length > 0) {
  langToolingSet.push("NODE_OPTIONS");
}
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88c9fd6dec

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1360 to +1361
const webFetchEnabled = webFetchPolicy !== false && webFetchPolicy?.enabled !== false;
const webFetchAllowlist = Array.isArray(webFetchPolicy?.allowedUrls)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor tool deny policy in web_fetch allowlist audit

This check treats web_fetch as enabled whenever tools.webFetch/web_fetch is not explicitly disabled, but it never consults runtime tool policy (tools.deny, agent-specific deny lists, or group policy), even though execution gating enforces deny-first matching via isToolAllowedByPolicies/makeToolPolicyMatcher in src/agents/pi-tools.policy.ts. In a hardened config that denies web_fetch globally (for example tools.deny: ["web_fetch"] or group:web), this still emits tools.web_fetch.no_url_allowlist, creating a false positive that can’t be cleared by the actual mitigation (disabling the tool through policy).

Useful? React with 👍 / 👎.

Techris93 and others added 2 commits March 5, 2026 21:14
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Telegram integration

1 participant