Skip to content

hook-utils: shared helpers degrade silently to permissive answers (fail-open defaults in the SSOT lib) #1487

Description

@kyle-sexton

Problem

hook-utils.sh helpers degrade silently to a permissive answer when their prerequisite is missing. That default is fine for the advisory/formatting hooks the helpers were written for, and wrong for the security guards that now consume them: a guard that cannot determine the truth must not proceed as if it had.

This is fleet-wide. hook-utils.sh is SSOT-synced by scripts/sync-hook-utils.sh from lib/hook-utils.sh, so every carrying plugin inherits the behavior.

Two instances, found independently by two different agents on two different PRs. That is the reason to file the pattern rather than either instance.

Instance 1 — path helpers fall back to the unresolved input

hook::physical_path returns its input unchanged when neither realpath nor readlink -f exists:

hook::physical_path() {
  local resolved
  if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then
    if [[ -n "$resolved" ]]; then printf '%s' "$resolved"; return; fi
  fi
  printf '%s' "$1"        # <- lexical path, silently
}

hook::repo_root does the same, falling back to the hint when git rev-parse --show-toplevel cannot answer.

Consequence found on #1097: a guard leaning on hook::physical_path for a containment check compared the lexical path where no canonicalizer existed, so an escaping symlink read as in-repository and was pinned by its lexical path.

Consequence on #1085 (this PR): block-noncanonical-commit needed "which repository will this command run in", where an unknown answer must block. Because both helpers report success-with-a-guess, the guard cannot distinguish "resolved" from "fell back", so neither helper was usable and the guard grew its own resolution with visible failure. Declining the shared helpers was the right call, but it means the lib is now bypassed by exactly the consumers with the strictest needs.

Instance 2 — the tokenizer does not honor # as a shell comment

hook::bash_parse_segments has no # handling, so words a shell would discard survive into the parsed argv.

Concrete chain, traced on #1085 with alias.b = 'commit --allow-empty -m bypass' persisted in the current repo:

$ git -c alias.a='!git b #' a -C <other-repo>

reparse handed to the tokenizer:  git b # -C <other-repo>
tokenized argv:                   [git, b, #, -C, <other-repo>]     <- '#' kept as a word
after alias splice:               git commit --allow-empty -m bypass '#' -C <other-repo>
commit-argument scan:             sees `-C`  ->  reads it as --reuse-message  ->  exempt=1
verdict:                          exit 0 (allowed)

real git: hands the body to a shell, which drops everything from the '#',
          so git runs `git b` only and the non-canonical commit LANDS.

git appends the invocation's trailing words to a ! body, and the shell then discards them at the #. The guard keeps them, and a leftover -C grants a bogus --reuse-message exemption. Note the exemption is the fail-open: the alias is correctly resolved and the commit -m is reached, then the verdict is thrown away.

No guard-local workaround is appropriate — deciding whether a given # is quoted is the tokenizer's job, and approximating shell comment semantics inside one guard is the modelling-instead-of-asking mistake that produced several earlier bypasses in this same file.

Why one issue

Both instances are the same design decision: a shared helper answering "I could not determine this" as a usable value instead of a distinguishable failure. Fixing them one at a time invites the next consumer to hit the third instance. What is worth deciding is the lib-wide contract.

Fix direction

  • Make undeterminable distinguishable. Give the affected helpers a failure signal a caller can branch on — a non-zero return, or an out-param plus empty result — so a guard can fail closed while an advisory hook keeps its current permissive behavior by ignoring it. Preserving today's behavior for existing callers matters: these are consumed fleet-wide.
  • Document the contract per helper, so a future consumer does not have to read the implementation to learn that success does not mean resolved.
  • hook::bash_parse_segments: honor # as a comment where a shell would, with the quoting rules the tokenizer already implements for ', ", and $'…'.
  • Sequencing note: any change here forces a version bump across every carrying plugin via sync-hook-utils.sh --check-bump, so it wants to be a deliberate lib change rather than a rider on a feature PR.

Related

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: securitySecurity-relevant: vulnerability, hardening, or disclosure follow-up.needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.priority: highSignificant impact, or blocks an imminent release; staff this cycle.

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions