Skip to content

Instantly share code, notes, and snippets.

@pgorod
Last active June 29, 2026 17:50
Show Gist options
  • Select an option

  • Save pgorod/f58b8c8c9a4707ce1d9465d7dd2a74ca to your computer and use it in GitHub Desktop.

Select an option

Save pgorod/f58b8c8c9a4707ce1d9465d7dd2a74ca to your computer and use it in GitHub Desktop.
Permsplainer explains ownerships and permissions for specific files
#!/bin/bash
#use this to log debug, enclose blocks you want to examine in 'set -x' and 'set +x'
exec 19>lixo.txt # any unused file descriptor number will do
BASH_XTRACEFD=19
# USAGE ./permsplainer.sh FILE USER
# Default values:
[ -z $1 ] && FILE=/var/www/html/suitecrm.log || FILE=`realpath "$1"`
[ -z $2 ] && USER=`whoami` || USER=$2
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
# $1 is Text to echo, $2 is optional color variable name, $3 is optional width in number of chars
cecho(){
COLOR=${2}
TABS=${3}
if [[ ! ${!2} ]]; then { COLOR="NC"; TABS=${#1}; } fi
if [ "${3}" -eq "" ] 2>/dev/null; then { TABS=${#1}; } fi
printf "${!COLOR}%-*s%s${NC}" "$TABS" "${1}"
unset COLOR
unset TABS
}
function red { printf "${RED}$@${NC}"; }
function yellow { printf "${YELLOW}$@${NC}"; }
function green { if [ $2 = true ]; then printf "${GREEN}$1${NC}"; else printf "$1"; fi }
function dirTexts(){
PERMS=$1
CAN=""
if [ "${PERMS:0:1}" = "r" ]; then CAN="${GREEN}Enumerate${NC}"; else CAN="${RED}not Enumerate${NC}"; fi
if [ "${PERMS:1:1}" = "w" ]; then CAN="$CAN, ${GREEN}Create/Delete${NC}"; else CAN="$CAN, ${RED}not Create/Delete${NC}"; fi
if [ ! "${PERMS:2:1}" = "-" ]; then CAN="$CAN, ${GREEN}Traverse${NC}"; else CAN="$CAN, ${RED}not Traverse${NC}"; fi
printf "$CAN"
}
function fileTexts(){
PERMS=$1
CAN=""
if [ "${PERMS:0:1}" = "r" ]; then CAN="${GREEN}Read${NC}"; else CAN="${RED}not Read${NC}"; fi
if [ "${PERMS:1:1}" = "w" ]; then CAN="$CAN, ${GREEN}Write${NC}"; else CAN="$CAN, ${RED}not Write${NC}"; fi
if [ ! "${PERMS:2:1}" = "-" ]; then CAN="$CAN, ${GREEN}Execute${NC}"; else CAN="$CAN, ${RED}not Execute${NC}"; fi
printf "$CAN"
}
function WhichAccess(){
FILEORDIR=$( [ $DIRPERMSTR = "d" ] && echo -n "dirTexts" || echo -n "fileTexts" )
[[ "$OWNER_MATCH" = "GREEN" ]] && # [[ ! "${OWNERPERMSTR:2:1}" = "-" ]] &&
{ WHICH="Owner (${OWNERPERMSTR:0:3}), can $($FILEORDIR ${OWNERPERMSTR:0:3})" ; return; }
[[ "$GROUP_MATCH" = "GREEN" ]] && [[ ! "${GROUPPERMSTR:2:1}" = "-" ]] &&
{ WHICH="Group member (${GROUPPERMSTR:0:3}), can $($FILEORDIR ${GROUPPERMSTR:0:3})"; return; }
[[ ! "${WORLDPERMSTR:2:1}" = "-" ]] && { WHICH="Other (${WORLDPERMSTR:0:3}), can $($FILEORDIR ${WORLDPERMSTR:0:3})"; return; }
false
}
printf "\nPermsplainer, please explain exactly how user ${RED}$USER${NC} can access ${YELLOW}$FILE${NC}!\n\n"
echo '-------------------------------------------------------------------------------------------------------------'
printf "These are dir/file properties, in all levels leading down to the file, with ${GREEN}relevant${NC} accesses for that user:\n"
echo '-------------------------------------------------------------------------------------------------------------'
echo
IFS=/
CUR_FILE=""
for item in $FILE; do # iterate directory parts, separated by the IFS which is '/' in this () subshell
CUR_FILE="$CUR_FILE/$item"
CUR_FILE=${CUR_FILE/\/\//\/} # replace two backslashes with one
TAB=$(( ${#FILE} + 2 )) # length of filename + 2
if [ "$CUR_FILE" = "/" ]; then # for some crazy reason I need a special case to print '/' manually...!
printf "/%-*s" $(( ${#FILE}+1 ))
else
printf "%-*s%s" $TAB $CUR_FILE # equivalent to something like printf '%-50s' $CUR_FILE
fi
# Use -L to get information about the target of a symlink, not the link itself
IFS=' '
EXTRA_TABS=0
INFO=( $(stat -c "%a %A %U %G" "$CUR_FILE") )
PERMS=${INFO[0]} # array separation depends on IFS also, has to be space in this case
PERMS=`printf "%04d" $PERMS` # adjust 755 to 0755
PERMSTR=${INFO[1]} # drwxr-xr-x
OWNER=${INFO[2]} # username such as root
GROUP=${INFO[3]} # groupname such as www-data
DIRPERMS=${PERMS:0:1} # typically 0, 1 or 2 on dirs for setgid etc
OWNERPERMS=${PERMS:1:1} # 0 to 7
GROUPPERMS=${PERMS:2:1} # 0 to 7
WORLDPERMS=${PERMS:3:1} # 0 to 7
DIRPERMSTR=${PERMSTR:0:1} # d
OWNERPERMSTR=${PERMSTR:1:3} # rwx
GROUPPERMSTR=${PERMSTR:4:3} # rwx
WORLDPERMSTR=${PERMSTR:7:3} # rwx
OWNER_MATCH="NC"
GROUP_MATCH="NC"
WORLD_MATCH="NC"
# User owns file?
OWNER_MATCH=$( [[ "$OWNER" = "$USER" ]] && echo -n "GREEN" || echo -n "NC" )
if [ "$OWNER_MATCH" = "NC" ]; then
# User belongs to group?
if id -nGz "$USER" | grep -qzxF "$GROUP"; then
GROUP_MATCH="GREEN"
WORLD_MATCH="NC"
EXTRA_TABS=4
else
WORLD_MATCH="GREEN"
EXTRA_TABS=8
fi
fi
# for dirs:
if [ $DIRPERMSTR = "d" ] && WhichAccess; then ACCESS="Access as $WHICH."; else ACCESS=""; fi
# for files:
if [ $DIRPERMSTR = "-" ] && WhichAccess; then ACCESS="Access as $WHICH."; fi
printf "Perms: $DIRPERMS"
cecho "$OWNERPERMS" $OWNER_MATCH
cecho "$GROUPPERMS" $GROUP_MATCH
cecho "$WORLDPERMS" $WORLD_MATCH
cecho "|$DIRPERMSTR"
cecho "|$OWNERPERMSTR" $OWNER_MATCH
cecho "|$GROUPPERMSTR" $GROUP_MATCH
cecho "|$WORLDPERMSTR|" $WORLD_MATCH
printf ','
cecho " Owner "; cecho $OWNER $OWNER_MATCH 9
cecho " Group "; cecho $GROUP $GROUP_MATCH 9
printf "\n%-*s---" $(($TAB+14+$EXTRA_TABS))
printf "\n%-*s\\ " $(($TAB+16+$EXTRA_TABS))
if [[ $(getfacl -s -p $CUR_FILE) ]]
then
printf "${RED}Has ACL!${NC} Sorry, I don't know how to interpret ACL's..."
else
cecho "$ACCESS" "YELLOW"
fi
printf '\n\n'
done
echo '---------------------------------------------------------------------------------------------------------------------'
printf "Effective permissions, obtained with actual access test, using full path (requires traverse right at all dir levels):\n"
echo '---------------------------------------------------------------------------------------------------------------------'
sudo -u $USER test -r $FILE && printf "User $USER ${GREEN}can read${NC} $FILE\n" || printf "User $USER ${RED}cannot read${NC} $FILE\n"
sudo -u $USER test -w $FILE && printf "User $USER ${GREEN}can write${NC} $FILE\n" || printf "User $USER ${RED}cannot write${NC} $FILE\n"
sudo -u $USER test -x $FILE && printf "User $USER ${GREEN}can execute${NC} $FILE\n" || printf "User $USER ${RED}cannot execute${NC} $FILE\n"
printf '\n'
if [ ! "$1" = "$FILE" ] # check if arg was a relative path, so that realpath used above changed it
then
printf "Since I was given a relative path ($1), I'll try another set of tests from where I am running:\n"
sudo -u $USER test -r $1 && printf "User $USER ${GREEN}can read${NC} $FILE\n" || printf "User $USER ${RED}cannot read${NC} $FILE\n"
sudo -u $USER test -w $1 && printf "User $USER ${GREEN}can write${NC} $FILE\n" || printf "User $USER ${RED}cannot write${NC} $FILE\n"
sudo -u $USER test -x $1 && printf "User $USER ${GREEN}can execute${NC} $FILE\n" || printf "User $USER ${RED}cannot execute${NC} $FILE\n"
printf '\n'
fi
printf '\n'
@pgorod

pgorod commented Jun 29, 2026

Copy link
Copy Markdown
Author

Here's a nice PHP version that Claude just burped out

#!/usr/bin/env php
<?php
declare(strict_types=1);

/**
 * permsplainer.php
 *
 * PHP 8.4 port of permsplainer.sh
 *
 * USAGE: php permsplainer.php FILE USER
 *        ./permsplainer.php FILE USER   (if chmod +x'd, with the shebang above)
 *
 * Explains, level by level, how USER can access FILE, based on owner/group/
 * world permissions, then double-checks with a real access test (via `sudo -u`).
 */


// ---------------------------------------------------------------------------
// Args / defaults
// ---------------------------------------------------------------------------
$arg1 = $argv[1] ?? '';
$arg2 = $argv[2] ?? '';

if ($arg1 === '') {
    $FILE = '/var/www/html/';
} else {
    $resolved = realpath($arg1);
    // realpath() returns false if the path doesn't exist; bash's `realpath`
    // would print an error to stderr and emit nothing, so mimic "best effort"
    // by falling back to the raw argument if it can't be resolved.
    $FILE = $resolved !== false ? $resolved : $arg1;
}

$USER = $arg2 !== '' ? $arg2 : (posix_getpwuid(posix_geteuid())['name'] ?? trim((string) shell_exec('whoami')));

// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
const RED = "\033[0;31m";
const GREEN = "\033[0;32m";
const YELLOW = "\033[0;33m";
const NC = "\033[0m"; // No Color

/** Map of color "names" -> ANSI codes, used wherever bash did indirect
 *  variable expansion like ${!2} or ${!COLOR}. */
function colorCode(string $name): string
{
    return match ($name) {
        'RED' => RED,
        'GREEN' => GREEN,
        'YELLOW' => YELLOW,
        'NC' => NC,
        default => NC, // unknown / empty color name behaves like NC
    };
}

/**
 * cecho($text, $colorName = null, $width = null)
 *
 *   if $colorName isn't a recognized/non-empty color var,
 *   COLOR becomes "NC" and TABS defaults to strlen($text);
 *   if $width is not a valid integer, TABS also defaults to strlen($text).
 */
function cecho(string $text, ?string $colorName = null, int|string|null $width = null): void
{
    $color = $colorName;
    $tabs = $width;

    // just checks "was a color name given at all, and is it one we know?"
    if ($colorName === null || $colorName === '' || colorCode($colorName) === '') {
        $color = 'NC';
        $tabs = mb_strlen($text);
    }

    // if no valid width was given, fall back to strlen($text).
    if ($tabs === null || $tabs === '') {
        $tabs = mb_strlen($text);
    }

    $tabs = (int) $tabs;
    $code = colorCode($color);

    // printf "${!COLOR}%-*s%s${NC}" "$TABS" "${1}"
    $padded = str_pad($text, $tabs);
    echo $code . $padded . NC;
}

function red(string $text): void
{
    echo RED . $text . NC;
}

function yellow(string $text): void
{
    echo YELLOW . $text . NC;
}

function green(string $text): void
{
    echo GREEN . $text . NC;
}

// ---------------------------------------------------------------------------
// Permission-description helpers
// ---------------------------------------------------------------------------

/**
 * dirTexts($perms) - $perms is a 3-char rwx string for one class (owner/group/world)
 */
function dirTexts(string $perms): string
{
    $can = '';
    $can .= ($perms[0] ?? '-') === 'r' ? GREEN . 'Enumerate' . NC : RED . 'not Enumerate' . NC;
    $can .= ', ';
    $can .= ($perms[1] ?? '-') === 'w' ? GREEN . 'Create/Delete' . NC : RED . 'not Create/Delete' . NC;
    $can .= ', ';
    $can .= ($perms[2] ?? '-') !== '-' ? GREEN . 'Traverse' . NC : RED . 'not Traverse' . NC;

    return $can;
}

/**
 * fileTexts($perms) - $perms is a 3-char rwx string for one class (owner/group/world)
 */
function fileTexts(string $perms): string
{
    $can = '';
    $can .= ($perms[0] ?? '-') === 'r' ? GREEN . 'Read' . NC : RED . 'not Read' . NC;
    $can .= ', ';
    $can .= ($perms[1] ?? '-') === 'w' ? GREEN . 'Write' . NC : RED . 'not Write' . NC;
    $can .= ', ';
    $can .= ($perms[2] ?? '-') !== '-' ? GREEN . 'Execute' . NC : RED . 'not Execute' . NC;

    return $can;
}

/**
 * Determines, in owner > group > other precedence, which class of access
 * explains the user's effective rights, and returns a descriptive string,
 * or null if none applies (bash's `false` / non-zero return).
 */
function whichAccess(
    string $dirPermStr,
    string $ownerMatch,
    string $groupMatch,
    string $ownerPermStr,
    string $groupPermStr,
    string $worldPermStr,
): ?string {
    $isDir = $dirPermStr === 'd';
    $fileOrDir = $isDir ? 'dirTexts' : 'fileTexts';

    if ($ownerMatch === 'GREEN') {
        $three = substr($ownerPermStr, 0, 3);
        return "Owner ({$three}), can " . $fileOrDir($three);
    }

    // The "no traverse bit -> skip this class" guard only makes sense for
    // directories (no execute bit there means you can't cd/traverse into
    // it, so listing it is moot). For plain files, group/other read or
    // write access is meaningful on its own and shouldn't be hidden just
    // because the execute bit is unset.
    if ($groupMatch === 'GREEN' && (!$isDir || substr($groupPermStr, 2, 1) !== '-')) {
        $three = substr($groupPermStr, 0, 3);
        return "Group member ({$three}), can " . $fileOrDir($three);
    }

    if (!$isDir || substr($worldPermStr, 2, 1) !== '-') {
        $three = substr($worldPermStr, 0, 3);
        return "Other ({$three}), can " . $fileOrDir($three);
    }

    return null;
}

// ---------------------------------------------------------------------------
// Intro banner
// ---------------------------------------------------------------------------
printf(
    "\nPermsplainer, please explain exactly how user %s%s%s can access %s%s%s!\n\n",
    RED, $USER, NC,
    YELLOW, $FILE, NC,
);
echo str_repeat('-', 113) . "\n";
printf("These are dir/file properties, in all levels leading down to the file, with %srelevant%s accesses for that user:\n", GREEN, NC);
echo str_repeat('-', 113) . "\n";
echo "\n";

// ---------------------------------------------------------------------------
// Walk every directory component leading down to $FILE
// ---------------------------------------------------------------------------

// Splitting on '/': this drops empty // segments naturally (consecutive slashes collapse)
$parts = array_filter(explode('/', $FILE), static fn (string $s): bool => $s !== '');

$curFile = '';

foreach ($parts as $item) {
    $curFile = $curFile . '/' . $item;
    $curFile = str_replace('//', '/', $curFile);

    $tab = strlen($FILE) + 2;

    if ($curFile === '/') {
        printf('/%-' . (strlen($FILE) + 1) . "s", '');
    } else {
        printf('%-' . $tab . 's%s', $curFile, '');
    }

    // -------------------------------------------------------------------
    // stat -c "%a %A %U %G" "$CUR_FILE"
    // -------------------------------------------------------------------
    $info = statInfo($curFile);

    if ($info === null) {
        // stat failed (e.g. file doesn't exist yet, broken symlink,
        // permission denied on a parent, etc.) — show this clearly instead
        // of guessing at perms/owner/group from nothing.
        printf("\n%-" . ($tab + 14) . "s---", '');
        printf("\n%-" . ($tab + 16) . "s\\ ", '');
        cecho('Cannot check this path - it may not exist, or a parent directory blocks lookup.', 'RED');
        echo "\n\n";
        continue;
    }

    [$rawPerms, $permStr, $owner, $group] = $info;

    $perms = sprintf('%04d', (int) $rawPerms); // adjust 755 to 0755

    $dirPerms = $perms[0];     // typically 0, 1 or 2 on dirs for setgid etc
    $ownerPerms = $perms[1];   // 0 to 7
    $groupPerms = $perms[2];   // 0 to 7
    $worldPerms = $perms[3];   // 0 to 7

    $dirPermStr = $permStr[0];            // d or -
    $ownerPermStr = substr($permStr, 1, 3); // rwx
    $groupPermStr = substr($permStr, 4, 3); // rwx
    $worldPermStr = substr($permStr, 7, 3); // rwx

    $ownerMatch = 'NC';
    $groupMatch = 'NC';
    $worldMatch = 'NC';
    $extraTabs = 0;

    // User owns file?
    $ownerMatch = ($owner === $USER) ? 'GREEN' : 'NC';

    if ($ownerMatch === 'NC') {
        // User belongs to group? (id -nGz "$USER" | grep -qzxF "$GROUP")
        $groupsOut = trim((string) shell_exec('id -nG ' . escapeshellarg($USER) . ' 2>/dev/null'));
        $userGroups = $groupsOut === '' ? [] : preg_split('/\s+/', $groupsOut);

        if (in_array($group, $userGroups, true)) {
            $groupMatch = 'GREEN';
            $worldMatch = 'NC';
            $extraTabs = 4;
        } else {
            $worldMatch = 'GREEN';
            $extraTabs = 8;
        }
    }

    // for dirs / for files: figure out the descriptive "Access as ..." text
    $access = '';
    if ($dirPermStr === 'd' || $dirPermStr === '-') {
        $which = whichAccess($dirPermStr, $ownerMatch, $groupMatch, $ownerPermStr, $groupPermStr, $worldPermStr);
        if ($which !== null) {
            $access = "Access as {$which}.";
        }
    }

    // -------------------------------------------------------------------
    // printf "Perms: $DIRPERMS" ; cecho ... ; etc
    // -------------------------------------------------------------------
    echo 'Perms: ' . $dirPerms;
    cecho($ownerPerms, $ownerMatch);
    cecho($groupPerms, $groupMatch);
    cecho($worldPerms, $worldMatch);
    cecho('|' . $dirPermStr);
    cecho('|' . $ownerPermStr, $ownerMatch);
    cecho('|' . $groupPermStr, $groupMatch);
    cecho('|' . $worldPermStr . '|', $worldMatch);
    echo ',';

    cecho(' Owner ');
    cecho($owner, $ownerMatch, 9);
    cecho(' Group ');
    cecho($group, $groupMatch, 9);

    printf("\n%-" . ($tab + 14 + $extraTabs) . "s---", '');
    printf("\n%-" . ($tab + 16 + $extraTabs) . "s\\ ", '');

    // -------------------------------------------------------------------
    // ACL check: getfacl -s -p $CUR_FILE
    // -------------------------------------------------------------------
    $aclOut = trim((string) shell_exec('getfacl -s -p ' . escapeshellarg($curFile) . ' 2>/dev/null'));

    if ($aclOut !== '') {
        echo RED . "Has ACL!" . NC . " Sorry, I don't know how to interpret ACL's...";
    } else {
        cecho($access, 'YELLOW');
    }

    echo "\n\n";
}

echo str_repeat('-', 119) . "\n";
echo "Effective permissions, obtained with actual access test, using full path (requires traverse right at all dir levels):\n";
echo str_repeat('-', 119) . "\n";

// ---------------------------------------------------------------------------
// Effective access tests via sudo -u $USER test -r/-w/-x $FILE
// ---------------------------------------------------------------------------

/** Runs `sudo -u $user test -$flag $path` and returns true if it succeeded. */
function sudoTest(string $flag, string $user, string $path): bool
{
    $cmd = sprintf(
        'sudo -u %s test -%s %s',
        escapeshellarg($user),
        $flag,
        escapeshellarg($path),
    );
    exec($cmd, $output, $exitCode);

    return $exitCode === 0;
}

/**
 * Runs `stat -c "%a %A %U %G" $path` and returns [perms, permStr, owner, group],
 * or null if stat fails (path doesn't exist, broken symlink, a parent dir
 * blocks lookup, etc).
 * Deliberately checks the real exit code rather than guessing from output,
 */
function statInfo(string $path): ?array
{
    $cmd = 'stat -c "%a %A %U %G" ' . escapeshellarg($path) . ' 2>/dev/null';
    $output = [];
    exec($cmd, $output, $exitCode);

    if ($exitCode !== 0 || $output === []) {
        return null;
    }

    $fields = preg_split('/\s+/', trim($output[0]));

    if (count($fields) !== 4) {
        return null;
    }

    return $fields;
}

function reportAccess(string $flag, string $verb, string $user, string $path): void
{
    if (sudoTest($flag, $user, $path)) {
        printf("User %s %scan %s%s %s\n", $user, GREEN, $verb, NC, $path);
    } else {
        printf("User %s %scannot %s%s %s\n", $user, RED, $verb, NC, $path);
    }
}

reportAccess('r', 'read', $USER, $FILE);
reportAccess('w', 'write', $USER, $FILE);
reportAccess('x', 'execute', $USER, $FILE);
echo "\n";

if ($arg1 !== $FILE) {
    printf("Since I was given a relative path (%s), I'll try another set of tests from where I am running:\n", $arg1);
    reportAccess('r', 'read', $USER, $arg1);
    reportAccess('w', 'write', $USER, $arg1);
    reportAccess('x', 'execute', $USER, $arg1);
    echo "\n";
}

echo "\n";

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