openclaw - 💡(How to fix) Fix [Bug]: security audit "config.secrets.hooks_token_in_config" fires for ${VAR} env refs because the check runs on the resolved config [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#62438Fetched 2026-04-08 03:04:16
View on GitHub
Comments
1
Participants
2
Timeline
1
Reactions
0
Author
Participants
Timeline (top)
commented ×1

openclaw security audit --deep reports config.secrets.hooks_token_in_config (INFO) and config.secrets.gateway_password_in_config (WARN) for fields that are correctly using the documented ${ENV_VAR} substitution syntax.

The audit's looksLikeEnvRef() helper is intended to suppress those findings when the config value is ${...} form, but it never gets a chance to fire because the audit receives the resolved runtime config — by the time cfg.hooks.token reaches the check, ${OPENCLAW_HOOKS_TOKEN} has already been substituted by the config loader and the literal env-ref string is gone.

This is the same root cause as the previously reported (and stale-bot-closed) #25853 — confirmed still reproducible on the latest release.

Error Message

openclaw security audit --deep reports config.secrets.hooks_token_in_config (INFO) and config.secrets.gateway_password_in_config (WARN) for fields that are correctly using the documented ${ENV_VAR} substitution syntax. The same false-positive applies to gateway.auth.password when set to ${OPENCLAW_GATEWAY_PASSWORD} (this one is severity warn).

Root Cause

In the installed dist (OpenClaw 2026.4.5 (3e72c03)):

dist/audit-Cw4zL7mc.js:783 passes the resolved runtime config to the secrets-in-config check:

findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg));

Compare with line 773, where the gateway-config check correctly receives both:

findings.push(...collectGatewayConfigFindings(cfg, context.sourceConfig, env));

dist/audit.nondeep.runtime-CzeKxm2B.js:379-385 then runs:

const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
  findings.push({ checkId: "config.secrets.hooks_token_in_config", ... });
}

where looksLikeEnvRef is v.startsWith("${") && v.endsWith("}").

By the time cfg reaches this function, dist/io-CS2J_l4V.js:18977 (resolveConfigForRead(resolved, deps.env)) has already substituted ${OPENCLAW_HOOKS_TOKEN} with the literal token value, so looksLikeEnvRef returns false and the warning fires unconditionally.

The same pattern applies to gateway.auth.password immediately above (line 379 of the same dist file).

Code Example

"hooks": {
     "enabled": true,
     "token": "${OPENCLAW_HOOKS_TOKEN}",
     ...
   }

---

INFO
config.secrets.hooks_token_in_config Hooks token is stored in config
  hooks.token is set in the config file; keep config perms tight and treat it like an API secret.

---

findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg));

---

findings.push(...collectGatewayConfigFindings(cfg, context.sourceConfig, env));

---

const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
  findings.push({ checkId: "config.secrets.hooks_token_in_config", ... });
}

---

- findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg));
+ findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg, context.sourceConfig));

---

- const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
- if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
+ const sourceHooksToken = typeof sourceConfig?.hooks?.token === "string" ? sourceConfig.hooks.token.trim() : "";
+ const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
+ if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(sourceHooksToken || hooksToken)) {
RAW_BUFFERClick to expand / collapse

Summary

openclaw security audit --deep reports config.secrets.hooks_token_in_config (INFO) and config.secrets.gateway_password_in_config (WARN) for fields that are correctly using the documented ${ENV_VAR} substitution syntax.

The audit's looksLikeEnvRef() helper is intended to suppress those findings when the config value is ${...} form, but it never gets a chance to fire because the audit receives the resolved runtime config — by the time cfg.hooks.token reaches the check, ${OPENCLAW_HOOKS_TOKEN} has already been substituted by the config loader and the literal env-ref string is gone.

This is the same root cause as the previously reported (and stale-bot-closed) #25853 — confirmed still reproducible on the latest release.

Reproduction

  1. In ~/.openclaw/openclaw.json:
    "hooks": {
      "enabled": true,
      "token": "${OPENCLAW_HOOKS_TOKEN}",
      ...
    }
  2. Set OPENCLAW_HOOKS_TOKEN=<48-char-secret> in the gateway's EnvironmentFile (or any other working env source).
  3. Restart the gateway. Hooks ingress works correctly — the env var IS being resolved at runtime.
  4. Run openclaw security audit --deep.

Expected: No config.secrets.hooks_token_in_config finding, since the value uses the documented env-substitution syntax (looksLikeEnvRef returns true on ${OPENCLAW_HOOKS_TOKEN}).

Actual:

INFO
config.secrets.hooks_token_in_config Hooks token is stored in config
  hooks.token is set in the config file; keep config perms tight and treat it like an API secret.

The same false-positive applies to gateway.auth.password when set to ${OPENCLAW_GATEWAY_PASSWORD} (this one is severity warn).

Root cause

In the installed dist (OpenClaw 2026.4.5 (3e72c03)):

dist/audit-Cw4zL7mc.js:783 passes the resolved runtime config to the secrets-in-config check:

findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg));

Compare with line 773, where the gateway-config check correctly receives both:

findings.push(...collectGatewayConfigFindings(cfg, context.sourceConfig, env));

dist/audit.nondeep.runtime-CzeKxm2B.js:379-385 then runs:

const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
  findings.push({ checkId: "config.secrets.hooks_token_in_config", ... });
}

where looksLikeEnvRef is v.startsWith("${") && v.endsWith("}").

By the time cfg reaches this function, dist/io-CS2J_l4V.js:18977 (resolveConfigForRead(resolved, deps.env)) has already substituted ${OPENCLAW_HOOKS_TOKEN} with the literal token value, so looksLikeEnvRef returns false and the warning fires unconditionally.

The same pattern applies to gateway.auth.password immediately above (line 379 of the same dist file).

Suggested fix

collectSecretsInConfigFindings should consult the source (un-resolved) config for the env-ref check, while still allowing other checks to use the runtime config. The cleanest mirror of the existing pattern is to pass both:

- findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg));
+ findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg, context.sourceConfig));

and inside collectSecretsInConfigFindings, prefer the source value when checking looksLikeEnvRef:

- const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
- if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
+ const sourceHooksToken = typeof sourceConfig?.hooks?.token === "string" ? sourceConfig.hooks.token.trim() : "";
+ const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
+ if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(sourceHooksToken || hooksToken)) {

Same change applies to the gateway.auth.password block immediately above (config.secrets.gateway_password_in_config).

The configSnapshot object created by readConfigFileSnapshotInternal (dist/io-CS2J_l4V.js:18912) already exposes both sourceConfig and runtimeConfig, so no new plumbing is needed.

Why this matters

  • Discourages users from using the documented env-substitution pattern, since it produces a "noisy" audit.
  • Forces users to either store the secret literally in the config file (which is worse) or live with the warning permanently.
  • The previously filed report #25853 was auto-closed for inactivity without a fix; the bot's closing comment asked for fresh repro on the latest release — this is that.

Environment

  • OpenClaw version: 2026.4.5 (3e72c03)
  • Install method: npm (/usr/lib/node_modules/openclaw)
  • Node: v22.x (system Node)
  • OS: WSL2 Ubuntu 24.04 (Linux 6.6.87.2-microsoft-standard-WSL2)
  • Reproduces on a single-operator personal-assistant setup with multiple agents
  • Runtime confirms env-substitution works — only the audit is wrong

Related

  • #25853 (closed by stale bot, same root cause, prior release)
  • #53998, #61058 — different but similar ${VAR}-vs-audit confusion in the secrets-audit subcommand

extent analysis

TL;DR

The most likely fix is to modify the collectSecretsInConfigFindings function to consult the source config for the env-ref check, passing both the runtime config and the source config.

Guidance

  • Modify the collectSecretsInConfigFindings function to accept both the runtime config and the source config, similar to the existing pattern in the gateway-config check.
  • Inside collectSecretsInConfigFindings, prefer the source value when checking looksLikeEnvRef to correctly identify env-substitution syntax.
  • Apply the same change to the gateway.auth.password block to fix the config.secrets.gateway_password_in_config false positive.
  • Verify the fix by running openclaw security audit --deep and checking for the absence of the config.secrets.hooks_token_in_config and config.secrets.gateway_password_in_config findings.

Example

- findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg));
+ findings.push(...auditNonDeep.collectSecretsInConfigFindings(cfg, context.sourceConfig));
- const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
- if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
+ const sourceHooksToken = typeof sourceConfig?.hooks?.token === "string" ? sourceConfig.hooks.token.trim() : "";
+ const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
+ if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(sourceHooksToken || hooksToken)) {

Notes

The suggested fix relies on the existing configSnapshot object, which already exposes both sourceConfig and runtimeConfig, eliminating the need for additional plumbing.

Recommendation

Apply the workaround by modifying the collectSecretsInConfigFindings function as described, to correctly handle env-substitution syntax and suppress false positives in the security audit.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING