Skip to content

fix: require non-empty digest in blueprint verification; fail closed on missing or empty field#765

Open
thebenignhacker wants to merge 2 commits intoNVIDIA:mainfrom
opena2a-org:fix/C-005-digest-bypass
Open

fix: require non-empty digest in blueprint verification; fail closed on missing or empty field#765
thebenignhacker wants to merge 2 commits intoNVIDIA:mainfrom
opena2a-org:fix/C-005-digest-bypass

Conversation

@thebenignhacker
Copy link

@thebenignhacker thebenignhacker commented Mar 24, 2026

Summary

blueprint.yaml ships with digest: "" and there is no verification code
in the codebase. An empty digest means no integrity check is ever performed
on the blueprint directory — any modification to policies, sandbox configs,
or orchestration scripts goes undetected.

If verification code were added with the common pattern
if (digest && digest !== actual), an empty string would evaluate as falsy,
silently bypassing the check.

Change

  • Add bin/lib/verify-digest.js: SHA-256 verification of the blueprint
    directory that fails closed when digest is missing or empty
  • blueprint.yaml: document that digest must be populated before release
  • Add tests confirming fail-closed behavior on empty digest
  • Support explicit --skip-verification opt-out for development workflows

Detection

This vulnerability class is detectable via HackMyAgent:

npx hackmyagent secure .

References

  • PSIRT disclosure: tickets 6009892–6010011
  • CWE-345: Insufficient Verification of Data Authenticity

Summary by CodeRabbit

  • New Features

    • Added blueprint integrity verification using deterministic SHA-256 digest; verification fails securely when digest is missing or mismatched, with an opt-out for development.
  • Documentation

    • Clarified the digest field and how to compute it before release.
  • Tests

    • Added tests covering digest computation, missing/empty digest handling, opt-out path, and regression cases.

…on missing or empty field

The blueprint.yaml ships with digest: "" which means no integrity
verification is performed. Previously there was no verification code
at all — an empty digest was silently accepted.

Changes:
- Add bin/lib/verify-digest.js: SHA-256 verification of the blueprint
  directory that fails closed when digest is missing or empty
- blueprint.yaml: document that digest must be populated before release
- Add tests confirming fail-closed behavior on empty digest

The verification module supports explicit --skip-verification opt-out
for development workflows, but the default is fail-closed.

Detection: npx hackmyagent secure .

References:
- PSIRT disclosure: tickets 6009892-6010011
- CWE-345: Insufficient Verification of Data Authenticity
@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

A new blueprint digest verification module is introduced that computes deterministic SHA-256 digests of blueprint directories and verifies integrity by comparing computed digests against expected values stored in blueprint.yaml. Supporting tests and a comment update in the blueprint file were added.

Changes

Cohort / File(s) Summary
Digest Verification Module
bin/lib/verify-digest.js
Added new CommonJS module that recursively collects blueprint files (sorted), hashes relative paths and contents with SHA-256, zeroes digest in blueprint.yaml when computing, extracts the expected digest value, and exposes computeBlueprintDigest(), readExpectedDigest(), and verifyBlueprintDigest(opts). Fail-closed unless opts.skipVerification is true.
Blueprint Configuration
nemoclaw-blueprint/blueprint.yaml
Updated inline comment for the digest field to require populating the digest before release and to show the Node.js command to compute it (./bin/lib/verify-digest). No other YAML keys changed.
Verification Tests
test/verify-digest.test.js
New Vitest suite that asserts digest format and determinism, validates readExpectedDigest() behavior, enforces fail-closed when expected digest is missing/empty, and verifies skipVerification returns a skipped/valid result. Includes a regression test for empty-string digest.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as "CLI / build step"
    participant Module as "verify-digest module"
    participant FS as "Filesystem (`nemoclaw-blueprint`)"
    participant Hasher as "SHA-256 hasher"

    CLI->>Module: invoke verifyBlueprintDigest(opts)
    Module->>FS: read file list (recursive, sorted)
    FS-->>Module: file paths & contents
    Module->>Module: zero `digest` in blueprint.yaml (in-memory)
    Module->>Hasher: hash each relative path + contents
    Hasher-->>Module: computedDigest
    Module->>FS: read blueprint.yaml -> extract expectedDigest
    Module-->>CLI: return { valid, reason?, expectedDigest, actualDigest }
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped through files, both large and small,
I counted paths and hashed them all.
SHA-256 hums a tidy song,
Blueprint sealed where hashes belong. 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: implementing required non-empty digest verification with fail-closed behavior on missing or empty fields.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
test/verify-digest.test.js (1)

32-42: Conditional test body weakens regression coverage.

The if (!expected || expected.trim() === "") guard means this test silently passes without executing any assertions if someone populates the digest field. For a regression test designed to catch the original CWE-345 bypass, consider making it unconditional by mocking or directly testing the fail-closed path:

Option 1: Always test with an explicit empty digest
   it("verifyBlueprintDigest fails closed on empty digest", () => {
-    const { verifyBlueprintDigest, readExpectedDigest } = require(verifyDigestPath);
-    const expected = readExpectedDigest();
-
-    // The shipped blueprint.yaml has digest: "" — verification must fail
-    if (!expected || expected.trim() === "") {
-      const result = verifyBlueprintDigest();
-      expect(result.valid).toBe(false);
-      expect(result.reason).toMatch(/missing or empty/);
-    }
+    // Test the fail-closed behavior directly by providing an empty digest scenario.
+    // Since blueprint.yaml ships with digest: "", this test should always execute.
+    const { verifyBlueprintDigest, readExpectedDigest } = require(verifyDigestPath);
+    const expected = readExpectedDigest();
+
+    // Assert precondition: shipped blueprint.yaml has empty digest
+    expect(!expected || expected.trim() === "").toBe(true);
+
+    const result = verifyBlueprintDigest();
+    expect(result.valid).toBe(false);
+    expect(result.reason).toMatch(/missing or empty/);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/verify-digest.test.js` around lines 32 - 42, The test currently guards
execution with if (!expected || expected.trim() === ""), which lets it skip
assertions when a digest exists; change the test to unconditionally exercise the
fail-closed path by forcing an empty digest: either stub/mock readExpectedDigest
to return "" (so require(verifyDigestPath).readExpectedDigest returns "") or
call verifyBlueprintDigest with an explicit empty/undefined digest to assert
result.valid is false and result.reason matches /missing or empty/; update the
test around verifyBlueprintDigest and readExpectedDigest to ensure the
assertions always run.
bin/lib/verify-digest.js (2)

24-24: Consider symlink handling for defense-in-depth.

fs.statSync follows symlinks, so a malicious symlink in the blueprint directory could cause the digest to include content from outside the directory. While the threat model may already assume directory integrity, using lstatSync + explicit symlink rejection would provide defense-in-depth for integrity verification.

Optional: Reject symlinks explicitly
     const fullPath = path.join(dirPath, entry);
     const relativePath = prefix ? `${prefix}/${entry}` : entry;
-    const stat = fs.statSync(fullPath);
+    const stat = fs.lstatSync(fullPath);
+    if (stat.isSymbolicLink()) {
+      throw new Error(`Symlinks not allowed in blueprint directory: ${relativePath}`);
+    }
     if (stat.isDirectory()) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/verify-digest.js` at line 24, Replace the use of
fs.statSync(fullPath) with fs.lstatSync(fullPath) in verify-digest.js and
explicitly reject symlinks: call const stat = fs.lstatSync(fullPath) and if
(stat.isSymbolicLink()) throw an error (or skip with a clear failure message) so
the digest routine (where fullPath is inspected) cannot follow symlinks and
include files outside the blueprint directory.

48-51: Regex assumes double-quoted digest value.

The regex /^digest:\s*"[^"]*"/m only matches double-quoted digest values. While the current blueprint.yaml uses this format, YAML also permits single quotes, unquoted values, or flow style. If the YAML format ever changes, the zeroing logic would fail silently (leaving the digest in the hash) and break verification.

This is low-risk given you control the file, but consider adding a comment or a stricter check:

Optional: Add a warning or support alternate formats
     // Zero the digest field in blueprint.yaml to avoid circular dependency
+    // NOTE: assumes digest is double-quoted; update regex if YAML style changes
     if (file === "blueprint.yaml") {
       content = Buffer.from(
         content.toString("utf-8").replace(/^digest:\s*"[^"]*"/m, 'digest: ""')
       );
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bin/lib/verify-digest.js` around lines 48 - 51, The current replacement in
the blueprint handling (inside the if (file === "blueprint.yaml") block) uses a
regex in content.toString(...).replace(...) that only matches a double-quoted
digest value; update that replacement to match single-quoted, double-quoted, or
unquoted digest values (e.g., expand the regex used in the replace call to
accept ' or " or bare tokens) so the digest is reliably zeroed regardless of
quoting, and optionally add a short comment or console.warn when a non-standard
format is encountered to surface unexpected YAML styles.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/verify-digest.test.js`:
- Around line 26-30: The test's assertion assumes readExpectedDigest() always
returns a string but the implementation may return null; update the test in
verify-digest.test.js to accept either a string or null by asserting that
readExpectedDigest() returns null OR typeof result === "string" (referencing the
readExpectedDigest function used in the test) so the test passes when the digest
field is missing or present.

---

Nitpick comments:
In `@bin/lib/verify-digest.js`:
- Line 24: Replace the use of fs.statSync(fullPath) with fs.lstatSync(fullPath)
in verify-digest.js and explicitly reject symlinks: call const stat =
fs.lstatSync(fullPath) and if (stat.isSymbolicLink()) throw an error (or skip
with a clear failure message) so the digest routine (where fullPath is
inspected) cannot follow symlinks and include files outside the blueprint
directory.
- Around line 48-51: The current replacement in the blueprint handling (inside
the if (file === "blueprint.yaml") block) uses a regex in
content.toString(...).replace(...) that only matches a double-quoted digest
value; update that replacement to match single-quoted, double-quoted, or
unquoted digest values (e.g., expand the regex used in the replace call to
accept ' or " or bare tokens) so the digest is reliably zeroed regardless of
quoting, and optionally add a short comment or console.warn when a non-standard
format is encountered to surface unexpected YAML styles.

In `@test/verify-digest.test.js`:
- Around line 32-42: The test currently guards execution with if (!expected ||
expected.trim() === ""), which lets it skip assertions when a digest exists;
change the test to unconditionally exercise the fail-closed path by forcing an
empty digest: either stub/mock readExpectedDigest to return "" (so
require(verifyDigestPath).readExpectedDigest returns "") or call
verifyBlueprintDigest with an explicit empty/undefined digest to assert
result.valid is false and result.reason matches /missing or empty/; update the
test around verifyBlueprintDigest and readExpectedDigest to ensure the
assertions always run.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: adbc404d-8eeb-488f-b5c3-d6d13119d34d

📥 Commits

Reviewing files that changed from the base of the PR and between 166319d and 1442645.

📒 Files selected for processing (3)
  • bin/lib/verify-digest.js
  • nemoclaw-blueprint/blueprint.yaml
  • test/verify-digest.test.js

readExpectedDigest() returns null when the digest field is absent from
blueprint.yaml. Update assertion to accept both string and null.

Addresses CodeRabbit review feedback.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/verify-digest.test.js (1)

32-42: Conditional test becomes a no-op when digest is populated.

This test only runs assertions when the digest field is empty or missing. Once blueprint.yaml is updated with a valid digest before release, the if branch is skipped and the test passes vacuously without asserting anything.

Consider mocking readExpectedDigest to return an empty string explicitly, or create a test fixture with a known empty digest to ensure this fail-closed behavior is always tested.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/verify-digest.test.js` around lines 32 - 42, The test currently only
asserts when the real readExpectedDigest() returns empty, making it a no-op if
blueprint.yaml has a digest; change the test to stub/mock readExpectedDigest to
return an empty string deterministically so verifyBlueprintDigest() is
exercised: import or require verifyBlueprintDigest and mock readExpectedDigest
(or inject a fixture) to return "" before calling verifyBlueprintDigest(), then
assert result.valid is false and result.reason matches /missing or empty/ to
ensure the fail-closed behavior is always tested.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/verify-digest.test.js`:
- Around line 51-58: The test depends on the real blueprint.yaml and will break
if that file contains a valid digest; change the test to explicitly control the
expected-digest source by stubbing/mocking readExpectedDigest (or injecting a
mock) so it returns an empty string for this regression scenario. Locate the
call site where verifyBlueprintDigest is required (verifyBlueprintDigest in
test/verify-digest.test.js using verifyDigestPath) and replace the module
dependency with a mock that makes readExpectedDigest return "" (or provide a
mocked function via dependency injection) before calling verifyBlueprintDigest,
then assert result.valid === false.

---

Nitpick comments:
In `@test/verify-digest.test.js`:
- Around line 32-42: The test currently only asserts when the real
readExpectedDigest() returns empty, making it a no-op if blueprint.yaml has a
digest; change the test to stub/mock readExpectedDigest to return an empty
string deterministically so verifyBlueprintDigest() is exercised: import or
require verifyBlueprintDigest and mock readExpectedDigest (or inject a fixture)
to return "" before calling verifyBlueprintDigest(), then assert result.valid is
false and result.reason matches /missing or empty/ to ensure the fail-closed
behavior is always tested.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9342e6c9-cb85-48db-869d-7fe97b169887

📥 Commits

Reviewing files that changed from the base of the PR and between 1442645 and ac88d50.

📒 Files selected for processing (1)
  • test/verify-digest.test.js

Comment on lines +51 to +58
it("empty string digest must not pass verification", () => {
// Regression: if (digest && digest !== actual) was the original bug —
// empty string is falsy in JS, causing silent bypass
const { verifyBlueprintDigest } = require(verifyDigestPath);
const result = verifyBlueprintDigest();
// With digest: "" in blueprint.yaml, this MUST fail
expect(result.valid).toBe(false);
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Test will fail once blueprint.yaml contains a valid digest.

This test unconditionally expects valid: false, but when the digest field is populated with the correct SHA-256 value before release, verifyBlueprintDigest() will return valid: true and this test will break.

For a regression test specifically targeting the empty-string bypass (CWE-345), consider mocking or stubbing readExpectedDigest to return "" explicitly, decoupling the test from the actual file state.

Proposed fix using dependency injection or mocking
   it("empty string digest must not pass verification", () => {
     // Regression: if (digest && digest !== actual) was the original bug —
     // empty string is falsy in JS, causing silent bypass
-    const { verifyBlueprintDigest } = require(verifyDigestPath);
-    const result = verifyBlueprintDigest();
-    // With digest: "" in blueprint.yaml, this MUST fail
-    expect(result.valid).toBe(false);
+    const verifyDigest = require(verifyDigestPath);
+    // Mock readExpectedDigest to simulate the empty-digest regression scenario
+    const originalRead = verifyDigest.readExpectedDigest;
+    verifyDigest.readExpectedDigest = () => "";
+    try {
+      const result = verifyDigest.verifyBlueprintDigest();
+      // Empty string digest MUST fail (fail-closed)
+      expect(result.valid).toBe(false);
+      expect(result.reason).toMatch(/missing or empty/);
+    } finally {
+      verifyDigest.readExpectedDigest = originalRead;
+    }
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/verify-digest.test.js` around lines 51 - 58, The test depends on the
real blueprint.yaml and will break if that file contains a valid digest; change
the test to explicitly control the expected-digest source by stubbing/mocking
readExpectedDigest (or injecting a mock) so it returns an empty string for this
regression scenario. Locate the call site where verifyBlueprintDigest is
required (verifyBlueprintDigest in test/verify-digest.test.js using
verifyDigestPath) and replace the module dependency with a mock that makes
readExpectedDigest return "" (or provide a mocked function via dependency
injection) before calling verifyBlueprintDigest, then assert result.valid ===
false.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant