Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 91 additions & 5 deletions scripts/hooks/block-no-verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

'use strict';

const { quotedRegionAt } = require('../lib/shell-quotes');

const MAX_STDIN = 1024 * 1024;
let raw = '';

Expand All @@ -35,6 +37,52 @@ const GIT_COMMANDS_WITH_NO_VERIFY = [
*/
const VALID_BEFORE_GIT = ' \t\n\r;&|$`(<{!"\']/.~\\';

/**
* Programs that execute a quoted argument as a shell command line, so a
* `git` inside one of their quoted arguments is a command that must still
* be checked (`sh -c "git commit --no-verify"`, `sudo`, `xargs`, `env`...).
* For any other argv0 (`node cli.js 'git commit --no-verify'`,
* `printf '%s' '...'`, `python3 -c "..."`) a quoted string is data.
*/
const COMMAND_WRAPPERS = new Set([
'sh',
'bash',
'zsh',
'dash',
'ksh',
'fish',
'busybox',
'eval',
'exec',
'command',
'xargs',
'sudo',
'doas',
'su',
'env',
'nice',
'ionice',
'nohup',
'timeout',
'time',
'watch',
'flock',
'ssh',
'script',
'csh',
'tcsh',
'setsid',
'stdbuf',
'taskset',
'chrt',
'unshare',
'chroot',
'runuser',
'npx',
'bunx',
'pnpx',
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Git config section and variable names are case-insensitive
// (subsection names are case-sensitive but core.hooksPath has none),
// so we normalize the candidate token to lowercase before matching.
Expand Down Expand Up @@ -149,7 +197,11 @@ function tokenizeShellWords(input, start = 0, end = input.length) {
continue;
}

if (/\s/.test(char)) {
// Whitespace ends a word; so do the unquoted substitution delimiters,
// which can never be part of a word (`echo "$(git push --no-verify)"`,
// "`git push --no-verify`" used to yield the token `--no-verify)` /
// `--no-verify\``, hiding the flag).
if (/[\s`()]/.test(char)) {
pushToken(i);
continue;
}
Expand Down Expand Up @@ -289,6 +341,32 @@ function isInComment(input, idx) {
return false;
}

/**
* Strip a leading path and a trailing `.exe` from a command word.
*/
function commandBasename(word) {
return String(word || '')
.replace(/^.*[\\/]/, '')
.replace(/\.exe$/i, '')
.toLowerCase();
}

/**
* A `git` inside a quoted string is only a command when that string is
* handed to something that executes it. Otherwise it is an argument of an
* unrelated program (a CLI under test, printf, python -c, ...) and must not
* be inspected for bypass flags. A double-quoted string that contains a
* command substitution (`"$(git ...)"`, "`git ...`") runs git before any
* program receives it, so it is never data.
*/
function isQuotedDataArgument(input, idx) {
const region = quotedRegionAt(input, idx);
if (region === null || region.argv0 === '') return false;
if (region.substitution) return false;
const base = commandBasename(region.argv0);
return base !== 'git' && !COMMAND_WRAPPERS.has(base);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/**
* Find the next 'git' token in the input starting from a position.
*/
Expand All @@ -307,7 +385,9 @@ function findGit(input, start) {
}

const before = idx > 0 ? input[idx - 1] : ' ';
if (VALID_BEFORE_GIT.includes(before)) return { idx, len };
if (VALID_BEFORE_GIT.includes(before) && !isQuotedDataArgument(input, idx)) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return { idx, len };
}
pos = idx + 1;
}
return null;
Expand Down Expand Up @@ -409,8 +489,8 @@ function isNoVerifyLongFlag(value) {
* right after the detected subcommand keyword) so that flags belonging to
* earlier commands in a chain are not falsely matched.
*/
function hasNoVerifyFlag(input, command, offset) {
const segmentEnd = findCommandSegmentEnd(input, offset);
function hasNoVerifyFlag(input, command, offset, limit = input.length) {
const segmentEnd = Math.min(findCommandSegmentEnd(input, offset), limit);
const tokens = tokenizeShellWords(input, offset, segmentEnd);
let skipNext = false;

Expand Down Expand Up @@ -497,7 +577,13 @@ function checkCommand(input) {
};
}

if (hasNoVerifyFlag(input, gitCommand, offset)) {
// A git command line inside a quoted string (`sh -c 'git push ...'`)
// ends with that string: scanning past the closing quote would read the
// rest of the outer statement in the wrong quote state, so `sh -c 'git
// push --no-verify'; echo done` glued `; echo done` onto the flag token.
const region = quotedRegionAt(input, detected.gitStart);
const limit = region === null ? input.length : region.end;
if (hasNoVerifyFlag(input, gitCommand, offset, limit)) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return {
blocked: true,
reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
Expand Down
210 changes: 210 additions & 0 deletions scripts/lib/shell-quotes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
'use strict';

/**
* Quoted-region scanner for shell command lines.
*
* One forward pass records every quoted region: where it starts and ends, the
* argv0 of the statement containing it (the first non-assignment, non-reserved
* word before the quote; '' when the quote is part of that first word) and
* whether the string carries a command substitution. A `$(` or backtick inside
* "..." suspends the string: the substitution body is top-level shell again
* (its own statements and quotes) until the matching `)` / backtick, after
* which the string resumes as a new region. Both halves are flagged
* `substitution`. Regions are appended only once complete, in order, so they
* are disjoint and sorted. The result is cached per input, so a command line
* holding thousands of quoted tokens is scanned once.
*/

// Words that open or structure a compound command; none of them receives the
// quoted string that follows, so none may become a statement's argv0.
const SHELL_RESERVED_WORDS = new Set([
'if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', 'for',
'case', 'esac', 'in', 'select', 'function', 'coproc', '!', '{', '}', '[[', ']]',
]);

const ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;

// Unquoted characters that start a new statement (or a nested command).
const STATEMENT_SEPARATORS = new Set([';', '|', '&', '\n', '(', ')', '`']);

let cache = { input: null, regions: [] };

function createState() {
return {
regions: [],
suspended: [],
quote: null,
escaped: false,
open: null,
argv0: null,
word: '',
inWord: false,
};
}

function endWord(state) {
if (!state.inWord) return;
if (
state.argv0 === null &&
state.word !== '' &&
!SHELL_RESERVED_WORDS.has(state.word) &&
!ASSIGNMENT_WORD.test(state.word)
) {
state.argv0 = state.word;
}
state.word = '';
state.inWord = false;
}

function newStatement(state) {
endWord(state);
state.argv0 = null;
}

function openRegion(state, start, quote, argv0, substitution) {
state.quote = quote;
state.open = { start, quote, argv0, substitution };
}

function closeRegion(state, end, substitution) {
const region = state.open;
state.regions.push({ ...region, end, substitution: region.substitution || substitution });
state.open = null;
state.quote = null;
}

/**
* `$(` or a backtick inside "...": park the outer statement's word state (the
* outer word `FOO="pre$(cmd)post"` continues after the substitution as if it
* were a single character) and scan the body as a fresh statement. Returns
* the number of characters consumed.
*/
function suspendString(state, index, char) {
state.suspended.push({
backtick: char === '`',
depth: 0,
argv0: state.open.argv0,
outer: { word: state.word, inWord: state.inWord, argv0: state.argv0 },
});
closeRegion(state, index, true);
state.word = '';
state.inWord = false;
state.argv0 = null;
return char === '$' ? 2 : 1;
}

function resumeString(state, index, outer) {
state.suspended.pop();
state.word = outer.outer.word;
state.inWord = outer.outer.inWord;
state.argv0 = outer.outer.argv0;
openRegion(state, index, '"', outer.argv0, true);
}

/** A character inside a quoted string. Returns the number of characters consumed. */
function scanQuotedChar(state, input, index) {
const char = input.charAt(index);
if (state.quote === '"' && char === '\\') {
state.escaped = true;
return 1;
}
if (char === state.quote) {
closeRegion(state, index, false);
return 1;
}
if (state.quote === '"' && (char === '`' || (char === '$' && input.charAt(index + 1) === '('))) {
return suspendString(state, index, char);
}
state.word += char;
state.inWord = true;
return 1;
}

/** A character outside quotes. Returns the number of characters consumed. */
function scanBareChar(state, index, char) {
if (char === '\\') {
state.escaped = true;
state.inWord = true;
return 1;
}
if (char === '"' || char === "'") {
state.inWord = true;
openRegion(state, index, char, state.argv0 === null ? '' : state.argv0, false);
return 1;
}
const outer = state.suspended.length > 0 ? state.suspended[state.suspended.length - 1] : null;
if (outer !== null) {
const resumes = outer.backtick ? char === '`' : char === ')' && outer.depth === 0;
if (resumes) {
resumeString(state, index, outer);
return 1;
}
if (!outer.backtick && (char === '(' || char === ')')) {
const depth = outer.depth + (char === '(' ? 1 : -1);
state.suspended = [...state.suspended.slice(0, -1), { ...outer, depth }];
}
}
if (STATEMENT_SEPARATORS.has(char)) {
newStatement(state);
return 1;
}
if (/\s/.test(char)) {
endWord(state);
return 1;
}
state.word += char;
state.inWord = true;
return 1;
}

/**
* Every quoted region of `input`, sorted by start and disjoint.
*
* @param {string} input
* @returns {Array<{start: number, end: number, quote: string, argv0: string, substitution: boolean}>}
*/
function quotedRegions(input) {
if (cache.input === input) return cache.regions;
const state = createState();
for (let i = 0; i < input.length; ) {
const char = input.charAt(i);
if (state.escaped) {
state.escaped = false;
state.word += char;
state.inWord = true;
i += 1;
continue;
}
i += state.quote ? scanQuotedChar(state, input, i) : scanBareChar(state, i, char);
}
if (state.open !== null) state.regions.push({ ...state.open, end: input.length });
cache = { input, regions: state.regions };
return state.regions;
}

/**
* The quoted region that strictly contains `idx`, or null when `idx` is not
* inside a quote. Regions are disjoint and sorted, so this is a binary search.
*
* @param {string} input
* @param {number} idx
*/
function quotedRegionAt(input, idx) {
const regions = quotedRegions(input);
let lo = 0;
let hi = regions.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const region = regions[mid];
if (idx <= region.start) {
hi = mid - 1;
} else if (idx >= region.end) {
lo = mid + 1;
} else {
return region;
}
}
return null;
}

module.exports = { quotedRegions, quotedRegionAt, SHELL_RESERVED_WORDS };
Loading