Skip to content

Fix macOS/BSD grep compatibility in security-suite hooks - #4

Open
nikolasdehor wants to merge 1 commit into
FlorianBruniaux:mainfrom
nikolasdehor:fix/macos-bsd-compatibility
Open

Fix macOS/BSD grep compatibility in security-suite hooks#4
nikolasdehor wants to merge 1 commit into
FlorianBruniaux:mainfrom
nikolasdehor:fix/macos-bsd-compatibility

Conversation

@nikolasdehor

Copy link
Copy Markdown

Summary

Several security-suite hooks silently stop enforcing their checks on macOS because they rely on GNU-only shell constructs that BSD grep and bash 3.2 (both macOS defaults) don't support. None of these failures are loud: the affected scripts either print an error to stderr while still exiting 0, or bail out early, so the security check simply never runs and the hook reports "all clear" regardless of the actual input.

Verified against the real macOS default environment: bash --version reports 3.2.57, and grep --version reports BSD grep (no GNU extensions). All fixes below were tested there, both positive (pattern present, detected) and negative (pattern absent, not flagged) cases.

Bug 1: grep -P is not available on BSD grep

Breaks on: macOS (any hook invocation), any other BSD-grep system.

grep -P enables PCRE syntax, which is a GNU grep extension. BSD grep has no -P flag at all and fails immediately with grep: invalid option -- P, printing a usage message to stderr on every single hook call. This affects:

  • prompt-injection-detector.sh (null-byte check)
  • unicode-injection-scanner.sh (7 call sites: zero-width chars, bidi override, null byte, tag characters, overlong UTF-8, 2x homoglyph check)
  • claudemd-scanner.sh (non-ASCII-near-keyword check)

Fix: Unicode codepoint-range patterns (e.g. [\x{200B}-\x{200D}\x{FEFF}]) are ported to perl -C -0777 -ne 'exit(/PATTERN/ ? 0 : 1)'. Perl ships by default on both macOS and Linux and accepts the exact same \x{...} codepoint-range syntax, so the regex bodies are unchanged. The overlong-UTF-8 byte check (which matches raw byte values, not decoded codepoints) uses perl without -C, since -C would decode input as UTF-8 first and change what a byte-range like \xC0-\xC1 matches.

Bug 2: the null-byte check can never work, on any system

Breaks on: every system, GNU or BSD. This is not macOS-specific.

grep -qP '\x00' (or any grep flavor's equivalent) tests a bash variable ($CONTENT) for a null byte. But bash cannot retain a real \0 inside a variable at all: it's silently dropped when the variable is assigned through command substitution (VAR=$(...)). By the time $CONTENT is checked, the byte is already gone, so this check has likely never actually fired, regardless of grep flavor or OS.

Fix: replaced the variable-based check with a stream-size comparison that never materializes the value into a bash variable: pipe the same jq filter through wc -c, then again through LC_ALL=C tr -d '\000' | wc -c, and compare the two counts. A mismatch means a null byte was present in the stream.

Bug 3: declare -A requires bash 4+, and macOS ships bash 3.2

Breaks on: macOS (default /bin/bash is 3.2.57, from 2007; associative arrays landed in bash 4.0, 2009).

output-secrets-scanner.sh and pre-commit-secrets.sh use declare -A to map secret names to regex patterns. On bash 3.2 this fails at the declare -A line itself (declare: -A: invalid option / similar), taking down the whole hook before any secret scanning happens.

Fix: converted each associative array into two parallel indexed arrays (SECRET_NAMES / SECRET_REGEXES, and PATTERN_NAMES / PATTERN_REGEXES), iterated by shared numeric index. Indexed arrays have existed since very early bash and work identically on 3.2 and 5.x.

Bug 4 (most critical, also not macOS-specific): patterns starting with - are parsed as grep options

Breaks on: every system, GNU or BSD, whenever a pattern happens to start with -.

This one was hiding behind Bug 3: once declare -A is fixed and the scripts actually run to completion, several regex patterns fail again for a different reason. The "Private Key" pattern is -----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----. When that string is interpolated unquoted into grep -qiE "$pattern", grep parses the leading -----BEGIN... as an unrecognized command-line flag instead of a search pattern, and errors out instead of matching:

grep: unrecognized option `-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----'

The practical impact: private key detection in output-secrets-scanner.sh and pre-commit-secrets.sh did not work, on any platform, for as long as this pattern has existed. A staged file containing an actual RSA/EC/OpenSSH private key would pass the pre-commit hook silently.

Fix: added -e before the interpolated pattern in every grep call that takes a pattern from a variable (grep -qiE -e "$pattern" / grep -noE -e "$pattern"). -e tells grep unambiguously "this is a pattern, not an option," regardless of what it starts with. Applied this to all four call sites in the codebase that build a grep pattern from a loop variable, including two (dangerous-actions-blocker.sh, repo-integrity-scanner.sh) where no current pattern starts with -, as preventive hardening so the next added pattern doesn't reintroduce the same silent failure.

How to reproduce (before this fix)

On macOS, with a plugin checkout:

echo '{"tool_name":"Write","tool_input":{"content":"any text"}}' \
  | bash plugins/security-suite/hooks/bash/unicode-injection-scanner.sh
# -> "grep: invalid option -- P" on stderr, hook still exits 0

echo '{"tool_output":"anything"}' \
  | bash plugins/security-suite/hooks/bash/output-secrets-scanner.sh
# -> "declare: -A: invalid option" (bash 3.2), hook aborts

# stage a fake private key and try the pre-commit hook:
printf -- '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----\n' > k.pem
git add k.pem
bash plugins/security-suite/hooks/bash/pre-commit-secrets.sh
# -> "grep: unrecognized option" printed, commit is NOT blocked

How to verify (after this fix)

# Unicode / null byte / overlong UTF-8 checks now fire correctly:
printf '{"tool_name":"Write","tool_input":{"content":"abc\\u0000def"}}' \
  | bash plugins/security-suite/hooks/bash/unicode-injection-scanner.sh
echo $?   # 2 (blocked), with a "Null byte detected" message

# Private key detection now actually blocks the commit:
printf -- '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----\n' > k.pem
git add k.pem
bash plugins/security-suite/hooks/bash/pre-commit-secrets.sh
echo $?   # 1 (commit blocked), reports "Private Key" as the match, no grep error

# Legitimate content still passes without false positives:
printf '{"tool_name":"Write","tool_input":{"content":"regular text, no injection here"}}' \
  | bash plugins/security-suite/hooks/bash/unicode-injection-scanner.sh
echo $?   # 0

All example values above (AKIA..., the RSA key block, etc.) are placeholders, not real credentials.

Test plan

  • bash -n syntax check on all 7 modified files
  • Positive/negative cases for every changed check, run against macOS's actual default /bin/bash (3.2.57) and BSD grep
  • Regression check: pre-existing checks unrelated to this fix (role-override patterns, jailbreak patterns, nested-command-execution patterns) still fire correctly
  • No behavior change to any pattern's matching logic, only to how patterns are invoked/stored

Several security-suite hooks silently stopped enforcing their checks on
macOS because they rely on GNU-only shell constructs. BSD grep (the
default on macOS) has no -P (PCRE) flag, and bash 3.2 (the macOS default
shell) has no associative arrays. Neither failure mode is loud: the
scripts either print an error to stderr while still exiting 0, or bail
out early, so the checks pass without ever actually running.

- grep -P is not available in BSD grep and fails with "invalid option --
  P" on every invocation (prompt-injection-detector.sh, and 7 call
  sites in unicode-injection-scanner.sh, plus claudemd-scanner.sh).
  Unicode codepoint ranges are ported to `perl -C -0777 -ne`, available
  on both macOS and Linux, preserving the same codepoint-range syntax.
  The overlong-UTF-8 byte check keeps operating on raw bytes (perl
  without -C, since -C would decode input as UTF-8 first and change
  what a raw byte value matches).

- The null-byte check (grep -qP against a null byte) can never work on
  any system, GNU or BSD: bash silently drops a null byte from a
  variable on assignment through command substitution, so the variable
  never contains the byte by the time it's checked. Replaced with a
  stream-size comparison (wc -c with and without stripping null bytes
  via tr) that never materializes the value into a bash variable.

- declare -A (associative arrays) requires bash 4+; bash 3.2, the
  default on macOS, doesn't have it and fails immediately on the first
  hook invocation (output-secrets-scanner.sh, pre-commit-secrets.sh).
  Converted to two parallel indexed arrays (names and regexes, same
  index) instead of a single map.

- Most critical bug, and not macOS-specific: several regex patterns
  start with "-----" (e.g. the Private Key pattern). Without -e, grep
  parses that as an unrecognized option instead of a search pattern and
  errors out, so private key detection never actually ran, on GNU or
  BSD grep. Fixed by passing patterns through -e in all four call sites
  that interpolate a variable into grep, including two files where no
  current pattern happens to start with a dash
  (dangerous-actions-blocker.sh, repo-integrity-scanner.sh) but the
  same failure mode would apply the moment one does.

All fixes were tested against the real macOS default shell (bash
3.2.57) and BSD grep, with positive and negative cases for each check.
Copilot AI review requested due to automatic review settings July 29, 2026 19:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

2 participants