Skip to content

fix(security): symlink rejection + accumulate-all-tampered-files in integrity verifier (#160) — 0.22.2#172

Merged
thebenignhacker merged 2 commits intomainfrom
fix/issue-160-symlink-multifile
May 1, 2026
Merged

fix(security): symlink rejection + accumulate-all-tampered-files in integrity verifier (#160) — 0.22.2#172
thebenignhacker merged 2 commits intomainfrom
fix/issue-160-symlink-multifile

Conversation

@thebenignhacker
Copy link
Copy Markdown
Contributor

Closes #160 follow-ups (b) and (c). Drops (a) baked-HMAC per [CHIEF-CSR]+[CHIEF-CA] decision: SLSA v1 provenance via Trusted Publishing remains the canonical trust root.

Summary

  • (b) Symlink rejection in loadManifest — defends against post-install file-replacement attacks where an attacker swaps the manifest for a symlink pointing at attacker-controlled data. Uses lstatSync (NOT existsSync + readFileSync) at every probed manifest location.
  • (c) Accumulate-all-tampered-files in checkPackageIntegrity — operators now see total count + first 5 paths in lexicographic order instead of first-failure short-circuit. Output capped at 200 chars.
  • Trust root clarification — README + CHANGELOG explicitly point users at npm view hackmyagent dist.attestations --json as the canonical SLSA v1 trust-root verification.
  • Sigstore/cosign keyless filed as deferred future-Wave (Future Wave: sigstore/cosign keyless verifier-side trust root (deferred) #171, closed wontfix).

This PR also folds in the previously-published 0.22.1 commit (@opena2a/credential-patterns@0.1.1 re-pin) which was tagged but not yet merged to main.

Validation (load-bearing end-to-end gate)

$ cp dist/cli.js /tmp/x; echo>>dist/cli.js; node dist/cli.js --version; echo "EXIT=$?"
INTEGRITY CHECK FAILED: HackMyAgent binary may have been tampered with.
...
  Failed: package_integrity -- 1 files tampered: cli.js
EXIT=3
$ rm dist/.integrity-manifest.json; ln -s /tmp/copy dist/.integrity-manifest.json; node dist/cli.js --version
INTEGRITY MANIFEST REJECTED: symlink at /Users/.../dist/.integrity-manifest.json (manifests must be regular files)
hackmyagent 0.22.2
EXIT=0  (dev-mode CLEAN by design — symlink does not silently grant a free pass)

Tests

  • 18 new deterministic CI tests across 3 files (symlink-rejection × 7, multifile-tamper × 9, end-to-end-tamper × 2)
  • 1 existing assertion updated for new " files tampered" format
  • Total: 2072/2098 pass (164 files passing)
  • Self-scan: 98/100 unchanged

Test plan

  • npm run build passes
  • npm test 2072/2098 pass
  • Manual tamper test: EXIT=3 with new format
  • Manual symlink test: stderr warning + EXIT=0 (dev-mode CLEAN)
  • Standard CLI commands work (--version, --help, secure ., scan-soul test/, check express, explain AST-PROMPT-001)
  • HMA self-scan: 98/100 unchanged

…low-up)

0.1.1 brings three false-positive suppressions surfaced by `secretless-ai
status` dogfooding inside this repo on 2026-04-29:

- Block-comment marker recognition in isKnownExample: /*, <!--, -->, ''',
  """, JSDoc-continuation lines (^\s*\*) join // and #.
- Localhost+demo-password DB connection allowlist with anchored
  localhost / 127.0.0.1 / [::1] host check (Phase 4.5 case-insensitive
  password match — Password123 no longer slips, IPv6 loopback recognized).
- Bare 'fake' in PLACEHOLDER_INDICATORS (replaces 'fake_' / 'fake-' —
  catches sk-proj-fake1234567890abc... shape values).

hackmyagent's CredVaultPlugin catalog at src/plugins/credvault.ts is
unchanged. The 10-entry local subset stays the synchronous CJS
detection source; the 0.1.1 isKnownExample additions live in the
package and apply only when consumers call the package helpers
directly (secretless-ai 0.16.4 does this).

Lockstep test __tests__/plugins/credvault/lockstep.test.ts re-runs
against 0.1.1 and stays green: every local pattern's regex.source +
regex.flags continues to match exactly one entry in the package (the
0.1.1 diff was additive; it did not narrow any of the 56 regexes).

Zero behavior change in runtime detection path. Self-scan: 89 → 89.
Tests: 2054/2080 pass.

Bumps: hackmyagent 0.22.0 → 0.22.1; @opena2a/credential-patterns
0.1.0 → 0.1.1 (exact pin per OpenA2A convention).
…ntegrity verifier (#160)

(b) loadManifest now uses lstatSync at every probed manifest location and
rejects any path that resolves to a symbolic link. Defends against post-
install file-replacement attacks where an attacker swaps the manifest for
a symlink pointing at attacker-controlled data. On rejection, writes
INTEGRITY MANIFEST REJECTED: symlink at <path> to stderr and falls
through to dev-mode CLEAN with explicit reason "Manifest rejected
(symlink) -- dev mode" on the manifest_load check.

(c) checkPackageIntegrity now accumulates all hash mismatches into a
sorted list and emits "<count> files tampered: <first 5>, ...and N more"
instead of short-circuiting on the first failure. Output is capped at
200 chars to defeat attacker log-flooding. Missing files still short-
circuit (a missing file can hide further tamper, so operators need to
know about it first).

Drops the previously-discussed (a) baked-in signing key design. SLSA v1
provenance via npm Trusted Publishing is the canonical trust root for
rebuild attacks; adding a long-lived MANIFEST_PRIVATE_KEY GHA secret
next to OIDC weakens the supply chain rather than strengthens it.
README and CHANGELOG updated with the explicit
`npm view hackmyagent dist.attestations --json` verification path.
Sigstore/cosign keyless filed as deferred future-Wave (#171).

18 new deterministic CI tests:
- integrity-verifier-symlink-rejection.test.ts (7 cases)
- integrity-verifier-multifile-tamper.test.ts (9 cases)
- integrity-verifier-end-to-end-tamper.test.ts (2 cases, the load-
  bearing gate that copies dist/ to a tmpdir, tampers cli.js, runs
  node dist/cli.js --version, and asserts EXIT=3 + INTEGRITY CHECK
  FAILED)

Tests: 2072/2098 pass. Self-scan: 98/100 unchanged.
Manual smoke: cp dist/cli.js /tmp/x; echo>>dist/cli.js;
  node dist/cli.js --version; echo "EXIT=$?"
  -> EXIT=3 with stderr "Failed: package_integrity --
     1 files tampered: cli.js"
@thebenignhacker thebenignhacker enabled auto-merge (squash) May 1, 2026 17:50
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

ML-DSA-44 benchmark (ubuntu-latest, c6i.xlarge-class)

env: linux/x64 AMD EPYC 7763 64-Core Processor (4 CPU) node=v20.20.2 noble=0.2.1
load avg: 1.29, 0.39, 0.14
ml-dsa-44-sign     p50=5.790ms p95=6.201ms p99=8.271ms max=8.838ms
ml-dsa-44-verify   p50=1.755ms p95=1.818ms p99=2.146ms max=2.455ms
budget: sign p99 OVER 2.5ms, verify p99 OVER 1.5ms
__BENCH_JSON__{"env":{"platform":"linux/x64","cpu":"AMD EPYC 7763 64-Core Processor","cpuCount":4,"node":"v20.20.2","totalMemGB":16,"nobleVersion":"0.2.1","loadAvg":[1.29,0.39,0.14]},"budget":{"sign":2.5,"verify":1.5},"sign":{"op":"ml-dsa-44-sign","p50":5.789527999999791,"p95":6.200796000000082,"p99":8.271144000000277,"max":8.837633000000096,"iters":1000},"verify":{"op":"ml-dsa-44-verify","p50":1.7545159999990574,"p95":1.818148000000292,"p99":2.1457100000006903,"max":2.4545979999993506,"iters":1000},"signOver":true,"verifyOver":true}

Budget (AIComply D17): sign p99 < 2.5ms, verify p99 < 1.5ms.
Runner is shared; a single over-budget run is noisy. Check for consistent regression across PR commits.

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

Security Review: PR #172

VERDICT: APPROVE

SUMMARY

This PR implements symlink rejection in loadManifest and accumulates all tampered files in checkPackageIntegrity as documented security hardening measures. I verified each change against the full source files for potential vulnerabilities. The symlink rejection uses lstatSync correctly (does not follow links), error handling includes explicit stderr output (avoiding silent failures), and the multi-file tamper accumulation has a 200-char hard cap to prevent log flooding. All mitigations are present in the implementation. The changes are test-covered with 18 new deterministic tests including an end-to-end tamper gate. No CRITICAL or HIGH security issues remain after verification.

FINDINGS

None — all potential issues have adequate mitigations in the same code:

Verified and cleared:

  1. Symlink traversal risk (integrity-verifier.ts:131-158) — Uses lstatSync NOT stat, so symlinks are never followed. Broken symlinks correctly rejected via stat.isSymbolicLink() check before any target resolution.

  2. Path traversal in manifest loading (integrity-verifier.ts:125-127) — Candidate paths use join(packageRoot, 'dist', MANIFEST_FILENAME) with constants, not user input. No dynamic path construction from external data.

  3. Unhandled exceptions in lstat (integrity-verifier.ts:135-149) — Wrapped in try/catch with explicit ENOENT handling and stderr output for non-ENOENT errors. No silent swallowing (checked against feedback_iife_after_parseasync_timing_trap.md requirement).

  4. Array sort stability (integrity-verifier.ts:428) — Uses default Array.prototype.sort() on strings which is deterministic (16-bit code-unit comparison per ECMAScript spec). Comment explicitly documents this (line 425-426).

  5. String slice out-of-bounds (integrity-verifier.ts:443-448) — All slice operations are guarded by budget calculations and capped at HARD_CAP. No negative indices possible (budget checked <= 0 before use).

  6. Race condition in module-scoped flag (integrity-verifier.ts:182, 592) — Single-threaded synchronous flow: loadManifest sets flag → verifyAll reads + clears flag. Comment at line 589 explicitly documents "synchronous call ordering ensures no race."


Reviewed 9 files changed (48432 bytes)

@thebenignhacker thebenignhacker merged commit e25ae70 into main May 1, 2026
4 checks passed
@thebenignhacker thebenignhacker deleted the fix/issue-160-symlink-multifile branch May 1, 2026 17:51
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.

Integrity verifier hardening: symlink rejection (b) + multi-file tamper accumulation (c)

1 participant