feat(cve/researcher): vault-resolvable secrets for NVD API key and webhook URL #192
Loading…
Reference in a new issue
No description provided.
Delete branch "worktree-witty-sniffing-heron"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
nvdApiKeyandwebhookUrlas optional,sensitive: trueglobal args on the CVE researcher and Discord webhook extensions${{ vault.get() }}expressions, falling back toNVD_API_KEY/DISCORD_WEBHOOK_URLenv vars for backwards compatibility2026.08.13.1with upgrade stanza, bump manifest version to matchTest plan
deno checkanddeno lintpass (verified locally)deno test extensions/models/)swamp extension source addaccepts the new manifest🤖 Generated with Claude Code
Adversarial Review
Medium
discord_webhook_env.ts:WebhookGlobalArgsschema defined but not exported —sensitive: truemay have no runtime effect.The PR adds a
WebhookGlobalArgsZod schema (line 12–21) with.meta({ sensitive: true })onwebhookUrl, but this schema is only used locally for TypeScript type inference (z.infer<typeof WebhookGlobalArgs>). Theextensionexport on line 23 has noglobalArgumentsproperty, so the framework never sees the sensitive annotation.Compare with
cve_research.tswhereGlobalArgsSchemais properly wired intomodel.globalArguments(line 717) — the framework can seenvdApiKey's sensitive flag there.Breaking scenario: If the framework relies on the extension's declared schema (not just the vault expression syntax) to decide what to redact from logs and reports, the resolved
webhookUrlvalue could appear in plain text in framework-level logs even though the author intended it to be redacted.Suggested fix: Either export the schema as
globalArguments: WebhookGlobalArgson theextensionobject, or confirm that vault-expression-based redaction makes this moot and drop the misleading.meta({ sensitive: true })to avoid a false sense of coverage.Low
discord_webhook_env.ts:41:||→??changes empty-string fallback behavior.The original
||would treat an empty stringwebhookUrlas falsy and fall through toDISCORD_WEBHOOK_URLenv var. The new??only falls through onnull/undefined, so an empty string from a misconfigured vault would skip the env var fallback and hit theif (!webhookUrl)error instead of silently recovering.This is arguably better behavior (surfacing misconfiguration instead of hiding it), but it is a semantic change worth being aware of. The subsequent
if (!webhookUrl)guard on line 43 ensures no empty-string URL reaches thefetchcall, so no security or data-integrity risk.Verdict
PASS — The core change (vault-resolvable
nvdApiKeywith env var fallback, version bump, upgrade entry) is clean and correctly threaded throughqueryNvd. Thesensitiveannotation gap on the discord webhook extension is worth addressing but does not block merge since the webhook URL was never marked sensitive before this PR either, making this a net improvement regardless.Code Review
Blocking Issues
discord_webhook_env.ts: extension object is missing globalArguments field (line 23)
WebhookGlobalArgs schema is defined and used to type ExtensionContext.globalArgs, but never attached to the exported extension object. Compare: cve_research.ts has globalArguments: GlobalArgsSchema in the model export -- that is how the swamp runtime discovers vault-resolvable global args.
Without globalArguments on the extension object, the runtime cannot populate context.globalArgs.webhookUrl from a vault.get() expression. The field will always be undefined, silently falling through to Deno.env.get("DISCORD_WEBHOOK_URL") -- exactly the old behaviour. The vault path documented in the README will appear to work but the vault-supplied value is never used.
Fix: add globalArguments: WebhookGlobalArgs to the exported extension object.
Suggestions
Code Review
Blocking Issues
None.
Suggestions
discord_webhook_env.ts:WebhookGlobalArgsis unexported. The schema is used viaz.infer<typeof WebhookGlobalArgs>inside the same file, which works, but exporting it would let test files or other in-package modules reference the type directly. Minor, but consistent with howGlobalArgsSchemais exposed incve_research.ts.No test coverage for the
nvdApiKeyvault path. The new parameter flows throughqueryNvdand into theapiKeyheader, but there are no test file changes in this PR. The stated testing rule specifically calls outvault/anddatastore/extensions;cve/is not in scope of that rule, so this doesn't block. Worth adding a test that mocks the NVD endpoint and asserts theapiKeyheader is set whenglobalArgs.nvdApiKeyis supplied.discord_webhook_env.ts: Discord error body is not truncated.errorTextfromresponse.text()is thrown verbatim. A malformed or unexpectedly large Discord error response could produce a very large error string. ConsidererrorText.slice(0, 500)for safety.Summary
The vault-backed secrets wiring is implemented correctly. Both
nvdApiKeyandwebhookUrlare declared withz.string().meta({ sensitive: true }).optional(), pass through to the right call sites, and fall back to env vars via??(correctly preferring??over||to avoid treating an empty-string env var as absent). The version bump is consistent betweencve_research.tsandmanifest.yaml, and the upgrade entry's pass-throughupgradeAttributesis appropriate since the new field is optional. Noanytypes, no default exports, no hardcoded secrets.Adversarial Review
Medium
discord_webhook_env.ts:42—||→??changes empty-string fallback behaviorThe change from
||to??alters how an empty-stringwebhookUrlis handled. With the old||, an empty string from a vault value would be treated as falsy and fall through toDeno.env.get("DISCORD_WEBHOOK_URL"). With??, an empty string is not nullish and would be used directly as the URL, causingfetchto fail with an invalid-URL error instead of falling through to the env var.Breaking example: A user's vault has a
webhookUrlkey set to""(misconfigured or placeholder). Previously this silently fell through to the env var. Now it produces a runtime fetch error.Suggested fix: This is arguably the correct behavior (empty string = explicit bad config should error, not silently fall through). If the intent is to preserve the forgiving behavior, add
.pipe(z.string().min(1))to the Zod schema or keep||. As-is, the Zod schema accepts any string including empty, so the inconsistency exists. Not blocking because an empty-string vault value is an unlikely misconfiguration, and the error would be clear.Low
discord_webhook_env.ts:59— No timeout on Discord webhookfetchThe Discord webhook
fetchcall has noAbortSignal.timeout(), unlike thefetchJsonhelper incve_research.tswhich usesAbortSignal.timeout(30_000). If the Discord API hangs, this call blocks indefinitely. Pre-existing issue — not introduced by this PR — but worth noting since the author is actively working in this file.cve_research.ts:281— Same??vs||pattern fornvdApiKeyconst apiKey = nvdApiKey ?? Deno.env.get("NVD_API_KEY")— same empty-string behavior as the webhook case above. An empty-string vault value fornvdApiKeywould be sent as theapiKeyheader, which NVD would reject. Lower risk than the webhook case since NVD rejects bad keys with a clear HTTP error, and the retry logic infetchJsonhandles it.Verdict
PASS — This is a clean, well-scoped change. It adds vault-resolvable secrets with proper
sensitivemetadata, optional fields with env-var fallbacks, a correct no-op upgrade path, consistent version bumps across manifest and model export, and updated documentation. The||→??semantic shift is the only behavioral change and is low-risk in practice.Code Review
Blocking Issues
None.
Suggestions
discord_webhook_env.ts— error body may echo sensitive data (very low risk). When the Discord webhook returns a non-2xx response, the full response body is included in the thrown error:Discord error payloads don't normally contain the webhook URL, but if this error propagates to a log, any unexpected content from Discord could appear there. Consider truncating
errorTextto a safe length (e.g.,.slice(0, 200)) to bound log output and prevent any future surprise from Discord changing its error format.No test coverage for the new
nvdApiKeyforwarding path. The testing rules specifically call outvault/anddatastore/extensions, so this isn't a hard requirement forcve/. That said, the key forwarding inqueryNvd(thenvdApiKey ?? Deno.env.get("NVD_API_KEY")line) has no corresponding test that verifies the argument-supplied key is actually set in theapiKeyheader. A small unit test with a local HTTP server capturing request headers would lock in this behaviour cheaply.discord_webhook_env.tsmethodsis an array of objects rather than a plain object.methods: [{ sendFromEnv: { ... } }]differs from the model convention used incve_research.ts(methods: { research: { ... } }). This is pre-existing and not introduced by this PR, so out of scope to fix here — but worth tracking if the extension schema has a canonical shape.Notes
??replacement for||in the webhook URL resolution is correct:.min(1)on the schema ensures an empty string can't arrive viaglobalArgs.webhookUrl, and??is semantically tighter (only falls back onnull/undefined).AbortSignal.timeout(30_000)added to the Discord fetch matches the timeout already present incve_research.ts— good consistency.upgradeAttributes: (old) => old) are all correct for an additive optional field.anytypes, all exports are named, and thenpm:zod@4.3.6import is pinned to an exact version.Adversarial Review
I traced all code paths introduced by this PR: vault-resolvable
nvdApiKeyglobal arg incve_research.ts,WebhookGlobalArgsschema and export indiscord_webhook_env.ts, the||→??fix, the 30-second timeout on the Discord fetch, the upgrade entry, version bump, and documentation changes.Critical / High
None found.
Medium
None found.
Low
discord_webhook_env.ts:66-78— Response body not consumed on success path. Whenresponse.okis true, the code proceeds towriteResourcewithout consuming or canceling the response body. In Deno, this is technically a resource leak (the connection isn't returned to the pool until GC or the abort signal fires at 30s). This is pre-existing behavior — the PR only added the timeout, which actually bounds the leak duration. Not blocking, just noting for a future cleanup: addawait response.body?.cancel()after theresponse.okcheck on the success path.Verdict
PASS — Clean, well-scoped change. The
nvdApiKeyglobal arg is correctly optional with.min(1)to reject empty strings,sensitive: truefor log redaction, and env-var fallback. The||to??fix indiscord_webhook_env.tsis correct (the.min(1)schema constraint guarantees a provided value is non-empty, so the operators are equivalent, but??is semantically precise). TheAbortSignal.timeout(30_000)on the Discord fetch is good hardening. The upgrade entry is a no-op passthrough, which is correct for an additive optional field. No security, logic, or data integrity issues found.