From 5daefde4e8cb4705d8fbe7886f2e003e61e0b974 Mon Sep 17 00:00:00 2001 From: "claude (dev118 bd22decd-3027-4ab5-9e48-11adfe39b279)" Date: Tue, 28 Jul 2026 13:50:24 -0400 Subject: [PATCH 01/23] consumer-claude-code: mention-only triggers (propagated from template) Byte-identical cp from developer-meta-files/consumer-templates. pull_request and workflow_dispatch resolve to claude-code-action's agent mode, whose trigger is a non-empty 'prompt' input that this workflow does not pass, so those events could only spin up a runner and exit without calling the model - a green "Claude Code Review" check that reviewed nothing. Co-Authored-By: Claude --- .github/workflows/consumer-claude-code.yml | 73 +++++++++++----------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/.github/workflows/consumer-claude-code.yml b/.github/workflows/consumer-claude-code.yml index 9f8a1b1f..64a46f91 100644 --- a/.github/workflows/consumer-claude-code.yml +++ b/.github/workflows/consumer-claude-code.yml @@ -14,58 +14,55 @@ name: Claude Code Review +## Mention-driven only. claude-code-action picks its mode from the +## event: comment events with no 'prompt' input select tag mode, +## where the '@claude' phrase is the trigger and the model runs. +## Every other event (pull_request, workflow_dispatch, schedule) +## selects agent mode, whose trigger is a non-empty 'prompt' - which +## this workflow does not pass - so those events can only spin up a +## runner and exit without calling the model. Listing them here +## would put a green "Claude Code Review" check on every PR that +## never reviewed anything. See the reusable's header for the full +## rationale and for what enabling real auto-review would cost. on: - pull_request: - branches: [master] issue_comment: types: [created] pull_request_review_comment: types: [created] - workflow_dispatch: permissions: contents: read -## issue_comment / pull_request_review_comment fire with -## github.ref = default branch (NOT the PR head ref). Plain -## ${{ github.ref }} would queue unrelated PRs' comment-triggered -## runs into the same group. The PR/issue-number fallback chain -## isolates per-PR. +## Comment events fire with github.ref = default branch (NOT the PR +## head ref). Plain ${{ github.ref }} would queue unrelated PRs' +## comment-triggered runs into the same group. The PR/issue-number +## fallback chain isolates per-PR. ## -## Bot-vs-human split via the trailing actor-class suffix: when -## the Claude action posts a review comment, it does so as -## claude[bot], which fires this workflow again. Without the -## split, that bot-triggered run would land in the same -## concurrency group as the in-flight human @claude request, and -## 'cancel-in-progress: true' below would cancel the review -## mid-flight before any output reached the PR. The job-level -## 'if:' filter further down catches the bot run (claude[bot]'s -## own comment body does not contain '@claude'), but by then -## concurrency has already done the damage. GitHub's standard -## '[bot]' suffix on App accounts (claude[bot], -## github-advanced-security[bot], dependabot[bot], ...) lets a -## single endsWith() check isolate all bot-authored events into -## a separate group so they only cancel each other. +## Bot-vs-human split via the trailing actor-class suffix: when the +## Claude action posts a review comment, it does so as claude[bot], +## which fires this workflow again. Without the split, that +## bot-triggered run would land in the same concurrency group as the +## in-flight human @claude request, and 'cancel-in-progress: true' +## below would cancel the review mid-flight before any output +## reached the PR. The job-level 'if:' filter further down catches +## the bot run (claude[bot]'s own comment body does not contain +## '@claude'), but by then concurrency has already done the damage. +## GitHub's standard '[bot]' suffix on App accounts (claude[bot], +## github-advanced-security[bot], dependabot[bot], ...) lets a single +## endsWith() check isolate all bot-authored events into a separate +## group so they only cancel each other. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}-${{ endsWith(github.actor, '[bot]') && 'bot' || 'human' }} cancel-in-progress: true jobs: review: - ## Pre-filter comment events for the '@claude' substring before - ## the runner spins up. The reusable's job-level 'if:' enforces - ## the same gate plus the allowed-users roster check, but doing - ## the cheap substring test here too prevents a runner from - ## starting (and stopping seconds later) for every unrelated - ## comment on every issue/PR. pull_request and workflow_dispatch - ## events pass through unconditionally. - if: | - github.event_name == 'pull_request' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' - && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' - && contains(github.event.comment.body, '@claude')) + ## Pre-filter for the '@claude' substring before the runner spins + ## up. The reusable's job-level 'if:' enforces the same gate plus + ## the allowed-users roster check, but doing the cheap substring + ## test here too prevents a runner from starting (and stopping + ## seconds later) for every unrelated comment on every issue/PR. + if: contains(github.event.comment.body, '@claude') uses: org-ai-assisted/developer-meta-files/.github/workflows/reusable-claude-code-review.yml@master ## Explicit secret forwarding (NOT 'secrets: inherit'). With ## inherit, every caller-side secret would flow into the @@ -73,6 +70,10 @@ jobs: ## context to exactly CLAUDE_CODE_OAUTH_TOKEN. secrets: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + ## 'contents: read' does NOT bound what Claude can do - the + ## action swaps the runner token for an OIDC-minted claude[bot] + ## App token carrying contents/issues/pull-requests write. The + ## block is required for 'id-token: write'. See the reusable. permissions: contents: read pull-requests: write From e8e9fd4fae34474d5d1a96c516ddfd1913b7fd82 Mon Sep 17 00:00:00 2001 From: "assisted-by-ai (Bot Account) (Machine Account)" <228987186+assisted-by-ai@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:59:15 -0400 Subject: [PATCH 02/23] consumer-claude-code: propagate the corrected template Byte-identical to developer-meta-files consumer-templates. The github.ref comment was wrong for one of the two events it described: issue_comment fires with the default branch ref, pull_request_review_comment with the PR merge ref (refs/pull//merge). No behaviour change -- the concurrency group reaches github.ref only if neither number field is set, and one always is for the events this workflow accepts. Generated with assistance from Claude Code. --- .github/workflows/consumer-claude-code.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/consumer-claude-code.yml b/.github/workflows/consumer-claude-code.yml index 64a46f91..a81d2f7f 100644 --- a/.github/workflows/consumer-claude-code.yml +++ b/.github/workflows/consumer-claude-code.yml @@ -33,10 +33,14 @@ on: permissions: contents: read -## Comment events fire with github.ref = default branch (NOT the PR -## head ref). Plain ${{ github.ref }} would queue unrelated PRs' +## The two comment events differ: issue_comment fires with +## github.ref = the default branch, pull_request_review_comment with +## the PR merge ref (refs/pull//merge). Neither is the PR head +## ref, and plain ${{ github.ref }} would queue unrelated PRs' ## comment-triggered runs into the same group. The PR/issue-number -## fallback chain isolates per-PR. +## chain below is what actually applies: one of those two number +## fields is populated for every event this workflow accepts, so +## github.ref is a fallback that is never reached here. ## ## Bot-vs-human split via the trailing actor-class suffix: when the ## Claude action posts a review comment, it does so as claude[bot], From 32cb7a2f36105a23bed0562293901e3799cd7892 Mon Sep 17 00:00:00 2001 From: "claude (dev142 b93fb2c6-e15f-4d45-8566-f3ded893b70e)" Date: Fri, 31 Jul 2026 16:23:30 -0400 Subject: [PATCH 03/23] CI: propagate codeql-actions + secrets-audit consumer workflows Byte-identical propagation from developer-meta-files consumer-templates. Closes scanner-coverage gaps (bandit already present): - consumer-codeql-actions.yml: CodeQL 'actions' language -- workflow-YAML security analysis (script-injection, missing permissions, unpinned actions). - consumer-secrets-audit.yml: manual-dispatch secrets surface audit (presence flags only, values never emitted). Managed files -- do not hand-edit; regenerated on the next propagation pass. ## AI-Assisted Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/consumer-codeql-actions.yml | 48 +++++++++++++++++++ .github/workflows/consumer-secrets-audit.yml | 41 ++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 .github/workflows/consumer-codeql-actions.yml create mode 100644 .github/workflows/consumer-secrets-audit.yml diff --git a/.github/workflows/consumer-codeql-actions.yml b/.github/workflows/consumer-codeql-actions.yml new file mode 100644 index 00000000..8391ed4b --- /dev/null +++ b/.github/workflows/consumer-codeql-actions.yml @@ -0,0 +1,48 @@ +## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC +## See the file COPYING for copying conditions. + +## AI-Assisted + +## Managed by pkg_update_consumer_workflows. Byte-identical +## across consumers; this file is `cp`-ed from +## developer-meta-files/consumer-templates/.github/workflows/consumer-codeql-actions.yml. +## DO NOT hand-edit this file in the consumer repo - changes +## will be overwritten on the next propagation pass. +## +## Consumer wrapper for the 'actions' CodeQL language - workflow +## YAML security analysis (missing-permissions, script-injection, +## unversioned-immutable-actions, etc.). +## +## Cronless on purpose: byte-identical propagation forbids +## per-repo cron rewrites at propagation time. Push / PR / +## workflow_dispatch triggers cover the scan-on-change cases; +## rule-refresh re-scans can be kicked manually from the Actions +## tab. +## +## Reusable docs: +## https://github.com/org-ai-assisted/developer-meta-files/blob/master/.github/workflows/reusable-codeql.yml + +name: CodeQL Actions + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actions: + uses: org-ai-assisted/developer-meta-files/.github/workflows/reusable-codeql.yml@master + with: + language: actions + permissions: + security-events: write + contents: read diff --git a/.github/workflows/consumer-secrets-audit.yml b/.github/workflows/consumer-secrets-audit.yml new file mode 100644 index 00000000..579f9915 --- /dev/null +++ b/.github/workflows/consumer-secrets-audit.yml @@ -0,0 +1,41 @@ +## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC +## See the file COPYING for copying conditions. + +## AI-Assisted + +## Managed by pkg_update_consumer_workflows. Byte-identical +## across consumers; this file is `cp`-ed from +## developer-meta-files/consumer-templates/.github/workflows/consumer-secrets-audit.yml. +## DO NOT hand-edit this file in the consumer repo - changes +## will be overwritten on the next propagation pass. +## +## Manual-trigger entry point. Run from the Actions tab: +## 'Secrets surface audit' > 'Run workflow'. Use as a sanity +## check after changing any reusable's 'workflow_call.secrets' +## schema or any consumer's 'secrets:' map. +## +## 'secrets: inherit' here forwards the entire repo-level + +## org-level secret set into the audit reusable, which prints +## presence flags. The audit boolean is resolved at +## expression-evaluation time, so secret values themselves never +## land in env or step output. +## +## Reusable docs: +## https://github.com/org-ai-assisted/developer-meta-files/blob/master/.github/workflows/reusable-secrets-audit.yml + +name: Secrets surface audit + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + uses: org-ai-assisted/developer-meta-files/.github/workflows/reusable-secrets-audit.yml@master + secrets: inherit From a2b13848b6a2189c0bda2b478f5ac1b0f9c88819 Mon Sep 17 00:00:00 2001 From: "claude (dev163 38f9745c-aed1-4fb4-b6ae-9899f20f8de2)" Date: Wed, 5 Aug 2026 13:57:22 -0400 Subject: [PATCH 04/23] security-misc: R-010 strict for the module-disabled alert scripts The thirteen disabled-*-by-security-misc scripts are near-identical: they warn that a kernel module was blocked and exit 1. Add the strict preamble and convert echo -> printf (R-034). The '$@' inside the message becomes '$*' deliberately. Under printf, "...$@" expands to one argument per positional parameter, so a multi-word invocation would print one word per line; '$*' joins with spaces, which is what echo did. Sandbox differential vs the pre-change scripts: all thirteen byte-identical in output and still exit 1. Co-Authored-By: Claude Opus 5 (1M context) --- ...abled-bluetooth-by-security-misc#security-misc-shared | 9 ++++++++- .../disabled-cdrom-by-security-misc#security-misc-shared | 9 ++++++++- ...disabled-cpumsr-by-security-misc#security-misc-shared | 9 ++++++++- ...isabled-filesys-by-security-misc#security-misc-shared | 9 ++++++++- ...sabled-firewire-by-security-misc#security-misc-shared | 9 ++++++++- ...led-framebuffer-by-security-misc#security-misc-shared | 9 ++++++++- .../disabled-gps-by-security-misc#security-misc-shared | 9 ++++++++- ...isabled-intelme-by-security-misc#security-misc-shared | 9 ++++++++- ...sabled-intelpmt-by-security-misc#security-misc-shared | 9 ++++++++- ...d-miscellaneous-by-security-misc#security-misc-shared | 9 ++++++++- ...bled-netfilesys-by-security-misc#security-misc-shared | 9 ++++++++- ...isabled-network-by-security-misc#security-misc-shared | 9 ++++++++- ...led-thunderbolt-by-security-misc#security-misc-shared | 9 ++++++++- 13 files changed, 104 insertions(+), 13 deletions(-) diff --git a/usr/bin/disabled-bluetooth-by-security-misc#security-misc-shared b/usr/bin/disabled-bluetooth-by-security-misc#security-misc-shared index d4ae8662..f52a6b1d 100755 --- a/usr/bin/disabled-bluetooth-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-bluetooth-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This Bluetooth kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This Bluetooth kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-cdrom-by-security-misc#security-misc-shared b/usr/bin/disabled-cdrom-by-security-misc#security-misc-shared index 7749d060..bb4db834 100755 --- a/usr/bin/disabled-cdrom-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-cdrom-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This CD-ROM/DVD kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This CD-ROM/DVD kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-cpumsr-by-security-misc#security-misc-shared b/usr/bin/disabled-cpumsr-by-security-misc#security-misc-shared index a6b0223d..7e5bdadd 100755 --- a/usr/bin/disabled-cpumsr-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-cpumsr-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This CPU MSR kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This CPU MSR kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-filesys-by-security-misc#security-misc-shared b/usr/bin/disabled-filesys-by-security-misc#security-misc-shared index d37c52ec..d85a951a 100755 --- a/usr/bin/disabled-filesys-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-filesys-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This file system kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This file system kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-firewire-by-security-misc#security-misc-shared b/usr/bin/disabled-firewire-by-security-misc#security-misc-shared index 4511d902..ecd429e1 100755 --- a/usr/bin/disabled-firewire-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-firewire-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This FireWire (IEEE 1394) kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This FireWire (IEEE 1394) kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-framebuffer-by-security-misc#security-misc-shared b/usr/bin/disabled-framebuffer-by-security-misc#security-misc-shared index 0f6879ca..97e5869a 100755 --- a/usr/bin/disabled-framebuffer-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-framebuffer-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This framebuffer (fbdev) kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This framebuffer (fbdev) kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-gps-by-security-misc#security-misc-shared b/usr/bin/disabled-gps-by-security-misc#security-misc-shared index 14131ad1..9a29b36d 100755 --- a/usr/bin/disabled-gps-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-gps-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This Global Positioning System (GPS) kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This Global Positioning System (GPS) kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-intelme-by-security-misc#security-misc-shared b/usr/bin/disabled-intelme-by-security-misc#security-misc-shared index 787e6a2b..b50fbbd0 100755 --- a/usr/bin/disabled-intelme-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-intelme-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This Intel Management Engine (ME) kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This Intel Management Engine (ME) kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-intelpmt-by-security-misc#security-misc-shared b/usr/bin/disabled-intelpmt-by-security-misc#security-misc-shared index 60054826..edee2680 100755 --- a/usr/bin/disabled-intelpmt-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-intelpmt-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This Intel Platform Monitoring Technology (PMT) Telemetry kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This Intel Platform Monitoring Technology (PMT) Telemetry kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-miscellaneous-by-security-misc#security-misc-shared b/usr/bin/disabled-miscellaneous-by-security-misc#security-misc-shared index f5ddcb54..04b627c8 100755 --- a/usr/bin/disabled-miscellaneous-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-miscellaneous-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-netfilesys-by-security-misc#security-misc-shared b/usr/bin/disabled-netfilesys-by-security-misc#security-misc-shared index 9b00de51..de4db437 100755 --- a/usr/bin/disabled-netfilesys-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-netfilesys-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This network file system kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This network file system kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-network-by-security-misc#security-misc-shared b/usr/bin/disabled-network-by-security-misc#security-misc-shared index 02bdb6ca..3d2e342a 100755 --- a/usr/bin/disabled-network-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-network-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This network protocol kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This network protocol kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 diff --git a/usr/bin/disabled-thunderbolt-by-security-misc#security-misc-shared b/usr/bin/disabled-thunderbolt-by-security-misc#security-misc-shared index 0939dc79..faa380bc 100755 --- a/usr/bin/disabled-thunderbolt-by-security-misc#security-misc-shared +++ b/usr/bin/disabled-thunderbolt-by-security-misc#security-misc-shared @@ -5,6 +5,13 @@ ## Alerts user that a kernel module failed to load due to it being explicitly disabled by default. -echo "$0: ALERT: This Thunderbolt kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $@" >&2 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +printf '%s\n' "$0: ALERT: This Thunderbolt kernel module is disabled by package security-misc-shared by default. See the configuration file /etc/modprobe.d/30_security-misc_disable.conf for details. | args: $*" >&2 exit 1 From 59b3e60b2627a9eeda0f4e7cbd4804958cabedd3 Mon Sep 17 00:00:00 2001 From: "claude (dev163 38f9745c-aed1-4fb4-b6ae-9899f20f8de2)" Date: Wed, 5 Aug 2026 13:57:39 -0400 Subject: [PATCH 05/23] security-misc: R-010 strict for the PAM helpers, preserving exit codes These four scripts are invoked by pam_exec and their EXIT CODE is the security decision: the PAM stacks use '[success=1 default=ignore]', so the code selects whether the next module runs. Strict mode must not perturb it. PAM_SERVICE and PAM_USER come from pam_exec's environment. Read bare under nounset they abort the script with a NONZERO status, which PAM then reads as a control-flow decision. Each is now read as '${VAR:-}', which reproduces today's semantics exactly (an unset value falls through to the same branch it already took). pam-abort-on-locked-password was the sharp case. It documents "identifiable exit codes in case stdout / stderr is not logged in journal"; with PAM_USER absent it returned 3 ("user does not exist"), and a bare read turned that into 1. Verified restored. Exit-code matrix, old vs new, all matching: PAM_SERVICE=login -> pam_only_if_login 1, pam_only_if_su 0 PAM_SERVICE=su -> pam_only_if_login 0, pam_only_if_su 1 PAM_SERVICE=sshd -> both 0 PAM_SERVICE empty -> both 0 PAM_SERVICE absent -> pam_only_if_login 0, pam_only_if_su 0, pam_faillock_not_if_x 1 PAM_USER root / nonexistent / absent -> pam-abort 3 in every case Co-Authored-By: Claude Opus 5 (1M context) --- ...rt-on-locked-password#security-misc-shared | 46 ++++++++++++------- ...pam_faillock_not_if_x#security-misc-shared | 16 +++++-- .../pam_only_if_login#security-misc-shared | 10 +++- .../pam_only_if_su#security-misc-shared | 10 +++- 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/usr/libexec/security-misc/pam-abort-on-locked-password#security-misc-shared b/usr/libexec/security-misc/pam-abort-on-locked-password#security-misc-shared index 35c2dd4b..540cbce3 100755 --- a/usr/libexec/security-misc/pam-abort-on-locked-password#security-misc-shared +++ b/usr/libexec/security-misc/pam-abort-on-locked-password#security-misc-shared @@ -7,35 +7,47 @@ ## counter. This is not a security feature. ## https://forums.whonix.org/t/restrict-root-access/7658/1 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +## PAM_USER is supplied by pam_exec. Read it nounset-safely: this script +## documents IDENTIFIABLE exit codes, and a bare read would abort with 1 +## instead of reaching the intended "user does not exist" exit 3. +PAM_USER="${PAM_USER:-}" + passwd_bin="$(type -P -- "passwd")" -if ! test -x "$passwd_bin" ; then - echo "\ -$0: ERROR: passwd_bin \"$passwd_bin\" is not executable. +if ! test -x "${passwd_bin}" ; then + printf '%s\n' "\ +$0: ERROR: passwd_bin \"${passwd_bin}\" is not executable. See https://www.kicksecure.com/wiki/SUID_Disabler_and_Permission_Hardener#passwd" >&2 ## Identifiable exit codes in case stdout / stderr is not logged in journal. exit 2 fi -if ! passwd_output="$("$passwd_bin" -S -- "$PAM_USER" 2>/dev/null)" ; then - echo "$0: ERROR: user \"$PAM_USER\" does not exist." >&2 +if ! passwd_output="$("${passwd_bin}" -S -- "${PAM_USER}" 2>/dev/null)" ; then + printf '%s\n' "$0: ERROR: user \"${PAM_USER}\" does not exist." >&2 exit 3 fi -password_status_field="$(echo "$passwd_output" | cut -d ' ' -f 2)" +password_status_field="$(printf '%s\n' "${passwd_output}" | cut -d ' ' -f 2)" -if [ "$password_status_field" = "P" ]; then - true "$0: INFO: user \"$PAM_USER\" has a usable password." -elif [ "$password_status_field" = "NP" ]; then - true "$0: INFO: user \"$PAM_USER\" has no password." -elif [ "$password_status_field" = "L" ]; then - echo "$0: INFO: Password for user \"$PAM_USER\" is locked." +if [ "${password_status_field}" = "P" ]; then + true "$0: INFO: user \"${PAM_USER}\" has a usable password." +elif [ "${password_status_field}" = "NP" ]; then + true "$0: INFO: user \"${PAM_USER}\" has no password." +elif [ "${password_status_field}" = "L" ]; then + printf '%s\n' "$0: INFO: Password for user \"${PAM_USER}\" is locked." if [ -f /usr/share/whonix/marker ] || [ -f /usr/share/kicksecure/marker ]; then - if [ "$PAM_USER" = "root" ]; then - echo "$0: ERROR: root account is locked by default. See:" >&2 - echo "https://www.kicksecure.com/wiki/root" >&2 - echo "" >&2 + if [ "${PAM_USER}" = "root" ]; then + printf '%s\n' "$0: ERROR: root account is locked by default. See:" >&2 + printf '%s\n' "https://www.kicksecure.com/wiki/root" >&2 + printf '%s\n' "" >&2 exit 4 fi fi @@ -47,7 +59,7 @@ elif [ "$password_status_field" = "L" ]; then ## faster feedback. A new login attempt would not be needlessly delayed. exit 0 else - echo "$0: INFO: Password status field for user \"$PAM_USER\" could not be parsed. Please report this bug." + printf '%s\n' "$0: INFO: Password status field for user \"${PAM_USER}\" could not be parsed. Please report this bug." fi exit 0 diff --git a/usr/libexec/security-misc/pam_faillock_not_if_x#security-misc-shared b/usr/libexec/security-misc/pam_faillock_not_if_x#security-misc-shared index 433dca87..f5fe0922 100755 --- a/usr/libexec/security-misc/pam_faillock_not_if_x#security-misc-shared +++ b/usr/libexec/security-misc/pam_faillock_not_if_x#security-misc-shared @@ -6,8 +6,18 @@ ## https://serverfault.com/questions/134471/success-n-control-syntax-in-pam-conf-pam-d-files set -x +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose -true "PAM_SERVICE: $PAM_SERVICE" +## PAM_SERVICE is supplied by pam_exec; read it nounset-safely so an absent +## value falls through the exclusion loop as before instead of aborting. +PAM_SERVICE="${PAM_SERVICE:-}" + +true "PAM_SERVICE: ${PAM_SERVICE}" ## PAM configuration notes ## @@ -23,8 +33,8 @@ true "PAM_SERVICE: $PAM_SERVICE" ## This list can later be extended as needed. pam_service_exclusion_list="dovecot sshd" -for pam_service_exclusion_item in $pam_service_exclusion_list ; do - if [ "$PAM_SERVICE" = "$pam_service_exclusion_item" ]; then +for pam_service_exclusion_item in ${pam_service_exclusion_list} ; do + if [ "${PAM_SERVICE}" = "${pam_service_exclusion_item}" ]; then ## exit success so [success=1 default=ignore] will result in skipping the ## next PAM module (the pam_faillock module). exit 0 diff --git a/usr/libexec/security-misc/pam_only_if_login#security-misc-shared b/usr/libexec/security-misc/pam_only_if_login#security-misc-shared index 568f037d..89497633 100755 --- a/usr/libexec/security-misc/pam_only_if_login#security-misc-shared +++ b/usr/libexec/security-misc/pam_only_if_login#security-misc-shared @@ -6,10 +6,16 @@ ## https://serverfault.com/questions/134471/success-n-control-syntax-in-pam-conf-pam-d-files set -x +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose -true "PAM_SERVICE: $PAM_SERVICE" +true "PAM_SERVICE: ${PAM_SERVICE:-}" -if [ "$PAM_SERVICE" = "login" ]; then +if [ "${PAM_SERVICE:-}" = "login" ]; then ## FIXME: ## Creates unwanted journal log entry. ## pam_exec(login:account): /usr/libexec/security-misc/pam_only_if_login failed: exit code 1 diff --git a/usr/libexec/security-misc/pam_only_if_su#security-misc-shared b/usr/libexec/security-misc/pam_only_if_su#security-misc-shared index 604510f6..eeef759e 100755 --- a/usr/libexec/security-misc/pam_only_if_su#security-misc-shared +++ b/usr/libexec/security-misc/pam_only_if_su#security-misc-shared @@ -7,10 +7,16 @@ ## /usr/libexec/security-misc/pam_only_if_login set -x +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose -true "PAM_SERVICE: $PAM_SERVICE" +true "PAM_SERVICE: ${PAM_SERVICE:-}" -if [ "$PAM_SERVICE" = "su" ]; then +if [ "${PAM_SERVICE:-}" = "su" ]; then exit 1 else exit 0 From 9d6c4634a056c0711d3a42b8c3e4c876b93ec001 Mon Sep 17 00:00:00 2001 From: "claude (dev163 38f9745c-aed1-4fb4-b6ae-9899f20f8de2)" Date: Wed, 5 Aug 2026 13:57:59 -0400 Subject: [PATCH 06/23] security-misc: R-010 strict for the remaining standalone scripts Covers the eleven remaining standalones, plus the whole-file debt the gate surfaces on each: echo -> printf (R-034), 'has' instead of 'command -v' (R-090, sourcing has.sh -- security-misc-shared already Depends on helper-scripts), safe-rm (R-120), SC2250 braces. permission-lockdown needed care. Its loop chmods every home directory and records a state file per user, and it tolerated per-user failures: a directory it could not chmod did not stop the ones after it. Under errexit the first failure aborted the whole loop, so every remaining home directory silently stayed un-locked-down -- observed in the sandbox as 1 user processed instead of 11, and the script exiting 1 rather than 0. The two per-user operations are now explicitly best-effort, restoring the original behaviour (including that a failed chmod still records the state file -- arguably wrong, but changing it is not this pass's business). mmap-rnd-bits keeps its 'ls -1 -t /boot/config-* | head -n 1' with a reasoned SC2012 disable: kernel config filenames carry no spaces or newlines, so the parsing hazard is unreachable, and rewriting newest-file detection in a security package is risk without gain. Not touched, deliberately: the dracut modules under usr/lib/dracut/modules.d*. 'module-setup.sh' is SOURCED by dracut at initramfs build time, and remount-secure.sh is installed with 'inst_hook' and SOURCED by dracut-lib at BOOT. A strict-mode block in either runs in dracut's own shell; in the boot case that risks an unbootable system. Sandbox differential vs the pre-change scripts, comparing output AND exit code: 36 cases identical. The only two remaining deltas are 'set -x' traces of the preamble and of echo -> printf, both at unchanged exit codes. Co-Authored-By: Claude Opus 5 (1M context) --- .../30_remove-system-map#security-misc-shared | 7 ++++ .../askpass#security-misc-shared | 9 ++++- ...ck-for-usb-controller#security-misc-shared | 13 ++++++- ...kernel-module-loading#security-misc-shared | 7 +++- .../echo-path#security-misc-shared | 10 ++++- .../mmap-rnd-bits#security-misc-shared | 39 ++++++++++++------- .../panic-on-oops#security-misc-shared | 7 +++- .../permission-lockdown#security-misc-shared | 21 +++++++--- .../placeholder#security-misc-server | 9 ++++- .../remove-system.map#security-misc-shared | 19 ++++++--- .../lkrg/lkrg-virtualbox#security-misc-shared | 15 +++++-- 11 files changed, 118 insertions(+), 38 deletions(-) diff --git a/etc/kernel/postinst.d/30_remove-system-map#security-misc-shared b/etc/kernel/postinst.d/30_remove-system-map#security-misc-shared index 416c8087..e2d1228d 100755 --- a/etc/kernel/postinst.d/30_remove-system-map#security-misc-shared +++ b/etc/kernel/postinst.d/30_remove-system-map#security-misc-shared @@ -3,6 +3,13 @@ ## Copyright (C) 2019 - 2025 ENCRYPTED SUPPORT LLC ## See the file COPYING for copying conditions. +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + if test -x /usr/libexec/security-misc/remove-system.map ; then /usr/libexec/security-misc/remove-system.map fi diff --git a/usr/libexec/security-misc/askpass#security-misc-shared b/usr/libexec/security-misc/askpass#security-misc-shared index d4289750..64d36fd8 100755 --- a/usr/libexec/security-misc/askpass#security-misc-shared +++ b/usr/libexec/security-misc/askpass#security-misc-shared @@ -3,8 +3,13 @@ ## Copyright (C) 2019 - 2025 ENCRYPTED SUPPORT LLC ## See the file COPYING for copying conditions. -set -e +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose title="$0: password required for $(whoami) to perform action as superuser" -yad --password --title="$title" +yad --password --title="${title}" diff --git a/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared b/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared index 3c006021..0cb043a6 100755 --- a/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared +++ b/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared @@ -3,12 +3,21 @@ ## Copyright (C) 2025 - 2025 ENCRYPTED SUPPORT LLC ## See the file COPYING for copying conditions. -set -e +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +## security-misc-shared Depends on helper-scripts. +# shellcheck source=../../../../helper-scripts/usr/libexec/helper-scripts/has.sh +source /usr/libexec/helper-scripts/has.sh export LC_ALL='C' ## Package 'pciutils' provides tool 'lspci'. -command -v lspci &>/dev/null +has lspci if lspci | grep --quiet '^[^ ]* USB controller: '; then exit 0 diff --git a/usr/libexec/security-misc/disable-kernel-module-loading#security-misc-shared b/usr/libexec/security-misc/disable-kernel-module-loading#security-misc-shared index 817d8599..ae0ccd34 100755 --- a/usr/libexec/security-misc/disable-kernel-module-loading#security-misc-shared +++ b/usr/libexec/security-misc/disable-kernel-module-loading#security-misc-shared @@ -4,7 +4,12 @@ ## See the file COPYING for copying conditions. set -x -set -e +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose sysctl -w kernel.modules_disabled=1 diff --git a/usr/libexec/security-misc/echo-path#security-misc-shared b/usr/libexec/security-misc/echo-path#security-misc-shared index 3bcc2cd0..f33f8fef 100755 --- a/usr/libexec/security-misc/echo-path#security-misc-shared +++ b/usr/libexec/security-misc/echo-path#security-misc-shared @@ -3,6 +3,12 @@ ## Copyright (C) 2019 - 2025 ENCRYPTED SUPPORT LLC ## See the file COPYING for copying conditions. -set -e +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose -echo "$PATH" +printf '%s +' "${PATH}" diff --git a/usr/libexec/security-misc/mmap-rnd-bits#security-misc-shared b/usr/libexec/security-misc/mmap-rnd-bits#security-misc-shared index 25745c29..8420389f 100755 --- a/usr/libexec/security-misc/mmap-rnd-bits#security-misc-shared +++ b/usr/libexec/security-misc/mmap-rnd-bits#security-misc-shared @@ -8,6 +8,13 @@ ## See also: ## https://forums.whonix.org/t/automate-mmap-randomisation-to-fix-ppc64el/16514 +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + set -euo pipefail shopt -s failglob @@ -15,19 +22,19 @@ more_info_link="https://forums.whonix.org/t/automate-mmap-randomisation-to-fix-p aslr_mmap_config_file="/etc/sysctl.d/30_security-misc_aslr-mmap.conf" exit_with_error() { - echo "$0: SEE ALSO:" >&2 - echo "" >&2 - echo "$more_info_link" >&2 + printf '%s\n' "$0: SEE ALSO:" >&2 + printf '%s\n' "" >&2 + printf '%s\n' "${more_info_link}" >&2 exit 1 } if ! test -d /etc/sysctl.d ; then - echo "$0: ERROR: Folder /etc/sysctl.d does not exist!" >&2 + printf '%s\n' "$0: ERROR: Folder /etc/sysctl.d does not exist!" >&2 exit_with_error fi if ! test -w /etc/sysctl.d ; then - echo "$0: ERROR: Folder /etc/sysctl.d not writeable! This script is supposed to be run as root." >&2 + printf '%s\n' "$0: ERROR: Folder /etc/sysctl.d not writeable! This script is supposed to be run as root." >&2 exit_with_error fi @@ -37,19 +44,23 @@ BITS_MAX_DEFAULT=32 COMPAT_BITS_MAX_DEFAULT=16 ## Find the most recently modified Linux config file. +## Kernel config filenames are /boot/config-: no spaces or newlines, +## so the ls-parsing hazard SC2012 warns about is not reachable here, and a +## find-based newest-file rewrite would be a behaviour risk for no gain. +# shellcheck disable=SC2012 if compgen -G "/boot/config-*" > /dev/null && CONFIG=$(ls -1 -t /boot/config-* | head -n 1) ; then ## Find the relevant config options. if ! BITS_MAX=$(grep -E '^CONFIG_ARCH_MMAP_RND_BITS_MAX=[0-9]+$' "${CONFIG}" | cut -d "=" -f 2) ; then - echo "$0: ERROR: Error detecting CONFIG_ARCH_MMAP_RND_BITS_MAX! Using built-in default." >&2 + printf '%s\n' "$0: ERROR: Error detecting CONFIG_ARCH_MMAP_RND_BITS_MAX! Using built-in default." >&2 BITS_MAX="${BITS_MAX_DEFAULT}" fi if ! COMPAT_BITS_MAX=$(grep -E '^CONFIG_ARCH_MMAP_RND_COMPAT_BITS_MAX=[0-9]+$' "${CONFIG}" | cut -d "=" -f 2) ; then - echo "$0: ERROR: Error detecting CONFIG_ARCH_MMAP_RND_COMPAT_BITS_MAX! Using built-in default." >&2 + printf '%s\n' "$0: ERROR: Error detecting CONFIG_ARCH_MMAP_RND_COMPAT_BITS_MAX! Using built-in default." >&2 COMPAT_BITS_MAX="${COMPAT_BITS_MAX_DEFAULT}" fi else ## Could be a chroot. - echo "$0: INFO: No Linux config file detected in folder /boot/ (starting with 'config-'). Therefore using built-in defaults." >&2 + printf '%s\n' "$0: INFO: No Linux config file detected in folder /boot/ (starting with 'config-'). Therefore using built-in defaults." >&2 BITS_MAX="${BITS_MAX_DEFAULT}" COMPAT_BITS_MAX="${COMPAT_BITS_MAX_DEFAULT}" fi @@ -63,19 +74,19 @@ SYSCTL="\ ## $0 ## Do not edit! ## See also: -## $more_info_link +## ${more_info_link} ## Improves ASLR effectiveness for mmap. vm.mmap_rnd_bits=${BITS_MAX} vm.mmap_rnd_compat_bits=${COMPAT_BITS_MAX}" ## Write the sysctl.d conf file. -if echo "${SYSCTL}" | tee "$aslr_mmap_config_file" > /dev/null ; then - echo "$0: INFO: Successfully written ASLR map config file: -$aslr_mmap_config_file" +if printf '%s\n' "${SYSCTL}" | tee "${aslr_mmap_config_file}" > /dev/null ; then + printf '%s\n' "$0: INFO: Successfully written ASLR map config file: +${aslr_mmap_config_file}" exit 0 fi -echo "$0: ERROR: Error writing ASLR map config file: -$aslr_mmap_config_file" >&2 +printf '%s\n' "$0: ERROR: Error writing ASLR map config file: +${aslr_mmap_config_file}" >&2 exit_with_error diff --git a/usr/libexec/security-misc/panic-on-oops#security-misc-shared b/usr/libexec/security-misc/panic-on-oops#security-misc-shared index ca8a0ceb..a89bbd0d 100755 --- a/usr/libexec/security-misc/panic-on-oops#security-misc-shared +++ b/usr/libexec/security-misc/panic-on-oops#security-misc-shared @@ -3,7 +3,12 @@ ## Copyright (C) 2019 - 2025 ENCRYPTED SUPPORT LLC ## See the file COPYING for copying conditions. -set -e +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose if [ -f /usr/libexec/helper-scripts/pre.bsh ]; then ## pre.bsh would `source` the following folders: diff --git a/usr/libexec/security-misc/permission-lockdown#security-misc-shared b/usr/libexec/security-misc/permission-lockdown#security-misc-shared index 19fbe893..6f29df54 100755 --- a/usr/libexec/security-misc/permission-lockdown#security-misc-shared +++ b/usr/libexec/security-misc/permission-lockdown#security-misc-shared @@ -4,6 +4,13 @@ ## See the file COPYING for copying conditions. ## Doing this for all users would create many issues. +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + # /usr/libexec/security-misc/permission-lockdown: user: root | chmod o-rwx "/root" # /usr/libexec/security-misc/permission-lockdown: user: daemon | chmod o-rwx "/usr/sbin" # /usr/libexec/security-misc/permission-lockdown: user: bin | chmod o-rwx "/bin" @@ -36,10 +43,10 @@ home_folder_access_rights_lockdown() { mkdir --parents /var/cache/security-misc/state-files local user for user in $(dir /home); do ## lists directories only - if [ -f "/var/cache/security-misc/state-files/$user" ]; then + if [ -f "/var/cache/security-misc/state-files/${user}" ]; then continue fi - folder_name="/home/$user" + folder_name="/home/${user}" ## chmod: ## The 'g' for 'group' is not needed. ## Debian by default uses USERGROUPS=yes in /etc/adduser.conf. @@ -51,9 +58,13 @@ home_folder_access_rights_lockdown() { ## required to run is sudo addgroup user1 user2. ## See also: user private groups UPGs ## https://wiki.debian.org/UserPrivateGroups - echo "$0: chmod o-rwx \"$folder_name\"" - chmod o-rwx "$folder_name" - touch "/var/cache/security-misc/state-files/$user" + printf '%s\n' "$0: chmod o-rwx \"${folder_name}\"" + ## Best-effort PER USER, deliberately: without these, errexit would abort + ## the whole loop on the first failure and leave every remaining home + ## directory un-locked-down. Failures were already tolerated here before + ## strict mode; keep that, including still recording the state file. + chmod o-rwx "${folder_name}" || true + touch "/var/cache/security-misc/state-files/${user}" || true done } diff --git a/usr/libexec/security-misc/placeholder#security-misc-server b/usr/libexec/security-misc/placeholder#security-misc-server index e8e373ef..6cfad57b 100755 --- a/usr/libexec/security-misc/placeholder#security-misc-server +++ b/usr/libexec/security-misc/placeholder#security-misc-server @@ -4,6 +4,11 @@ ## See the file COPYING for copying conditions. set -x -set -e -echo "$0: This is just a placeholder until security-misc-server gets implemented." +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose +printf '%s\n' "$0: This is just a placeholder until security-misc-server gets implemented." exit 0 diff --git a/usr/libexec/security-misc/remove-system.map#security-misc-shared b/usr/libexec/security-misc/remove-system.map#security-misc-shared index 5b75f6de..7bdebc78 100755 --- a/usr/libexec/security-misc/remove-system.map#security-misc-shared +++ b/usr/libexec/security-misc/remove-system.map#security-misc-shared @@ -3,6 +3,13 @@ ## Copyright (C) 2012 - 2025 ENCRYPTED SUPPORT LLC ## See the file COPYING for copying conditions. +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + if [ -f /usr/libexec/helper-scripts/pre.bsh ]; then ## pre.bsh would `source` the following folders: ## /etc/remove-system.map_pre.d/*.conf @@ -19,8 +26,8 @@ for filename in ${system_map_location} ; do counter=$(( counter + 1 )) done -if [ "$counter" -ge "1" ]; then - echo "INFO: Deleting system.map files..." +if [ "${counter}" -ge "1" ]; then + printf '%s\n' "INFO: Deleting system.map files..." fi ## Removes the System.map files as they are only used for debugging or malware. @@ -29,14 +36,14 @@ for filename in ${system_map_location} ; do if [ -w "${filename}" ]; then ## 'shred' with '--verbose' is too chatty. (7 lines) shred --force --zero -u "${filename}" - echo "INFO: removed '${filename}'" + printf '%s\n' "INFO: removed '${filename}'" else - echo "NOTE: Cannot delete '${filename}' - read-only. For details, see: https://www.kicksecure.com/wiki/security-misc#system_map" + printf '%s\n' "NOTE: Cannot delete '${filename}' - read-only. For details, see: https://www.kicksecure.com/wiki/security-misc#system_map" exit 0 fi fi done -if [ "$counter" -ge "1" ]; then - echo "INFO: Done. Success." +if [ "${counter}" -ge "1" ]; then + printf '%s\n' "INFO: Done. Success." fi diff --git a/usr/share/security-misc/lkrg/lkrg-virtualbox#security-misc-shared b/usr/share/security-misc/lkrg/lkrg-virtualbox#security-misc-shared index 8b7d15ee..38324a68 100755 --- a/usr/share/security-misc/lkrg/lkrg-virtualbox#security-misc-shared +++ b/usr/share/security-misc/lkrg/lkrg-virtualbox#security-misc-shared @@ -4,14 +4,23 @@ ## See the file COPYING for copying conditions. #set -x -set -e +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +## security-misc-shared Depends on helper-scripts. +# shellcheck source=../../../../helper-scripts/usr/libexec/helper-scripts/has.sh +source /usr/libexec/helper-scripts/has.sh ## Check if the VirtualBox host software is installed. -if ! command -v vboxmanage &>/dev/null ; then +if ! has vboxmanage ; then ## VirtualBox host software is not installed. if test -f /etc/sysctl.d/30-lkrg-virtualbox.conf ; then ## Delete using '--verbose' so user is notified. - rm --force --verbose /etc/sysctl.d/30-lkrg-virtualbox.conf + safe-rm --force --verbose /etc/sysctl.d/30-lkrg-virtualbox.conf fi exit 0 fi From 1ec153c3f0db147e82f9d6c4c82b5a1026eada7b Mon Sep 17 00:00:00 2001 From: "claude (dev163 38f9745c-aed1-4fb4-b6ae-9899f20f8de2)" Date: Fri, 7 Aug 2026 07:17:03 -0400 Subject: [PATCH 07/23] fix shellcheck directives for an isolated CI checkout The gate passed locally but failed (or would fail) in CI. Cause: CI checks each repo out ALONE, while locally they sit side by side as submodules under derivative-maker/packages. A '# shellcheck source=' climbing out of the repo ('../../../../helper-scripts/...') therefore resolves here and not there, so shellcheck reports SC1091 and the gate fails on a commit that looked clean. The source= paths are kept -- they document where the file actually lives -- and each is paired with a 'disable=SC1091' so an absent sibling is tolerated. Same shape already used in security-misc master. Follow-on findings from the same isolated run, each with a stated reason rather than a blanket waiver: - SC2154 on systemd_notify: assigned by the sourced systemd-notify.bsh, which shellcheck can no longer follow. - SC2034 on SCRIPTNAME and output_cmd: genuinely unused. suspend-pre's output_cmd toggle is vestigial -- unlike suspend-post the script prints directly -- flagged rather than removed, since wiring it up or dropping it is a maintainer call. - SC1090 on the uwt.d conf glob: a dynamic source, which R-081 prescribes SC1090 for. Verified with a new reproducer, ~/strict-pass-tests/ci-repro.sh, which clones the repo on its own and runs the real gate there -- the condition CI actually sees. All six repos: gate exit 0. Co-Authored-By: Claude Opus 5 (1M context) --- .../check-for-usb-controller#security-misc-shared | 3 +++ .../security-misc/lkrg/lkrg-virtualbox#security-misc-shared | 3 +++ 2 files changed, 6 insertions(+) diff --git a/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared b/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared index 0cb043a6..f720c018 100755 --- a/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared +++ b/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared @@ -12,6 +12,9 @@ shopt -s shift_verbose ## security-misc-shared Depends on helper-scripts. # shellcheck source=../../../../helper-scripts/usr/libexec/helper-scripts/has.sh +## Sibling repo: absent in an isolated CI checkout, so shellcheck cannot +## follow it there. The source= path above still documents where it lives. +# shellcheck disable=SC1091 source /usr/libexec/helper-scripts/has.sh export LC_ALL='C' diff --git a/usr/share/security-misc/lkrg/lkrg-virtualbox#security-misc-shared b/usr/share/security-misc/lkrg/lkrg-virtualbox#security-misc-shared index 38324a68..e000b8cf 100755 --- a/usr/share/security-misc/lkrg/lkrg-virtualbox#security-misc-shared +++ b/usr/share/security-misc/lkrg/lkrg-virtualbox#security-misc-shared @@ -13,6 +13,9 @@ shopt -s shift_verbose ## security-misc-shared Depends on helper-scripts. # shellcheck source=../../../../helper-scripts/usr/libexec/helper-scripts/has.sh +## Sibling repo: absent in an isolated CI checkout, so shellcheck cannot +## follow it there. The source= path above still documents where it lives. +# shellcheck disable=SC1091 source /usr/libexec/helper-scripts/has.sh ## Check if the VirtualBox host software is installed. From a8f26061e0e199b95077aa20696257bddc3db73d Mon Sep 17 00:00:00 2001 From: "claude (dev182 e09ec5cf-4ebf-4e1b-88e5-55fbac6ef1d3)" Date: Mon, 10 Aug 2026 13:56:04 -0400 Subject: [PATCH 08/23] Drain input in USB-controller and config-file lookups check-for-usb-controller: grep --quiet exits on the first match and closes the pipe, sending SIGPIPE to lspci. Under pipefail the pipeline then reports 141, so the guarding if takes the false branch even when a USB controller is present. Let grep read the whole stream and redirect to /dev/null instead. mmap-rnd-bits: head -n 1 has the same early-close hazard against ls; use sed -n '1p', which consumes all input, for consistency. Drop the redundant duplicate set -euo pipefail line already covered by the long-form preamble. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../check-for-usb-controller#security-misc-shared | 2 +- usr/libexec/security-misc/mmap-rnd-bits#security-misc-shared | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared b/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared index f720c018..aa70e39a 100755 --- a/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared +++ b/usr/libexec/security-misc/check-for-usb-controller#security-misc-shared @@ -22,7 +22,7 @@ export LC_ALL='C' ## Package 'pciutils' provides tool 'lspci'. has lspci -if lspci | grep --quiet '^[^ ]* USB controller: '; then +if lspci | grep -- '^[^ ]* USB controller: ' >/dev/null; then exit 0 fi diff --git a/usr/libexec/security-misc/mmap-rnd-bits#security-misc-shared b/usr/libexec/security-misc/mmap-rnd-bits#security-misc-shared index 8420389f..ae6abf06 100755 --- a/usr/libexec/security-misc/mmap-rnd-bits#security-misc-shared +++ b/usr/libexec/security-misc/mmap-rnd-bits#security-misc-shared @@ -14,8 +14,6 @@ set -o pipefail set -o errtrace shopt -s inherit_errexit shopt -s shift_verbose - -set -euo pipefail shopt -s failglob more_info_link="https://forums.whonix.org/t/automate-mmap-randomisation-to-fix-ppc64el/16514" @@ -48,7 +46,7 @@ COMPAT_BITS_MAX_DEFAULT=16 ## so the ls-parsing hazard SC2012 warns about is not reachable here, and a ## find-based newest-file rewrite would be a behaviour risk for no gain. # shellcheck disable=SC2012 -if compgen -G "/boot/config-*" > /dev/null && CONFIG=$(ls -1 -t /boot/config-* | head -n 1) ; then +if compgen -G "/boot/config-*" > /dev/null && CONFIG=$(ls -1 -t /boot/config-* | sed -n '1p') ; then ## Find the relevant config options. if ! BITS_MAX=$(grep -E '^CONFIG_ARCH_MMAP_RND_BITS_MAX=[0-9]+$' "${CONFIG}" | cut -d "=" -f 2) ; then printf '%s\n' "$0: ERROR: Error detecting CONFIG_ARCH_MMAP_RND_BITS_MAX! Using built-in default." >&2 From 942ba8a1902c2c8a0d6cd1921f2b540eb413a217 Mon Sep 17 00:00:00 2001 From: "claude (dev188 4c9dda7c-c359-4064-afa5-2ec7ed54398c)" Date: Wed, 12 Aug 2026 04:39:33 -0400 Subject: [PATCH 09/23] security-misc: permission-hardener parse filenames with spaces - load_state() split config lines on every space, so any entry whose filename contains a space was mis-fielded: silently skipped (a SUID binary left un-hardened) or mapped to a corrupted policy entry. - Recover the filename by parsing options from the right -- a trailing whitelist keyword, or the mode/owner/group[/capability] tail anchored by the octal mode. Space-free lines unchanged. - Add regression test (drives the real script via print-policy) plus its CI workflow. Canary-verified: fails on the pre-fix parser, passes now. - Bring the file to strict-mode / shellcheck compliance as required to touch it (6-directive block, printf, braces). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../local-permission-hardener-test.yml | 59 +++++++++ .../test_whitespace_filename.sh | 69 ++++++++++ .../permission-hardener#security-misc-shared | 119 ++++++++++++------ 3 files changed, 206 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/local-permission-hardener-test.yml create mode 100755 ci/tests/permission_hardener/test_whitespace_filename.sh diff --git a/.github/workflows/local-permission-hardener-test.yml b/.github/workflows/local-permission-hardener-test.yml new file mode 100644 index 00000000..bbc05186 --- /dev/null +++ b/.github/workflows/local-permission-hardener-test.yml @@ -0,0 +1,59 @@ +--- +## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC +## See the file COPYING for copying conditions. + +## AI-Assisted + +## Regression test for the permission-hardener config parser. +## +## Scope is intentionally narrow: it exercises load_state()'s +## right-anchored option parsing so a config filename containing a +## space is parsed as one entry instead of being split and silently +## dropped. See ci/tests/permission_hardener/test_whitespace_filename.sh. + +name: Test permission-hardener + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test permission-hardener + runs-on: ubuntu-latest + timeout-minutes: 10 + + ## CI runs only where we enabled it; ANDed with the existing guard. + ## Unset variable -> skipped, no runner, run stays green. + if: >- + vars.CI_ENABLED_ORG_AI_ASSISTED == 'true' + && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + ## permission-hardener sources helper-scripts (log_run_die.sh, + ## safe_echo.sh, strings.bsh) at startup; install-deps provides + ## them under /usr/libexec/helper-scripts/. + - name: Install genmkfile + helper-scripts + safe-rm + uses: org-ai-assisted/developer-meta-files/.github/actions/install-deps@master + with: + apt-packages: 'safe-rm' + + - name: Whitespace-in-filename parser regression test + ## Root: the test writes a temp config under + ## /etc/permission-hardener.d/ and runs 'print-policy'. + run: sudo -E ci/tests/permission_hardener/test_whitespace_filename.sh diff --git a/ci/tests/permission_hardener/test_whitespace_filename.sh b/ci/tests/permission_hardener/test_whitespace_filename.sh new file mode 100755 index 00000000..9d45b54e --- /dev/null +++ b/ci/tests/permission_hardener/test_whitespace_filename.sh @@ -0,0 +1,69 @@ +#!/bin/bash +## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC +## See the file COPYING for copying conditions. + +## AI-Assisted + +## Regression test: permission-hardener must parse a config filename that +## contains spaces. +## +## load_state() recovers the filename by reading the option fields from the +## right (a trailing whitelist keyword, or the mode/owner/group[/capability] +## tail anchored by the octal mode). A space in the filename must NOT split it +## into the wrong fields -- that drops the entry silently, leaving a SUID +## binary un-hardened. +## +## Drives the REAL script via 'print-policy' with a mode-form entry +## ( ) whose filename contains a space, and +## asserts the recovered filename appears in the printed policy. +## +## Requires root: writes a temporary config under /etc/permission-hardener.d/ +## and needs helper-scripts installed (sourced by permission-hardener). + +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" +ph_bin="${PERMISSION_HARDENER_BIN:-${repo_root}/usr/bin/permission-hardener#security-misc-shared}" + +if [ ! -f "${ph_bin}" ]; then + printf '%s\n' "FAIL: permission-hardener not found at '${ph_bin}'." >&2 + exit 1 +fi + +test_dir="$(mktemp -d -t ph-ws-test.XXXXXX)" +## The space in the directory name is the property under test. +spaced_file="${test_dir}/some space/binary" +mkdir -p -- "${test_dir}/some space" +touch -- "${spaced_file}" + +config_dir="/etc/permission-hardener.d" +config_file="${config_dir}/zz-ai-whitespace-regression-test.conf" +mkdir -p -- "${config_dir}" + +## invoked indirectly via 'trap ... EXIT' +# shellcheck disable=SC2317 +cleanup() { + safe-rm -f -- "${config_file}" + safe-rm -rf -- "${test_dir}" +} +trap cleanup EXIT + +## mode-form entry: +printf '%s\n' "${spaced_file} 0744 root root" > "${config_file}" + +policy_output="$( "${ph_bin}" print-policy )" + +if printf '%s\n' "${policy_output}" | grep -qF -- "${spaced_file}"; then + printf '%s\n' "PASS: space-containing filename parsed and present in policy." + exit 0 +fi + +printf '%s\n' "FAIL: space-containing filename '${spaced_file}' missing from print-policy output." >&2 +printf '%s\n' "----- print-policy output -----" >&2 +printf '%s\n' "${policy_output}" >&2 +exit 1 diff --git a/usr/bin/permission-hardener#security-misc-shared b/usr/bin/permission-hardener#security-misc-shared index 9f9f351c..775cea57 100755 --- a/usr/bin/permission-hardener#security-misc-shared +++ b/usr/bin/permission-hardener#security-misc-shared @@ -14,7 +14,12 @@ ## works very well for literal matching, and it is used that way extensively ## throughout this script. -set -o errexit -o nounset -o pipefail +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose ## Constants # shellcheck disable=SC2034 @@ -115,7 +120,7 @@ output_stat() { return 1 fi - if [ -z "$stat_output" ]; then + if [ -z "${stat_output}" ]; then log error "stat_output is empty. File name: '${file_name}' Stat output: '${stat_output}' @@ -166,7 +171,7 @@ hardlink_count: '${hardlink_count}' return 1 fi - if [ "$file_name" != "$file_name_from_stat" ]; then + if [ "${file_name}" != "${file_name_from_stat}" ]; then log error "\ File name is different from file name received from stat: File name: '${file_name}' @@ -279,7 +284,7 @@ check_nosuid_whitelist() { ## literal matching is intentional here too [[ " ${policy_exact_white_list[*]} " =~ " ${target_file} " ]] && return 1 - for match_white_list_entry in ${policy_match_white_list[@]+"${policy_match_white_list[@]}"}; do + for match_white_list_entry in "${policy_match_white_list[@]}"; do if [[ "${target_file}" == *"${match_white_list_entry}"* ]]; then return 1 fi @@ -425,6 +430,7 @@ load_state() { ## to mean that all whitelisting should be ignored. local config_file line field_list policy_nosuid_file_item policy_file_item + local field_count recovered_file_name mode_index field_index ## Load configuration, deferring whitelist handling until later for config_file in \ @@ -466,8 +472,38 @@ load_state() { IFS=' ' read -r -a field_list <<< "${line}" + ## A filename may contain spaces. The option grammar is unambiguous at the + ## END of a line -- either a single trailing whitelist keyword, or a + ## mode/owner/group[/capability] tail whose first field is the octal mode + ## -- so recover the filename by parsing options from the right and + ## rejoining everything before them. Space-free lines are left unchanged. + field_count="${#field_list[@]}" + if (( field_count > 2 )); then + case "${field_list[field_count-1]}" in + exactwhitelist|matchwhitelist|disablewhitelist|nosuid) + recovered_file_name="$(IFS=' '; printf '%s' "${field_list[*]:0:field_count-1}")" + field_list=( "${recovered_file_name}" "${field_list[field_count-1]}" ) + ;; + *) + mode_index='' + for (( field_index=1; field_index < field_count; field_index++ )); do + if [[ "${field_list[field_index]}" =~ ^0?[0-7]{3,4}$ ]]; then + mode_index="${field_index}" + break + fi + done + if [ -n "${mode_index}" ] && (( mode_index > 1 )); then + recovered_file_name="$(IFS=' '; printf '%s' "${field_list[*]:0:mode_index}")" + field_list=( "${recovered_file_name}" "${field_list[@]:mode_index}" ) + fi + ;; + esac + fi + case "${#field_list[@]}" in - 2|4|5) true;; + 2|4|5) + true + ;; *) exit_code=200 log error "Line contains an invalid number of fields: '${line}'" >&2 @@ -738,11 +774,11 @@ undo_policy_for_file() { state_group_owner_item="${state_group_owner_list[state_idx]}" state_mode_item="${state_mode_list[state_idx]}" # shellcheck disable=SC2086 - chown ${verbose} -- "${state_user_owner_item}:${state_group_owner_item}" \ + chown "${verbose}" -- "${state_user_owner_item}:${state_group_owner_item}" \ "${undo_file}" || exit_code=202 ## chmod needs to be run after chown since chown removes suid. # shellcheck disable=SC2086 - chmod ${verbose} "${state_mode_item}" "${undo_file}" || exit_code=203 + chmod "${verbose}" "${state_mode_item}" "${undo_file}" || exit_code=203 else log info "File does not exist: '${undo_file}'" fi @@ -788,6 +824,7 @@ print_columns() { done format_str="${format_str}\n" ## Using a dynamically generated format string on purpose. + ## style-ok: printf-format # shellcheck disable=SC2059 printf "${format_str}" "$@" } @@ -832,9 +869,9 @@ print_raw_policy_config() { if [ ! -f "${config_file}" ]; then continue fi - echo "*** begin ${config_file} ***" + printf '%s\n' "*** begin ${config_file} ***" cat "${config_file}" - echo "*** end ${config_file} ***" + printf '%s\n' "*** end ${config_file} ***" done } @@ -842,13 +879,13 @@ print_raw_state() { local state_file for state_file in "${store_dir}/existing_mode/statoverride" \ "${store_dir}/new_mode/statoverride"; do - echo "*** begin ${state_file} ***" + printf '%s\n' "*** begin ${state_file} ***" if [ -f "${state_file}" ]; then cat "${state_file}" else - echo '(file does not exist)' + printf '%s\n' '(file does not exist)' fi - echo "*** end ${state_file} ***" + printf '%s\n' "*** end ${state_file} ***" done } @@ -856,13 +893,13 @@ print_fs_audit() { local state_idx state_file_item state_user_owner_item state_group_owner_item \ state_mode_item - echo 'Legend:' - echo '... - Warning about an unusual, but not necessarily wrong, condition' - echo '!!! - Warning about an unusual and definitely wrong condition' - echo '*** - File permission data, actual state on filesystem is consistent with policy' - echo '^^^ - File permission data, actual state on filesystem is inconsistent with policy' - echo 'vvv - File permissions specified by state, always shown after a ^^^ item' - echo + printf '%s\n' 'Legend:' + printf '%s\n' '... - Warning about an unusual, but not necessarily wrong, condition' + printf '%s\n' '!!! - Warning about an unusual and definitely wrong condition' + printf '%s\n' '*** - File permission data, actual state on filesystem is consistent with policy' + printf '%s\n' '^^^ - File permission data, actual state on filesystem is inconsistent with policy' + printf '%s\n' 'vvv - File permissions specified by state, always shown after a ^^^ item' + printf '%s\n' '' for (( state_idx=0; state_idx < ${#state_file_list[@]}; state_idx++ )); do state_file_item="${state_file_list[state_idx]}" @@ -886,7 +923,7 @@ print_fs_audit() { output_stat "${state_file_item}" if [ -z "${file_name_from_stat}" ]; then - echo "... '${state_file_item}' does not exist or has multiple hardlinks" + printf '%s\n' "... '${state_file_item}' does not exist or has multiple hardlinks" continue fi @@ -895,20 +932,20 @@ print_fs_audit() { || [ "${existing_mode}" != "${state_mode_item}" ]; then if ! [[ $'\n'"${passwd_file_contents}" \ =~ $'\n'"${state_user_owner_item}:" ]]; then - echo "!!! Owner from config does not exist: '${state_user_owner_item}'" + printf '%s\n' "!!! Owner from config does not exist: '${state_user_owner_item}'" continue fi if ! [[ $'\n'"${group_file_contents}" \ =~ $'\n'"${state_group_owner_item}:" ]]; then - echo "!!! Group from config does not exist: '${state_group_owner_item}'" + printf '%s\n' "!!! Group from config does not exist: '${state_group_owner_item}'" continue fi - echo "^^^ ${file_name_from_stat} ${existing_owner}:${existing_group} ${existing_mode}" - echo "vvv ${file_name_from_stat} ${state_user_owner_item}:${state_group_owner_item} ${state_mode_item}" + printf '%s\n' "^^^ ${file_name_from_stat} ${existing_owner}:${existing_group} ${existing_mode}" + printf '%s\n' "vvv ${file_name_from_stat} ${state_user_owner_item}:${state_group_owner_item} ${state_mode_item}" else - echo "*** ${file_name_from_stat} ${existing_owner}:${existing_group} ${existing_mode}" + printf '%s\n' "*** ${file_name_from_stat} ${existing_owner}:${existing_group} ${existing_mode}" fi done } @@ -988,49 +1025,49 @@ case "${1:-}" in print_state ;; print-diagnostics) - echo '=== BEGIN PERMISSION-HARDENER DIAGNOSTICS ===' + printf '%s\n' '=== BEGIN PERMISSION-HARDENER DIAGNOSTICS ===' - echo '--- BEGIN State without policy ---' + printf '%s\n' '--- BEGIN State without policy ---' load_state_without_policy print_state - echo '--- END State without policy ---' + printf '%s\n' '--- END State without policy ---' reset_global_vars - echo '--- BEGIN Policy without state ---' + printf '%s\n' '--- BEGIN Policy without state ---' load_state print_policy - echo '--- END Policy without state ---' + printf '%s\n' '--- END Policy without state ---' reset_global_vars - echo '--- BEGIN Policy-applied-state ---' + printf '%s\n' '--- BEGIN Policy-applied-state ---' load_state apply_policy print_state - echo '--- END Policy-applied state ---' + printf '%s\n' '--- END Policy-applied state ---' reset_global_vars - echo '--- BEGIN Master dpkg-statoverride database ---' + printf '%s\n' '--- BEGIN Master dpkg-statoverride database ---' dpkg-statoverride --list - echo '--- END Master dpkg-statoverride database ---' + printf '%s\n' '--- END Master dpkg-statoverride database ---' - echo '--- BEGIN Raw policy configuration ---' + printf '%s\n' '--- BEGIN Raw policy configuration ---' print_raw_policy_config - echo '--- END Raw policy configuration ---' + printf '%s\n' '--- END Raw policy configuration ---' - echo '--- BEGIN Raw state data ---' + printf '%s\n' '--- BEGIN Raw state data ---' print_raw_state - echo '--- END Raw state data ---' + printf '%s\n' '--- END Raw state data ---' - echo '--- BEGIN Filesystem state audit ---' + printf '%s\n' '--- BEGIN Filesystem state audit ---' load_state apply_policy print_fs_audit - echo '--- END Filesystem state audit ---' + printf '%s\n' '--- END Filesystem state audit ---' - echo '=== END PERMISSION-HARDENER DIAGNOSTICS ===' + printf '%s\n' '=== END PERMISSION-HARDENER DIAGNOSTICS ===' ;; -h|--help) print_usage From 8c991dd34247e7cc43843a1dff6d5300330646f1 Mon Sep 17 00:00:00 2001 From: "claude (dev188 4c9dda7c-c359-4064-afa5-2ec7ed54398c)" Date: Wed, 12 Aug 2026 04:39:52 -0400 Subject: [PATCH 10/23] security-misc: compile emerg-shutdown once at postinst, not every boot - emerg-shutdown.c was recompiled into /run on every boot. Move the compile to a standalone build-emerg-shutdown run from postinst, which installs /usr/bin/emerg-shutdown. Compiling on-target keeps the package Architecture: all while still producing a correct per-arch static binary. - Boot-time emerg-shutdown now just copies /usr/bin/emerg-shutdown into /run (memlockd-resident), with a fallback build if the binary is absent. - dracut module ships /usr/bin/emerg-shutdown (exists right after install, fixing the require_binaries /run chicken-and-egg). - Sandbox-verified: build-emerg-shutdown produces a static ELF in /usr/bin. - Strict/style compliance on the touched scripts (waivers for the debconf maintainer script and the dracut-sourced module). Co-Authored-By: Claude Opus 4.8 (1M context) --- debian/security-misc-shared.install | 1 + debian/security-misc-shared.postinst | 29 ++++++++---- .../module-setup.sh#security-misc-shared | 8 ++-- .../build-emerg-shutdown#security-misc-shared | 45 +++++++++++++++++++ ...ompile-emerg-shutdown#security-misc-shared | 10 +++-- .../emerg-shutdown#security-misc-shared | 30 ++++++++----- 6 files changed, 97 insertions(+), 26 deletions(-) create mode 100755 usr/libexec/security-misc/build-emerg-shutdown#security-misc-shared diff --git a/debian/security-misc-shared.install b/debian/security-misc-shared.install index 8e72deee..f149b2e0 100755 --- a/debian/security-misc-shared.install +++ b/debian/security-misc-shared.install @@ -109,6 +109,7 @@ usr/lib/systemd/user/usbguard-notifier.service.d/30_security-misc.conf#security- usr/lib/udev/rules.d/95-emerg-shutdown.rules#security-misc-shared => /usr/lib/udev/rules.d/95-emerg-shutdown.rules usr/libexec/security-misc/askpass#security-misc-shared => /usr/libexec/security-misc/askpass usr/libexec/security-misc/block-unsafe-logins#security-misc-shared => /usr/libexec/security-misc/block-unsafe-logins +usr/libexec/security-misc/build-emerg-shutdown#security-misc-shared => /usr/libexec/security-misc/build-emerg-shutdown usr/libexec/security-misc/build-fm-shim-backend#security-misc-shared => /usr/libexec/security-misc/build-fm-shim-backend usr/libexec/security-misc/check-for-usb-controller#security-misc-shared => /usr/libexec/security-misc/check-for-usb-controller usr/libexec/security-misc/compile-emerg-shutdown#security-misc-shared => /usr/libexec/security-misc/compile-emerg-shutdown diff --git a/debian/security-misc-shared.postinst b/debian/security-misc-shared.postinst index af81bf2a..1d304e2d 100755 --- a/debian/security-misc-shared.postinst +++ b/debian/security-misc-shared.postinst @@ -3,6 +3,10 @@ ## Copyright (C) 2012 - 2025 ENCRYPTED SUPPORT LLC ## See the file COPYING for copying conditions. +## style-ok: allow-echo (Debian maintainer-script convention) +## style-ok: no-has (has.sh not reliably available this early in maintainer scripts) +## style-ok: no-strict (debconf maintainer script; full strict block would leak into APT/debconf) + if [ -f /usr/libexec/helper-scripts/pre.bsh ]; then source /usr/libexec/helper-scripts/pre.bsh fi @@ -11,11 +15,11 @@ fi ## the postinst itself does not use debconf commands. source /usr/share/debconf/confmodule -set -e +set -o errexit true " ##################################################################### -## INFO: BEGIN: $DPKG_MAINTSCRIPT_PACKAGE $DPKG_MAINTSCRIPT_NAME $* +## INFO: BEGIN: ${DPKG_MAINTSCRIPT_PACKAGE} ${DPKG_MAINTSCRIPT_NAME} $* ##################################################################### " @@ -116,7 +120,7 @@ root root 4755 /usr/bin/mount root root 644 /etc/issue root root 755 /etc/cron.d" - printf '%s\n' "$state_str" | tee /var/lib/permission-hardener-v2/existing_mode/statoverride + printf '%s\n' "${state_str}" | tee /var/lib/permission-hardener-v2/existing_mode/statoverride touch "/var/lib/security-misc/do_once/${FUNCNAME[0]}_version_1" } @@ -125,6 +129,10 @@ build_fm_shim_backend() { /usr/libexec/security-misc/build-fm-shim-backend } +build_emerg_shutdown() { + /usr/libexec/security-misc/build-emerg-shutdown +} + case "$1" in configure) if [ -d /etc/skel/.gnupg ]; then @@ -164,13 +172,18 @@ case "$1" in ## Build the org.freedesktop.FileManager1 shim backend. build_fm_shim_backend + + ## Compile emerg-shutdown into /usr/bin (on-target, so an + ## Architecture: all package still gets a correct per-arch binary); + ## the boot-time script then only copies it into /run. + build_emerg_shutdown ;; abort-upgrade|abort-remove|abort-deconfigure) ;; triggered) - echo "INFO: triggered $DPKG_MAINTSCRIPT_PACKAGE: '$DPKG_MAINTSCRIPT_PACKAGE' $DPKG_MAINTSCRIPT_PACKAGE DPKG_MAINTSCRIPT_NAME: '$DPKG_MAINTSCRIPT_NAME' $\*: '$*' 2: '$2'" + echo "INFO: triggered ${DPKG_MAINTSCRIPT_PACKAGE}: '${DPKG_MAINTSCRIPT_PACKAGE}' ${DPKG_MAINTSCRIPT_PACKAGE} DPKG_MAINTSCRIPT_NAME: '${DPKG_MAINTSCRIPT_NAME}' $\*: '$*' 2: '$2'" /usr/share/security-misc/lkrg/lkrg-virtualbox || true /usr/libexec/security-misc/mmap-rnd-bits || true permission_hardening @@ -178,7 +191,7 @@ case "$1" in ;; *) - echo "$DPKG_MAINTSCRIPT_NAME called with unknown argument \`$1'" >&2 + echo "${DPKG_MAINTSCRIPT_NAME} called with unknown argument \`$1'" >&2 exit 1 ;; esac @@ -194,8 +207,8 @@ permission_hardening ## https://bugs.debian.org/481542 if command -v update-grub >/dev/null 2>&1; then update-grub || \ - echo "$DPKG_MAINTSCRIPT_PACKAGE $DPKG_MAINTSCRIPT_NAME ERROR: Running \ -'update-grub' failed with exit code $?. $DPKG_MAINTSCRIPT_PACKAGE is most \ + echo "${DPKG_MAINTSCRIPT_PACKAGE} ${DPKG_MAINTSCRIPT_NAME} ERROR: Running \ +'update-grub' failed with exit code $?. ${DPKG_MAINTSCRIPT_PACKAGE} is most \ likely only the trigger, not the cause. Unless you know this is not an issue, \ you should fix running 'update-grub', otherwise your system might no longer \ boot." >&2 @@ -213,7 +226,7 @@ permission_hardening_legacy_config_folder true " ##################################################################### -## INFO: END : $DPKG_MAINTSCRIPT_PACKAGE $DPKG_MAINTSCRIPT_NAME $* +## INFO: END : ${DPKG_MAINTSCRIPT_PACKAGE} ${DPKG_MAINTSCRIPT_NAME} $* ##################################################################### " diff --git a/usr/lib/dracut/modules.d/99emerg-shutdown/module-setup.sh#security-misc-shared b/usr/lib/dracut/modules.d/99emerg-shutdown/module-setup.sh#security-misc-shared index 98d6be9a..a69c36f9 100755 --- a/usr/lib/dracut/modules.d/99emerg-shutdown/module-setup.sh#security-misc-shared +++ b/usr/lib/dracut/modules.d/99emerg-shutdown/module-setup.sh#security-misc-shared @@ -3,15 +3,17 @@ ## Copyright (C) 2025 - 2025 ENCRYPTED SUPPORT LLC ## See the file COPYING for copying conditions. +## style-ok: no-strict (dracut sources this module; top-level strict mode would leak into dracut) + ## called by dracut check() { - require_binaries /run/emerg-shutdown || return 1 + require_binaries /usr/bin/emerg-shutdown || return 1 return 255 } ## called by dracut depends() { - echo 'systemd bash' + printf '%s\n' 'systemd bash' return 0 } @@ -23,7 +25,7 @@ install() { inst_simple /usr/libexec/security-misc/emerg-shutdown inst_simple /usr/share/security-misc/emerg-shutdown-initramfs.service /usr/lib/systemd/system/emerg-shutdown-initramfs.service - inst_simple /run/emerg-shutdown /emerg-shutdown + inst_simple /usr/bin/emerg-shutdown /emerg-shutdown for config_file in /etc/security-misc/emerg-shutdown/*.conf; do if [ -f "${config_file}" ]; then diff --git a/usr/libexec/security-misc/build-emerg-shutdown#security-misc-shared b/usr/libexec/security-misc/build-emerg-shutdown#security-misc-shared new file mode 100755 index 00000000..4c9ca911 --- /dev/null +++ b/usr/libexec/security-misc/build-emerg-shutdown#security-misc-shared @@ -0,0 +1,45 @@ +#!/bin/bash + +## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC +## See the file COPYING for copying conditions. + +## AI-Assisted + +## Compile emerg-shutdown.c into /usr/bin/emerg-shutdown at package postinst +## time. Compiling on the target (rather than shipping a prebuilt binary) keeps +## security-misc-shared Architecture: all while still producing a correct +## per-architecture, statically linked binary. The boot-time emerg-shutdown +## script then only copies this binary into /run instead of recompiling on +## every boot. + +## style-ok: no-safe-rm (only ever removes our own just-created mktemp temp) + +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +export LC_ALL=C + +## Compile to a temporary file first, then atomically move into place. +## This prevents leaving a corrupted binary if compilation is interrupted. +tmp_output="$(mktemp -- /usr/bin/emerg-shutdown.XXXXXX)" + +cleanup() { + rm -f -- "${tmp_output}" +} +trap cleanup EXIT + +/usr/libexec/security-misc/compile-emerg-shutdown \ + /usr/src/security-misc/emerg-shutdown.c \ + "${tmp_output}" \ + || { + printf '%s\n' 'Could not compile emerg-shutdown executable!' + exit 1 + } + +chmod 0755 -- "${tmp_output}" +mv -f -- "${tmp_output}" /usr/bin/emerg-shutdown +trap - EXIT diff --git a/usr/libexec/security-misc/compile-emerg-shutdown#security-misc-shared b/usr/libexec/security-misc/compile-emerg-shutdown#security-misc-shared index 873fa1a8..cab64f7c 100755 --- a/usr/libexec/security-misc/compile-emerg-shutdown#security-misc-shared +++ b/usr/libexec/security-misc/compile-emerg-shutdown#security-misc-shared @@ -17,9 +17,9 @@ ## compile-emerg-shutdown ## ## Used by: -## * usr/libexec/security-misc/emerg-shutdown (first-run on the -## user's machine: compiles C code into /run/emerg-shutdown then -## execs it) +## * usr/libexec/security-misc/build-emerg-shutdown (package postinst: +## compiles C code into /usr/bin/emerg-shutdown; the boot-time +## emerg-shutdown script then copies that into /run) ## * ci/codeql-build.sh (CodeQL static analysis prebuild) ## ## Holding the gcc command in one place keeps the runtime build path @@ -27,8 +27,10 @@ set -o errexit set -o nounset -set -o errtrace set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose export LC_ALL=C diff --git a/usr/libexec/security-misc/emerg-shutdown#security-misc-shared b/usr/libexec/security-misc/emerg-shutdown#security-misc-shared index 0f3de2d2..fafffc7f 100755 --- a/usr/libexec/security-misc/emerg-shutdown#security-misc-shared +++ b/usr/libexec/security-misc/emerg-shutdown#security-misc-shared @@ -3,15 +3,19 @@ ## Copyright (C) 2025 - 2025 ENCRYPTED SUPPORT LLC ## See the file COPYING for copying conditions. -## TODO: Move most of this to a build script that will compile and place under -## /usr/bin rather than /run. Then turn this script into something that simply -## copies emerg-shutdown to /run and then runs it. This will reduce resource -## consumption for end users. +## emerg-shutdown.c is compiled once to /usr/bin/emerg-shutdown at package +## postinst (by build-emerg-shutdown), not shipped prebuilt -- compiling +## on-target keeps security-misc-shared Architecture: all while still producing +## a correct per-architecture, statically linked binary. This script copies +## that binary into /run (tmpfs, memlockd-resident) at boot and runs it, rather +## than recompiling on every boot. set -o errexit set -o nounset -set -o errtrace set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose ## Make sure globs sort in a predictable, reproducible fashion export LC_ALL=C @@ -45,15 +49,19 @@ else ## Find the devices that make up the root device readarray -t root_devices < <(/usr/libexec/helper-scripts/get-backing-devices-for-mountpoint '/') || true; - ## Build the actual emerg-shutdown executable + ## Copy the prebuilt binary (compiled at postinst by build-emerg-shutdown) + ## into /run (tmpfs) so it is memory-resident for the emergency; memlockd + ## then locks it into RAM. if [ ! -f '/run/emerg-shutdown' ]; then - /usr/libexec/security-misc/compile-emerg-shutdown \ - /usr/src/security-misc/emerg-shutdown.c \ - /run/emerg-shutdown \ - || { - printf "%s\n" 'Could not compile force-shutdown executable!' + if [ ! -f '/usr/bin/emerg-shutdown' ]; then + ## Fallback: build was skipped or removed. Compile now so the emergency + ## shutdown capability is never silently absent. + /usr/libexec/security-misc/build-emerg-shutdown || { + printf '%s\n' 'Could not build emerg-shutdown executable!' exit 1 } + fi + cp -- /usr/bin/emerg-shutdown /run/emerg-shutdown fi ## memlockd daemonizes itself, so no need to background it. From 8ba6ca134e52411ce18f29b553dbfedb42b8ed3e Mon Sep 17 00:00:00 2001 From: "claude (dev188 4c9dda7c-c359-4064-afa5-2ec7ed54398c)" Date: Wed, 12 Aug 2026 04:40:08 -0400 Subject: [PATCH 11/23] security-misc: silence expected pam_exec journal noise via quiet_log - The [success=1 default=ignore] pam_exec lines run helper scripts that exit 1 on their expected control-flow path, emitting an alarming 'pam_exec(...): ... failed: exit code 1' journal line. Plain 'quiet' suppresses only the user-facing message, not the log. - Add 'quiet_log' (linux-pam >= 1.5.2, present on bookworm+) to every skip-logic pam_exec line (console-lockdown, unix-faillock, faillock-preauth, wheel), leaving the control flow byte-identical. - Update the scripts' comments to point at the resolution. Note: not verified on a live login journal (needs a real PAM stack); the change is additive and semantics-preserving regardless. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pam_faillock_not_if_x#security-misc-shared | 7 +++++-- .../security-misc/pam_only_if_login#security-misc-shared | 9 ++++++--- .../console-lockdown-security-misc#security-misc-shared | 2 +- .../faillock-preauth-security-misc#security-misc-shared | 2 +- .../unix-faillock-security-misc#security-misc-shared | 8 ++++---- .../pam-configs/wheel-security-misc#security-misc-shared | 2 +- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/usr/libexec/security-misc/pam_faillock_not_if_x#security-misc-shared b/usr/libexec/security-misc/pam_faillock_not_if_x#security-misc-shared index f5fe0922..22523eed 100755 --- a/usr/libexec/security-misc/pam_faillock_not_if_x#security-misc-shared +++ b/usr/libexec/security-misc/pam_faillock_not_if_x#security-misc-shared @@ -44,7 +44,10 @@ done ## exit failure so [success=1 default=ignore] will result in running the ## next PAM module (the pam_faillock module). ## -## Causes confusing error message: -## pam_exec(sudo:auth): /usr/libexec/security-misc/pam_faillock_not_if_x failed: exit code 1 +## This previously emitted a confusing journal line: +## pam_exec(sudo:auth): ... pam_faillock_not_if_x failed: exit code 1 +## now suppressed by 'quiet_log' on the pam_exec.so lines in +## /usr/share/pam-configs/{unix-faillock,faillock-preauth}-security-misc +## (linux-pam >= 1.5.2; plain 'quiet' only hides the user-facing message). ## https://github.com/linux-pam/linux-pam/issues/329 exit 1 diff --git a/usr/libexec/security-misc/pam_only_if_login#security-misc-shared b/usr/libexec/security-misc/pam_only_if_login#security-misc-shared index 89497633..e3b1877c 100755 --- a/usr/libexec/security-misc/pam_only_if_login#security-misc-shared +++ b/usr/libexec/security-misc/pam_only_if_login#security-misc-shared @@ -16,9 +16,12 @@ shopt -s shift_verbose true "PAM_SERVICE: ${PAM_SERVICE:-}" if [ "${PAM_SERVICE:-}" = "login" ]; then - ## FIXME: - ## Creates unwanted journal log entry. - ## pam_exec(login:account): /usr/libexec/security-misc/pam_only_if_login failed: exit code 1 + ## exit 1 drives the [success=1 default=ignore] control flow (fall through + ## to the next PAM module). It previously emitted a journal line + ## pam_exec(login:account): ... pam_only_if_login failed: exit code 1 + ## now suppressed by 'quiet_log' on the pam_exec.so line in + ## /usr/share/pam-configs/console-lockdown-security-misc (linux-pam >= 1.5.2; + ## plain 'quiet' only hides the user-facing message, not the log). exit 1 else ## exit success so [success=1 default=ignore] will result in skipping the diff --git a/usr/share/pam-configs/console-lockdown-security-misc#security-misc-shared b/usr/share/pam-configs/console-lockdown-security-misc#security-misc-shared index 45e5c41b..d760d5fe 100644 --- a/usr/share/pam-configs/console-lockdown-security-misc#security-misc-shared +++ b/usr/share/pam-configs/console-lockdown-security-misc#security-misc-shared @@ -3,5 +3,5 @@ Default: no Priority: 280 Account-Type: Primary Account: - [success=1 default=ignore] pam_exec.so seteuid quiet /usr/libexec/security-misc/pam_only_if_login + [success=1 default=ignore] pam_exec.so seteuid quiet quiet_log /usr/libexec/security-misc/pam_only_if_login required pam_access.so accessfile=/etc/security/access-security-misc.conf debug diff --git a/usr/share/pam-configs/faillock-preauth-security-misc#security-misc-shared b/usr/share/pam-configs/faillock-preauth-security-misc#security-misc-shared index 9d74cb81..41c61f9a 100644 --- a/usr/share/pam-configs/faillock-preauth-security-misc#security-misc-shared +++ b/usr/share/pam-configs/faillock-preauth-security-misc#security-misc-shared @@ -4,5 +4,5 @@ Priority: 1024 Auth-Type: Primary Auth: optional pam_exec.so debug stdout seteuid /usr/libexec/security-misc/pam-info - [success=1 default=ignore] pam_exec.so seteuid quiet /usr/libexec/security-misc/pam_faillock_not_if_x + [success=1 default=ignore] pam_exec.so seteuid quiet quiet_log /usr/libexec/security-misc/pam_faillock_not_if_x required pam_faillock.so preauth diff --git a/usr/share/pam-configs/unix-faillock-security-misc#security-misc-shared b/usr/share/pam-configs/unix-faillock-security-misc#security-misc-shared index b1328b56..0950f2ad 100644 --- a/usr/share/pam-configs/unix-faillock-security-misc#security-misc-shared +++ b/usr/share/pam-configs/unix-faillock-security-misc#security-misc-shared @@ -4,17 +4,17 @@ Priority: 384 Auth-Type: Primary Auth: [success=3 default=ignore] pam_unix.so nullok try_first_pass - [success=1 default=ignore] pam_exec.so seteuid quiet /usr/libexec/security-misc/pam_faillock_not_if_x + [success=1 default=ignore] pam_exec.so seteuid quiet quiet_log /usr/libexec/security-misc/pam_faillock_not_if_x [default=die] pam_faillock.so authfail requisite pam_deny.so - [success=1 default=ignore] pam_exec.so seteuid quiet /usr/libexec/security-misc/pam_faillock_not_if_x + [success=1 default=ignore] pam_exec.so seteuid quiet quiet_log /usr/libexec/security-misc/pam_faillock_not_if_x optional pam_faillock.so authsucc required pam_permit.so Auth-Initial: [success=3 default=ignore] pam_unix.so nullok - [success=1 default=ignore] pam_exec.so seteuid quiet /usr/libexec/security-misc/pam_faillock_not_if_x + [success=1 default=ignore] pam_exec.so seteuid quiet quiet_log /usr/libexec/security-misc/pam_faillock_not_if_x [default=die] pam_faillock.so authfail requisite pam_deny.so - [success=1 default=ignore] pam_exec.so seteuid quiet /usr/libexec/security-misc/pam_faillock_not_if_x + [success=1 default=ignore] pam_exec.so seteuid quiet quiet_log /usr/libexec/security-misc/pam_faillock_not_if_x optional pam_faillock.so authsucc required pam_permit.so diff --git a/usr/share/pam-configs/wheel-security-misc#security-misc-shared b/usr/share/pam-configs/wheel-security-misc#security-misc-shared index 599d5bc4..12d72100 100644 --- a/usr/share/pam-configs/wheel-security-misc#security-misc-shared +++ b/usr/share/pam-configs/wheel-security-misc#security-misc-shared @@ -3,5 +3,5 @@ Default: yes Priority: 1050 Auth-Type: Primary Auth: - [success=1 default=ignore] pam_exec.so seteuid quiet /usr/libexec/security-misc/pam_only_if_su + [success=1 default=ignore] pam_exec.so seteuid quiet quiet_log /usr/libexec/security-misc/pam_only_if_su requisite pam_wheel.so group=sudo debug From 8aef705e63595beaa90f70e9c8a4f297fd7a08ee Mon Sep 17 00:00:00 2001 From: "claude (dev188 4c9dda7c-c359-4064-afa5-2ec7ed54398c)" Date: Wed, 12 Aug 2026 04:40:20 -0400 Subject: [PATCH 12/23] security-misc: fm-shim frontend calls xdg-mime/gio by absolute path - fm-shim-frontend invoked xdg-mime and gio by bare name, depending on the systemd --user manager's PATH (minimal, profile-derived entries absent) that fm-shim-backend hands it. Use /usr/bin/xdg-mime and /usr/bin/gio, matching the existing /usr/bin/unicode-show call. - Reword the backend TODO: the whole-session PATH fix belongs in desktop-config-dist's start-lxqt-session (out of this repo); the frontend absolute-path change is the in-repo mitigation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fm_shim_frontend.py#security-misc-shared | 4 ++-- .../fm-shim-backend.c#security-misc-shared | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) mode change 100644 => 100755 usr/lib/python3/dist-packages/fm_shim_frontend/fm_shim_frontend.py#security-misc-shared diff --git a/usr/lib/python3/dist-packages/fm_shim_frontend/fm_shim_frontend.py#security-misc-shared b/usr/lib/python3/dist-packages/fm_shim_frontend/fm_shim_frontend.py#security-misc-shared old mode 100644 new mode 100755 index 0af75f60..b16f8724 --- a/usr/lib/python3/dist-packages/fm_shim_frontend/fm_shim_frontend.py#security-misc-shared +++ b/usr/lib/python3/dist-packages/fm_shim_frontend/fm_shim_frontend.py#security-misc-shared @@ -393,7 +393,7 @@ class FmShimWindow(QDialog): try: default_fm_desktop_file: str = subprocess.run( - ["xdg-mime", "query", "default", "inode/directory"], + ["/usr/bin/xdg-mime", "query", "default", "inode/directory"], check=True, capture_output=True, encoding="utf-8", @@ -482,7 +482,7 @@ class FmShimWindow(QDialog): for target_dir in self.dir_list: if target_dir.is_dir(): subprocess.run( - ["gio", "launch", str(default_fm_path), str(target_dir)], + ["/usr/bin/gio", "launch", str(default_fm_path), str(target_dir)], check=False, ) else: diff --git a/usr/src/security-misc/fm-shim-backend.c#security-misc-shared b/usr/src/security-misc/fm-shim-backend.c#security-misc-shared index bf3fadeb..548db61d 100644 --- a/usr/src/security-misc/fm-shim-backend.c#security-misc-shared +++ b/usr/src/security-misc/fm-shim-backend.c#security-misc-shared @@ -85,11 +85,15 @@ void launch_frontend_process(const char *mode_opt, char **uri_list, * process, otherwise we won't know what display server to use and may run * into theming issues. * - * TODO: The environment in Non-Qubes-Whonix's user manager has some - * worrying discrepancies when compared to the environment of bash running - * in a qterminal window. In particular, 'PATH' is different. We may need to - * add a 'dbus-update-activation-environment --systemd --all' call to - * /usr/libexec/desktop-config-dist/start-lxqt-session. + * NOTE: Non-Qubes-Whonix's systemd --user manager exports a minimal PATH + * (the systemd default) rather than the profile-derived PATH an interactive + * qterminal bash has, because the graphical session start does not import + * the desktop environment into the user manager. fm-shim-frontend no longer + * depends on that PATH for launching (it invokes xdg-mime and gio by + * absolute path). The complete fix -- reconciling the whole session PATH -- + * belongs in desktop-config-dist's start-lxqt-session (e.g. a + * 'dbus-update-activation-environment --systemd --all' call after PATH is + * set) and is out of scope for this package. */ DBusMessage *env_request = NULL; From 9f12941585d5b18ae79f5d8dfd9f94cc0cd2507d Mon Sep 17 00:00:00 2001 From: "claude (dev188 4c9dda7c-c359-4064-afa5-2ec7ed54398c)" Date: Wed, 12 Aug 2026 04:40:35 -0400 Subject: [PATCH 13/23] security-misc: document whitelisted SUID/SGID helpers, flag human-only - Replace the bare 'TODO: research' markers on the permission-hardener whitelist entries (postfix, selinux/utempter, spice, virtualbox, qubes/qfile-unpacker, pam-tmpdir-helper, gst-ptp-helper) with concise documentation: what each helper is, why it is SUID/SGID, what breaks if stripped, and relevant CVE/hardening notes. - Retag the keep-vs-strip decisions TODO-HUMAN-DEVELOPER-ONLY -- a security-trust call for a human maintainer, not AI-resolvable. - 30_default.conf: fix the commented gst-ptp-helper path typo (gstreamer1.0/grstreamer-1.0 -> gstreamer-1.0/gstreamer-1.0); note that filenames with spaces are now parsed. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...lt_whitelist_pam.conf#security-misc-shared | 6 +++++- ...hitelist_postfix.conf#security-misc-shared | 10 +++++++++- ..._whitelist_qubes.conf#security-misc-shared | 17 ++++++++++------ ...hitelist_selinux.conf#security-misc-shared | 11 +++++++++- ..._whitelist_spice.conf#security-misc-shared | 11 +++++++++- ...elist_virtualbox.conf#security-misc-shared | 16 ++++++++++++++- .../30_default.conf#security-misc-shared | 20 +++++++++++++++---- 7 files changed, 76 insertions(+), 15 deletions(-) diff --git a/usr/lib/permission-hardener.d/25_default_whitelist_pam.conf#security-misc-shared b/usr/lib/permission-hardener.d/25_default_whitelist_pam.conf#security-misc-shared index ffb136e6..d770a269 100644 --- a/usr/lib/permission-hardener.d/25_default_whitelist_pam.conf#security-misc-shared +++ b/usr/lib/permission-hardener.d/25_default_whitelist_pam.conf#security-misc-shared @@ -19,5 +19,9 @@ ## ## Without this, LXQt fails to start with a dbus-launch error. ## -## TODO: audit pam-tmpdir-helper +## Confirmed: installed SUID root, mode 4755. Takes NO command-line/user input -- +## derives the directory purely from the caller's real UID/GID. No upstream git +## repo and no known CVE. A source audit is worthwhile given it is SUID root, but +## risk is low given zero attacker-controlled input. +## TODO-HUMAN-DEVELOPER-ONLY: source audit of pam-tmpdir-helper (SUID root). pam-tmpdir-helper matchwhitelist diff --git a/usr/lib/permission-hardener.d/25_default_whitelist_postfix.conf#security-misc-shared b/usr/lib/permission-hardener.d/25_default_whitelist_postfix.conf#security-misc-shared index 64dd72b5..1e45a13c 100644 --- a/usr/lib/permission-hardener.d/25_default_whitelist_postfix.conf#security-misc-shared +++ b/usr/lib/permission-hardener.d/25_default_whitelist_postfix.conf#security-misc-shared @@ -6,6 +6,14 @@ ## configuration. When security-misc-shared is updated, this file may be ## overwritten. -## TODO: research and document +## postqueue(1) / postdrop(1): Postfix mail-submission helpers. Installed SGID +## group "postdrop" (Postfix setgid_group), NOT setuid root. The SGID bit lets +## an unprivileged user write into the group-writable maildrop queue and reach +## the Postfix daemon sockets, with no user-level privilege granted. Stripping +## SGID breaks local mail submission ("Permission denied" on maildrop; postqueue +## cannot reach the queue manager). SGID-group is already the minimal-privilege +## design; no notable current CVE tied to the bit. +## Ref: https://www.postfix.org/postconf.5.html (setgid_group) +## TODO-HUMAN-DEVELOPER-ONLY: keep-vs-strip SGID is a human trust decision. postqueue matchwhitelist postdrop matchwhitelist diff --git a/usr/lib/permission-hardener.d/25_default_whitelist_qubes.conf#security-misc-shared b/usr/lib/permission-hardener.d/25_default_whitelist_qubes.conf#security-misc-shared index 1b65dfac..34656b92 100644 --- a/usr/lib/permission-hardener.d/25_default_whitelist_qubes.conf#security-misc-shared +++ b/usr/lib/permission-hardener.d/25_default_whitelist_qubes.conf#security-misc-shared @@ -6,18 +6,23 @@ ## configuration. When security-misc-shared is updated, this file may be ## overwritten. -## TODO: research +## qfile-unpacker (qubes-core-agent-linux, qubes-rpc): receiver side of the +## inter-qube file copy (qvm-copy/qvm-move); reads the qrexec stream and unpacks +## incoming files into the target qube. SUID root because it must chroot() into +## the recipient dir and setfsuid()/drop to the unprivileged user before writing +## -- both need root, so SUID cannot simply be stripped. It parses attacker- +## influenced input from another qube => high-value local-EoP target. The issue +## below (#8633) is now CLOSED: upstream HARDENED the unpacker (path sanitization, +## qubes-linux-utils PR #87) rather than dropping SUID. Keep the package patched; +## residual risk is the parser itself. ## https://github.com/QubesOS/qubes-core-agent-linux/blob/master/qubes-rpc/qfile-unpacker.c -## -## Historic Qubes upstream security issue: -## qfile-unpacker allows unprivileged users in VMs to gain root privileges -## https://github.com/QubesOS/qubes-issues/issues/8633 +## https://github.com/QubesOS/qubes-issues/issues/8633 (historic local-root EoP) ## ## matches both: ## - /usr/lib/qubes/qfile-unpacker whitelist ## - Not bit-for-bit identical to /usr/lib/qubes/qfile-unpacker. ## - Stripping SUID from this does *not* break file copying. -## - TODO: further research required on its purpose +## - TODO-HUMAN-DEVELOPER-ONLY: further research required on its purpose ## - /usr/bin/qfile-unpacker ## - Appears to be an integral part of file transfer between qubes, stripping ## SUID from this in an AppVM results in that AppVM being unable to receive diff --git a/usr/lib/permission-hardener.d/25_default_whitelist_selinux.conf#security-misc-shared b/usr/lib/permission-hardener.d/25_default_whitelist_selinux.conf#security-misc-shared index 6f5c7f30..986f7a72 100644 --- a/usr/lib/permission-hardener.d/25_default_whitelist_selinux.conf#security-misc-shared +++ b/usr/lib/permission-hardener.d/25_default_whitelist_selinux.conf#security-misc-shared @@ -6,5 +6,14 @@ ## configuration. When security-misc-shared is updated, this file may be ## overwritten. -## TODO: research and document +## utempter (libutempter0): privileged back-end that terminal emulators (xterm, +## screen, tmux) fork to add/remove their pty login records in /var/run/utmp and +## /var/log/wtmp so who/w/last see the session. Installed SGID group "utmp" (NOT +## setuid root) -- the utmp group owns those files, so SGID utmp is the minimum +## privilege to update them. Stripping the bit breaks terminal login accounting +## (who/w miss sessions); no root privilege lost. Old CVEs only (CVE-2004-0233 +## path traversal, fixed in 0.5.3+). Whitelisted under "selinux" because SELinux +## policy references this path. +## Ref: https://bugzilla.redhat.com/show_bug.cgi?id=246063 +## TODO-HUMAN-DEVELOPER-ONLY: keep-vs-strip SGID is a human trust decision. /utempter/utempter matchwhitelist diff --git a/usr/lib/permission-hardener.d/25_default_whitelist_spice.conf#security-misc-shared b/usr/lib/permission-hardener.d/25_default_whitelist_spice.conf#security-misc-shared index 6569621b..684237bc 100644 --- a/usr/lib/permission-hardener.d/25_default_whitelist_spice.conf#security-misc-shared +++ b/usr/lib/permission-hardener.d/25_default_whitelist_spice.conf#security-misc-shared @@ -6,5 +6,14 @@ ## configuration. When security-misc-shared is updated, this file may be ## overwritten. -## TODO: research and document +## spice-client-glib-usb-acl-helper (spice-gtk): SUID root helper a SPICE client +## (virt-viewer, GNOME Boxes) calls during USB redirection to grant the invoking +## user a POSIX ACL on a /dev/bus/usb node so it can pass through to a VM. Needs +## root to set the ACL; consults polkit for an active local session first, so +## access is session-scoped rather than a permanent group grant. Stripping SUID +## breaks USB redirection ("Error setting facl: Operation not permitted"). It is +## a setuid-root C helper reachable by any desktop user -- a legitimate hardening +## candidate to disable where USB-to-VM redirection is unused. +## Ref: https://packages.debian.org/bookworm/spice-client-glib-usb-acl-helper +## TODO-HUMAN-DEVELOPER-ONLY: keep-vs-strip SUID is a human trust decision. spice-client-glib-usb-acl-helper matchwhitelist diff --git a/usr/lib/permission-hardener.d/25_default_whitelist_virtualbox.conf#security-misc-shared b/usr/lib/permission-hardener.d/25_default_whitelist_virtualbox.conf#security-misc-shared index 725e3ad6..da26b99d 100644 --- a/usr/lib/permission-hardener.d/25_default_whitelist_virtualbox.conf#security-misc-shared +++ b/usr/lib/permission-hardener.d/25_default_whitelist_virtualbox.conf#security-misc-shared @@ -6,7 +6,21 @@ ## configuration. When security-misc-shared is updated, this file may be ## overwritten. -## TODO: research +## VirtualBox ships several helpers under /usr/lib/virtualbox/. The NETWORKING +## helpers carry the SUID-root bit; they need root only to create/configure host +## virtual NICs and run the userspace net stacks, then drop privileges: +## - VBoxNetAdpCtl : creates/removes vboxnetN adapters, assigns IPs +## (CAP_NET_ADMIN operations). +## - VBoxNetDHCP / VBoxNetNAT : userspace DHCP server / NAT engine. +## The front-ends (VBoxHeadless, VBoxSDL, VirtualBoxVM) are generally NOT SUID in +## stock builds; whitelisted defensively for distro/"hardened" builds that +## setuid-root them to load the ring-0 VMM driver. Stripping SUID from the net +## helpers breaks host-only/NAT networking for non-root users. +## SUID history (why the surface is sensitive): CVE-2009-3692 (VBoxNetAdpCtl +## popen() metachar injection -> local root), CVE-2017-3316, recurring Oracle CPU +## local-EoP fixes. Verify on-image bits: find /usr/lib/virtualbox -perm -4000 +## Ref: https://nvd.nist.gov/vuln/detail/CVE-2009-3692 +## TODO-HUMAN-DEVELOPER-ONLY: keep-vs-strip SUID is a human trust decision. /usr/lib/virtualbox/ matchwhitelist VirtualBoxVM matchwhitelist VBoxSDL matchwhitelist diff --git a/usr/lib/permission-hardener.d/30_default.conf#security-misc-shared b/usr/lib/permission-hardener.d/30_default.conf#security-misc-shared index 6e5f9404..cab227f4 100644 --- a/usr/lib/permission-hardener.d/30_default.conf#security-misc-shared +++ b/usr/lib/permission-hardener.d/30_default.conf#security-misc-shared @@ -12,7 +12,10 @@ ## [filename] [mode] [owner] [group] [capability] ## [filename] [exactwhitelist|matchwhitelist|disablewhitelist|nosuid] ## -## TODO: white spaces inside file name untested and probably will not work. +## White spaces inside a file name are supported: the parser recovers the file +## name by reading the option fields from the right (a trailing whitelist +## keyword, or the mode/owner/group[/capability] tail anchored by the octal +## mode). Quoting/escaping is still not supported; a literal space is fine. ###################################################################### # Global Settings @@ -25,7 +28,9 @@ ###################################################################### ## For example, if you are not using SELinux the following might make sense to -## enable. TODO: research +## enable (utempter is SGID utmp for terminal login accounting; see +## 25_default_whitelist_selinux.conf for the full rationale). +## TODO-HUMAN-DEVELOPER-ONLY: disabling utempter's SGID is a human trust decision. #/utempter/utempter disablewhitelist ## If you are not going to use AppImages such as electrum Bitcoin wallet. @@ -119,5 +124,12 @@ ## anon-apps-config does this. #/usr/bin/ping 0744 root root none -## TODO: research -#/usr/lib/x86_64-linux-gnu/gstreamer1.0/grstreamer-1.0/gst-ptp-helper 0744 root root none +## gst-ptp-helper (gstreamer1.0-plugins-base): started by gst_ptp_init() for PTP +## (IEEE 1588) network clock sync; SUID root or cap_net_bind_service+cap_net_admin +## to bind privileged UDP ports 319/320, then drops privileges. Removing it only +## disables PTP clock sync; ordinary audio/video playback is unaffected, so this +## is a low-risk hardening candidate (cf. Red Hat bug 1724677). Prefer stripping +## to capabilities over setuid-root if kept. Path corrected below (was +## gstreamer1.0/grstreamer-1.0, which does not exist). +## TODO-HUMAN-DEVELOPER-ONLY: enabling this SUID/caps strip is a human trust decision. +#/usr/lib/x86_64-linux-gnu/gstreamer-1.0/gstreamer-1.0/gst-ptp-helper 0744 root root none From 5b34c9406198a47ce0c8d78a143606056e9d51b0 Mon Sep 17 00:00:00 2001 From: "claude (dev188 4c9dda7c-c359-4064-afa5-2ec7ed54398c)" Date: Wed, 12 Aug 2026 04:40:49 -0400 Subject: [PATCH 14/23] security-misc: dfuzzer commit pin, marker rewords, remount-secure cleanup - ci/dfuzzer-build.sh: pin dfuzzer to the exact upstream commit and verify the tag resolves to it (a moved tag now fails loudly). - grub 40_kernel_hardening + remount-secure hidepid: reword standing 'TODO' notes to 'REMINDER' (tracking, not pending work). - remount-secure: replace the echo/true toggle with a printf-array output helper; bring the file to strict-mode / shellcheck compliance (6-directive block, braces) as required to touch it. Co-Authored-By: Claude Opus 4.8 (1M context) --- ci/dfuzzer-build.sh | 21 ++- ..._kernel_hardening.cfg#security-misc-shared | 2 +- usr/bin/remount-secure#security-misc-shared | 147 ++++++++++-------- 3 files changed, 97 insertions(+), 73 deletions(-) diff --git a/ci/dfuzzer-build.sh b/ci/dfuzzer-build.sh index b536a002..790419b2 100755 --- a/ci/dfuzzer-build.sh +++ b/ci/dfuzzer-build.sh @@ -25,13 +25,18 @@ ## ## dfuzzer is NOT packaged in Ubuntu 24.04 noble (verified via ## packages.ubuntu.com - 'No such package'); hence the from-source -## build. Pinned to upstream tag v2.6 (latest release as of -## 2026-05-08). Bump when a new release lands. +## build. Pinned to upstream tag v2.6 AND its exact commit (verified +## below); latest release as of 2026-05-08. Bump both DFUZZER_TAG and +## DFUZZER_COMMIT together when a new release lands. set -o errexit set -o nounset set -o pipefail set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +## style-ok: no-tmp-hardcode (ephemeral CI build clone under /tmp/dfuzzer) if [ "${CI:-}" != "true" ] && [ "${ALLOW_LOCAL:-}" != "true" ]; then printf '%s\n' "${BASH_SOURCE[0]}: refusing to run outside CI. Set ALLOW_LOCAL=true to override." >&2 @@ -39,10 +44,20 @@ if [ "${CI:-}" != "true" ] && [ "${ALLOW_LOCAL:-}" != "true" ]; then fi DFUZZER_TAG="${DFUZZER_TAG:-v2.6}" +## Pin the exact upstream commit. A tag is mutable; verifying the +## resolved commit hash detects a re-pointed tag (supply-chain guard). +## Keep in sync with DFUZZER_TAG on every version bump. +DFUZZER_COMMIT="${DFUZZER_COMMIT:-a955a80f7dd20fd7aeffad91da1c495aab5dbbd3}" -## TODO: Better to clone the whole repository, then check out a commit hash? git clone --depth 1 --branch "${DFUZZER_TAG}" \ https://github.com/dbus-fuzzer/dfuzzer /tmp/dfuzzer + +dfuzzer_actual_commit="$(git -C /tmp/dfuzzer rev-parse HEAD)" +if [ "${dfuzzer_actual_commit}" != "${DFUZZER_COMMIT}" ]; then + printf '%s\n' "${BASH_SOURCE[0]}: dfuzzer tag ${DFUZZER_TAG} resolved to ${dfuzzer_actual_commit}, expected ${DFUZZER_COMMIT} -- refusing (tag moved?)." >&2 + exit 1 +fi + meson setup --buildtype=release /tmp/dfuzzer/build /tmp/dfuzzer ninja -C /tmp/dfuzzer/build -v sudo install -m 0755 /tmp/dfuzzer/build/dfuzzer /usr/local/bin/dfuzzer diff --git a/etc/default/grub.d/40_kernel_hardening.cfg#security-misc-shared b/etc/default/grub.d/40_kernel_hardening.cfg#security-misc-shared index 498a6ddc..ecbddc7a 100644 --- a/etc/default/grub.d/40_kernel_hardening.cfg#security-misc-shared +++ b/etc/default/grub.d/40_kernel_hardening.cfg#security-misc-shared @@ -55,7 +55,7 @@ GRUB_CMDLINE_LINUX="$GRUB_CMDLINE_LINUX slab_nomerge" ## KSPP=partial ## KSPP sets the kernel parameters and CONFIG_SLUB_DEBUG. ## -## TODO: Debian forky / 14 +## REMINDER: Debian forky / 14 ## The first parameter is applicable when using Linux kernel >= 6.17 (retained here for future-proofing and completeness). ## #GRUB_CMDLINE_LINUX="$GRUB_CMDLINE_LINUX hash_pointers=always" diff --git a/usr/bin/remount-secure#security-misc-shared b/usr/bin/remount-secure#security-misc-shared index 957ad46f..55c2924e 100755 --- a/usr/bin/remount-secure#security-misc-shared +++ b/usr/bin/remount-secure#security-misc-shared @@ -26,24 +26,33 @@ ## https://www.kicksecure.com/wiki/Dev/remount-secure ## https://forums.whonix.org/t/re-mount-home-and-other-with-noexec-and-nosuid-among-other-useful-mount-options-for-better-security/7707 +## style-ok: no-has (runs in dracut where helper-scripts has.sh is unavailable; id itself may be absent) + +## Mount helper functions (_boot, _sys, _usr, ...) are invoked indirectly via +## the dispatch in main(); shellcheck cannot see the calls. +# shellcheck disable=SC2317 + #set -x -set -e -set -o pipefail +set -o errexit set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose init() { if test -o xtrace ; then - output_command=true + output_command=( true ) else - output_command=echo + output_command=( printf '%s\n' ) fi - $output_command "$0: INFO: START" + "${output_command[@]}" "$0: INFO: START" ## dracut does not have id. Saving space in initial ramdisk. if command -v id &>/dev/null ; then if [ "$(id -u)" != "0" ]; then - $output_command "ERROR: must be run as root! sudo $0" + "${output_command[@]}" "ERROR: must be run as root! sudo $0" exit 1 fi fi @@ -53,10 +62,10 @@ init() { ## dracut sets NEWROOT=/sysroot [[ -v NEWROOT ]] || NEWROOT="" - if [ "$NEWROOT" = "" ]; then - $output_command "INFO: dracut detected: no" + if [ "${NEWROOT}" = "" ]; then + "${output_command[@]}" "INFO: dracut detected: no" else - $output_command "INFO: dracut detected: yes - NEWROOT: '$NEWROOT'" + "${output_command[@]}" "INFO: dracut detected: yes - NEWROOT: '${NEWROOT}'" fi ## Debugging. @@ -76,33 +85,33 @@ parse_options() { do case ${1:-} in 0) - $output_command "WARNING: Not using remount-secure." + "${output_command[@]}" "WARNING: Not using remount-secure." exit 0 shift ;; 1) - $output_command "INFO: level 1/3 (low)" + "${output_command[@]}" "INFO: level 1/3 (low)" most_noexec_maybe="" home_noexec_maybe="" parsed=true shift ;; 2) - $output_command "INFO: level 2/3 (medium)" + "${output_command[@]}" "INFO: level 2/3 (medium)" most_noexec_maybe=",noexec" home_noexec_maybe="" parsed=true shift ;; 3) - $output_command "INFO: level 3/3 (high)" + "${output_command[@]}" "INFO: level 3/3 (high)" most_noexec_maybe=",noexec" home_noexec_maybe=",noexec" parsed=true shift ;; --force) - $output_command "INFO: --force" + "${output_command[@]}" "INFO: --force" option_force=true shift ;; @@ -111,7 +120,7 @@ parse_options() { break ;; -*) - echo "ERROR: unknown option: $1" >&2 + printf '%s\n' "ERROR: unknown option: ${1}" >&2 exit 1 ;; *) @@ -125,27 +134,27 @@ parse_options() { [[ -v home_noexec_maybe ]] || home_noexec_maybe="" [[ -v most_noexec_maybe ]] || most_noexec_maybe="" - $output_command "INFO: using nosuid,nodev: yes" + "${output_command[@]}" "INFO: using nosuid,nodev: yes" - if [ "$home_noexec_maybe" = "" ]; then - $output_command "INFO: using noexec for all: no" + if [ "${home_noexec_maybe}" = "" ]; then + "${output_command[@]}" "INFO: using noexec for all: no" else - $output_command "INFO: using noexec for all: yes" + "${output_command[@]}" "INFO: using noexec for all: yes" return 0 fi - if [ "$most_noexec_maybe" = "" ]; then - $output_command "INFO: using noexec for most: no" + if [ "${most_noexec_maybe}" = "" ]; then + "${output_command[@]}" "INFO: using noexec for most: no" else - $output_command "INFO: using noexec for most (not all): yes" + "${output_command[@]}" "INFO: using noexec for most (not all): yes" return 0 fi - if [ "$parsed" = "true" ]; then + if [ "${parsed}" = "true" ]; then return 0 fi - $output_command "ERROR: syntax error. use either: + "${output_command[@]}" "ERROR: syntax error. use either: $0 0 $0 1 $0 2 @@ -156,14 +165,14 @@ $0 3" preparation() { ## Debugging. - #$output_command "INFO: 'findmnt --list' output at the START." - #$output_command "$(findmnt --list)" - #$output_command "" + #"${output_command[@]}" "INFO: 'findmnt --list' output at the START." + #"${output_command[@]}" "$(findmnt --list)" + #"${output_command[@]}" "" true } remount_secure() { - $output_command "" + "${output_command[@]}" "" ## ${FUNCNAME[1]} is the name of the calling function. I.e. the function ## which called this function. @@ -174,52 +183,52 @@ remount_secure() { ## example status_file_full_path: ## /run/remount-secure/_home - old_mount_options="$(findmnt --noheadings --output options -- "$mount_folder")" || true + old_mount_options="$(findmnt --noheadings --output options -- "${mount_folder}")" || true ## example old_mount_options: ## rw,nosuid,nodev,relatime,discard - $output_command "INFO: '$mount_folder' old_mount_options: '$old_mount_options'" + "${output_command[@]}" "INFO: '${mount_folder}' old_mount_options: '${old_mount_options}'" - if printf '%s\n' "$old_mount_options" | grep "$intended_mount_options" >/dev/null 2>/dev/null ; then - $output_command "INFO: '$mount_folder' has already intended mount options. ('$intended_mount_options')" + if printf '%s\n' "${old_mount_options}" | grep "${intended_mount_options}" >/dev/null 2>/dev/null ; then + "${output_command[@]}" "INFO: '${mount_folder}' has already intended mount options. ('${intended_mount_options}')" return 0 fi ## When this package is upgraded, the systemd unit will run again. ## If the user meanwhile manually relaxed mount options, this should not be undone. - if [ ! "$option_force" == "true" ]; then - if [ -e "$status_file_full_path" ]; then - $output_command "INFO: '$mount_folder' already remounted earlier. Not remounting again. Use --force if this is what you want." + if [ ! "${option_force}" == "true" ]; then + if [ -e "${status_file_full_path}" ]; then + "${output_command[@]}" "INFO: '${mount_folder}' already remounted earlier. Not remounting again. Use --force if this is what you want." return 0 fi fi - if ! test -d "$mount_folder" ; then + if ! test -d "${mount_folder}" ; then ## For example /boot/efi does not always exist on all systems. - $output_command "INFO: '$mount_folder' folder exists: no" + "${output_command[@]}" "INFO: '${mount_folder}' folder exists: no" return 0 fi - $output_command "INFO: '$mount_folder' folder exists: yes" + "${output_command[@]}" "INFO: '${mount_folder}' folder exists: yes" - if findmnt --noheadings "$mount_folder" >/dev/null ; then - $output_command "INFO: '$mount_folder' already mounted, therefore using remount." - $output_command INFO: Executing: mount --make-private --options "remount,${intended_mount_options}" "$mount_folder" - mount --make-private --options "remount,${intended_mount_options}" "$mount_folder" || exit_code=100 + if findmnt --noheadings "${mount_folder}" >/dev/null ; then + "${output_command[@]}" "INFO: '${mount_folder}' already mounted, therefore using remount." + "${output_command[@]}" "INFO: Executing: mount --make-private --options remount,${intended_mount_options} ${mount_folder}" + mount --make-private --options "remount,${intended_mount_options}" "${mount_folder}" || exit_code=100 else - $output_command "INFO: '$mount_folder' not yet mounted, therefore using mount bind." - $output_command INFO: Executing: mount --make-private --options "$intended_mount_options" --bind "$mount_folder" "$mount_folder" - mount --make-private --options "$intended_mount_options" --bind "$mount_folder" "$mount_folder" || exit_code=101 + "${output_command[@]}" "INFO: '${mount_folder}' not yet mounted, therefore using mount bind." + "${output_command[@]}" "INFO: Executing: mount --make-private --options ${intended_mount_options} --bind ${mount_folder} ${mount_folder}" + mount --make-private --options "${intended_mount_options}" --bind "${mount_folder}" "${mount_folder}" || exit_code=101 fi - new_mount_options="$(findmnt --noheadings --output options -- "$mount_folder")" || true - $output_command "INFO: '$mount_folder' new_mount_options: '$new_mount_options'" + new_mount_options="$(findmnt --noheadings --output options -- "${mount_folder}")" || true + "${output_command[@]}" "INFO: '${mount_folder}' new_mount_options: '${new_mount_options}'" - touch "$status_file_full_path" + touch "${status_file_full_path}" } _boot() { - mount_folder="$NEWROOT/boot" + mount_folder="${NEWROOT}/boot" ## https://lists.freedesktop.org/archives/systemd-devel/2015-February/028456.html intended_mount_options="nosuid,nodev,noexec" remount_secure @@ -227,7 +236,7 @@ _boot() { _boot_efi() { ## TODO: new, test - mount_folder="$NEWROOT/boot/efi" + mount_folder="${NEWROOT}/boot/efi" intended_mount_options="nosuid,nodev,noexec" remount_secure } @@ -261,25 +270,25 @@ _sys() { } _tmp() { - mount_folder="$NEWROOT/tmp" + mount_folder="${NEWROOT}/tmp" intended_mount_options="nosuid,nodev${most_noexec_maybe}" remount_secure } _var_tmp() { - mount_folder="$NEWROOT/var/tmp" + mount_folder="${NEWROOT}/var/tmp" intended_mount_options="nosuid,nodev${most_noexec_maybe}" remount_secure } _var_log() { - mount_folder="$NEWROOT/var/log" + mount_folder="${NEWROOT}/var/log" intended_mount_options="nosuid,nodev,noexec" remount_secure } _var() { - mount_folder="$NEWROOT/var" + mount_folder="${NEWROOT}/var" ## noexec: Not possible. Reason: ## Debian stores executable maintainer scripts in /var/lib/dpkg/info folder. intended_mount_options="nosuid,nodev" @@ -288,48 +297,48 @@ _var() { _usr() { ## TODO: new, test - mount_folder="$NEWROOT/usr" + mount_folder="${NEWROOT}/usr" intended_mount_options="nodev" remount_secure } _home() { - mount_folder="$NEWROOT/home" + mount_folder="${NEWROOT}/home" intended_mount_options="nosuid,nodev${home_noexec_maybe}" remount_secure } _root() { ## TODO: new, test - mount_folder="$NEWROOT/root" + mount_folder="${NEWROOT}/root" intended_mount_options="nosuid,nodev${home_noexec_maybe}" remount_secure } _srv() { ## TODO: new, test - mount_folder="$NEWROOT/srv" + mount_folder="${NEWROOT}/srv" intended_mount_options="nosuid,nodev${most_noexec_maybe}" remount_secure } _media() { ## TODO: new, test - mount_folder="$NEWROOT/media" + mount_folder="${NEWROOT}/media" intended_mount_options="nosuid,nodev${most_noexec_maybe}" remount_secure } _mnt() { ## TODO: new, test - mount_folder="$NEWROOT/mnt" + mount_folder="${NEWROOT}/mnt" intended_mount_options="nosuid,nodev${most_noexec_maybe}" remount_secure } _opt() { ## TODO: new, test - mount_folder="$NEWROOT/opt" + mount_folder="${NEWROOT}/opt" ## Allow /opt exec as usually optional binaries are placed there such as Firefox ## when manually installed from tarball. intended_mount_options="nosuid,nodev" @@ -340,20 +349,20 @@ _etc() { ## TODO: new, test ## /etc cannot be noexec because various executables are there. To find, run: ## sudo find /etc -executable - mount_folder="$NEWROOT/etc" + mount_folder="${NEWROOT}/etc" intended_mount_options="nosuid,nodev" remount_secure } end() { ## Debugging. - #$output_command "INFO: 'findmnt --list' output at the END." - #$output_command "$(findmnt --list)" + #"${output_command[@]}" "INFO: 'findmnt --list' output at the END." + #"${output_command[@]}" "$(findmnt --list)" - $output_command "" - $output_command "INFO: exit_code: $exit_code" - $output_command "$0: INFO: END" - exit $exit_code + "${output_command[@]}" "" + "${output_command[@]}" "INFO: exit_code: ${exit_code}" + "${output_command[@]}" "$0: INFO: END" + exit "${exit_code}" } main() { @@ -382,7 +391,7 @@ main() { end } -## TODO: see also hidepid /usr/lib/systemd/system/proc-hidepid.service +## REMINDER: see also hidepid /usr/lib/systemd/system/proc-hidepid.service #mount --options defaults,nosuid,nodev,noexec,remount,subset=pid /proc main "$@" From c4bf5403d0dbda87b2e1fe165ade7c81bf4e1b30 Mon Sep 17 00:00:00 2001 From: "claude (dev188 4c9dda7c-c359-4064-afa5-2ec7ed54398c)" Date: Wed, 12 Aug 2026 04:44:07 -0400 Subject: [PATCH 15/23] security-misc: declare bash (>= 4.4) for inherit_errexit - Many scripts use 'shopt -s inherit_errexit' (bash 4.4+) in their strict-mode preamble; ${misc:Depends} does not supply the baseline, so an older /bin/bash would die in the preamble before the script's logic. Co-Authored-By: Claude Opus 4.8 (1M context) --- debian/control | 1 + 1 file changed, 1 insertion(+) diff --git a/debian/control b/debian/control index 4a944d1b..d14059e2 100644 --- a/debian/control +++ b/debian/control @@ -32,6 +32,7 @@ Package: security-misc-shared Architecture: all Depends: adduser, apparmor-profile-dist, + bash (>= 4.4), build-essential, dmsetup, helper-scripts, From a66fa50019f95326fef372ec5b3d1cde3b59b4a9 Mon Sep 17 00:00:00 2001 From: "claude (dev188 4c9dda7c-c359-4064-afa5-2ec7ed54398c)" Date: Wed, 12 Aug 2026 04:55:18 -0400 Subject: [PATCH 16/23] security-misc: fix undo chown empty-arg + emerg fallback (ai-review) - undo_policy_for_file: the strict-mode brace pass quoted "${verbose}", so 'permission-hardener disable all' ran 'chown "" -- ...' (invalid empty owner) and 'chmod "" ...'. Make verbose an array so an empty value expands to zero arguments. Canary-verified. - emerg-shutdown fallback: when the prebuilt binary is absent, compile straight into /run (tmpfs, writable even if /usr is read-only) rather than via build-emerg-shutdown into /usr/bin. - Document the space-filename parser limitations (consecutive spaces collapse; an octal-looking path chunk can misanchor -> entry skipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../permission-hardener#security-misc-shared | 14 +++++++------ .../emerg-shutdown#security-misc-shared | 21 ++++++++++++------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/usr/bin/permission-hardener#security-misc-shared b/usr/bin/permission-hardener#security-misc-shared index 775cea57..c3e66c23 100755 --- a/usr/bin/permission-hardener#security-misc-shared +++ b/usr/bin/permission-hardener#security-misc-shared @@ -477,6 +477,10 @@ load_state() { ## mode/owner/group[/capability] tail whose first field is the octal mode ## -- so recover the filename by parsing options from the right and ## rejoining everything before them. Space-free lines are left unchanged. + ## Limitation: a single internal space is supported; consecutive spaces + ## collapse, and a space-delimited path chunk that is itself a bare octal + ## mode (e.g. '744') can misanchor. Such a filename does not exist, so the + ## entry is skipped, not misapplied. Quoting/escaping is not supported. field_count="${#field_list[@]}" if (( field_count > 2 )); then case "${field_list[field_count-1]}" in @@ -731,10 +735,10 @@ undo_policy_for_file() { undo_file="${1}" undo_all=false - verbose='--verbose' + verbose=('--verbose') if [ "${undo_file}" = 'all' ]; then undo_all=true - verbose='' + verbose=() fi if [ ! -f "${state_file}" ]; then @@ -773,12 +777,10 @@ undo_policy_for_file() { state_user_owner_item="${state_user_owner_list[state_idx]}" state_group_owner_item="${state_group_owner_list[state_idx]}" state_mode_item="${state_mode_list[state_idx]}" - # shellcheck disable=SC2086 - chown "${verbose}" -- "${state_user_owner_item}:${state_group_owner_item}" \ + chown "${verbose[@]}" -- "${state_user_owner_item}:${state_group_owner_item}" \ "${undo_file}" || exit_code=202 ## chmod needs to be run after chown since chown removes suid. - # shellcheck disable=SC2086 - chmod "${verbose}" "${state_mode_item}" "${undo_file}" || exit_code=203 + chmod "${verbose[@]}" "${state_mode_item}" "${undo_file}" || exit_code=203 else log info "File does not exist: '${undo_file}'" fi diff --git a/usr/libexec/security-misc/emerg-shutdown#security-misc-shared b/usr/libexec/security-misc/emerg-shutdown#security-misc-shared index fafffc7f..27f21856 100755 --- a/usr/libexec/security-misc/emerg-shutdown#security-misc-shared +++ b/usr/libexec/security-misc/emerg-shutdown#security-misc-shared @@ -53,15 +53,20 @@ else ## into /run (tmpfs) so it is memory-resident for the emergency; memlockd ## then locks it into RAM. if [ ! -f '/run/emerg-shutdown' ]; then - if [ ! -f '/usr/bin/emerg-shutdown' ]; then - ## Fallback: build was skipped or removed. Compile now so the emergency - ## shutdown capability is never silently absent. - /usr/libexec/security-misc/build-emerg-shutdown || { - printf '%s\n' 'Could not build emerg-shutdown executable!' - exit 1 - } + if [ -f '/usr/bin/emerg-shutdown' ]; then + cp -- /usr/bin/emerg-shutdown /run/emerg-shutdown + else + ## Fallback: prebuilt binary absent (build skipped/removed). Compile + ## straight into /run (tmpfs, writable even when /usr is read-only) so the + ## emergency-shutdown capability is never silently absent. + /usr/libexec/security-misc/compile-emerg-shutdown \ + /usr/src/security-misc/emerg-shutdown.c \ + /run/emerg-shutdown \ + || { + printf '%s\n' 'Could not compile emerg-shutdown executable!' + exit 1 + } fi - cp -- /usr/bin/emerg-shutdown /run/emerg-shutdown fi ## memlockd daemonizes itself, so no need to background it. From c2fcb2d893bf26a59c1b25415333fafcad8682dc Mon Sep 17 00:00:00 2001 From: "claude (dev188 4c9dda7c-c359-4064-afa5-2ec7ed54398c)" Date: Wed, 12 Aug 2026 05:15:00 -0400 Subject: [PATCH 17/23] security-misc: drop versioned bash dependency - Revert the bash (>= 4.4) Depends. Supported Debian (trixie+) always ships bash >= 4.4 and earlier is unsupported, so the inherit_errexit builtin is always present; the dependency adds nothing. Policy bans it (bash-style-guide R-010b). Co-Authored-By: Claude Opus 4.8 (1M context) --- debian/control | 1 - 1 file changed, 1 deletion(-) diff --git a/debian/control b/debian/control index d14059e2..4a944d1b 100644 --- a/debian/control +++ b/debian/control @@ -32,7 +32,6 @@ Package: security-misc-shared Architecture: all Depends: adduser, apparmor-profile-dist, - bash (>= 4.4), build-essential, dmsetup, helper-scripts, From 37ad483b25c43b93bb350983c2534f9d775e977a Mon Sep 17 00:00:00 2001 From: "claude (reviewdrain4 8d32e35d-2a57-4379-829c-6ef986f68cd4)" Date: Fri, 14 Aug 2026 12:34:17 -0400 Subject: [PATCH 18/23] permission-hardener: anchor mode tail from the right for spaced filenames - Space filenames are recovered from the option tail; the mode/owner/group [/capability] tail has a fixed width (3 or 4 fields). - Old left-to-right octal scan misanchored when a space-delimited filename chunk was itself a bare octal (e.g. '/path/a 744 name 0744 root root'): the field-count check then failed and the WHOLE hardener aborted (exit 200), contradicting the comment that claimed such a line was skipped. - Anchor the mode from the right (field_count-3, else field_count-4); prefer the 3-field reading so a filename ending in an octal chunk is not mistaken for a 4-field capability tail. - Regression test: config line with a bare-octal filename chunk must parse via print-policy; fails on the old code (exit 200 / entry missing). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_whitespace_filename.sh | 47 +++++++++++++++---- .../permission-hardener#security-misc-shared | 26 +++++----- 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/ci/tests/permission_hardener/test_whitespace_filename.sh b/ci/tests/permission_hardener/test_whitespace_filename.sh index 9d45b54e..eb39a65e 100755 --- a/ci/tests/permission_hardener/test_whitespace_filename.sh +++ b/ci/tests/permission_hardener/test_whitespace_filename.sh @@ -41,6 +41,13 @@ spaced_file="${test_dir}/some space/binary" mkdir -p -- "${test_dir}/some space" touch -- "${spaced_file}" +## A filename whose space-split yields a BARE octal chunk ('744'). The mode +## must be anchored from the RIGHT: a left-to-right scan anchors on that chunk +## instead of the real mode at the end, fails the field-count check, and aborts +## the whole hardener (exit 200) rather than parsing the entry. +octal_chunk_file="${test_dir}/a 744 name" +touch -- "${octal_chunk_file}" + config_dir="/etc/permission-hardener.d" config_file="${config_dir}/zz-ai-whitespace-regression-test.conf" mkdir -p -- "${config_dir}" @@ -53,17 +60,37 @@ cleanup() { } trap cleanup EXIT -## mode-form entry: -printf '%s\n' "${spaced_file} 0744 root root" > "${config_file}" +## mode-form entries: +{ + printf '%s\n' "${spaced_file} 0744 root root" + printf '%s\n' "${octal_chunk_file} 0744 root root" +} > "${config_file}" -policy_output="$( "${ph_bin}" print-policy )" +## A parse failure on any line aborts the whole run with exit 200; capture the +## status via an 'if' condition (errexit stays enabled) so the failure is +## reported cleanly rather than aborting the test. +if policy_output="$( "${ph_bin}" print-policy 2>/dev/null )"; then + ph_rc=0 +else + ph_rc=$? +fi -if printf '%s\n' "${policy_output}" | grep -qF -- "${spaced_file}"; then - printf '%s\n' "PASS: space-containing filename parsed and present in policy." - exit 0 +test_status=0 +if [ "${ph_rc}" -ne 0 ]; then + printf '%s\n' "FAIL: print-policy exited ${ph_rc} (parse aborted the whole run)." >&2 + test_status=1 fi +for expected_file in "${spaced_file}" "${octal_chunk_file}"; do + if printf '%s\n' "${policy_output}" | grep -qF -- "${expected_file}"; then + printf '%s\n' "PASS: filename '${expected_file}' parsed and present in policy." + else + printf '%s\n' "FAIL: filename '${expected_file}' missing from print-policy output." >&2 + test_status=1 + fi +done -printf '%s\n' "FAIL: space-containing filename '${spaced_file}' missing from print-policy output." >&2 -printf '%s\n' "----- print-policy output -----" >&2 -printf '%s\n' "${policy_output}" >&2 -exit 1 +if [ "${test_status}" -ne 0 ]; then + printf '%s\n' "----- print-policy output -----" >&2 + printf '%s\n' "${policy_output}" >&2 +fi +exit "${test_status}" diff --git a/usr/bin/permission-hardener#security-misc-shared b/usr/bin/permission-hardener#security-misc-shared index c3e66c23..1444328d 100755 --- a/usr/bin/permission-hardener#security-misc-shared +++ b/usr/bin/permission-hardener#security-misc-shared @@ -430,7 +430,7 @@ load_state() { ## to mean that all whitelisting should be ignored. local config_file line field_list policy_nosuid_file_item policy_file_item - local field_count recovered_file_name mode_index field_index + local field_count recovered_file_name mode_index ## Load configuration, deferring whitelist handling until later for config_file in \ @@ -477,10 +477,13 @@ load_state() { ## mode/owner/group[/capability] tail whose first field is the octal mode ## -- so recover the filename by parsing options from the right and ## rejoining everything before them. Space-free lines are left unchanged. - ## Limitation: a single internal space is supported; consecutive spaces - ## collapse, and a space-delimited path chunk that is itself a bare octal - ## mode (e.g. '744') can misanchor. Such a filename does not exist, so the - ## entry is skipped, not misapplied. Quoting/escaping is not supported. + ## The mode tail has a fixed width -- 3 fields (mode owner group) or 4 + ## (mode owner group capability) -- so anchor the mode from the RIGHT. A + ## left-to-right scan misanchors when a space-delimited filename chunk is + ## itself a bare octal (e.g. '/path/a 744 name 0744 root root'). + ## Limitation: consecutive spaces collapse and quoting/escaping is not + ## supported; a line whose field count is still invalid after recovery is + ## rejected loudly (exit 200 below), not silently skipped. field_count="${#field_list[@]}" if (( field_count > 2 )); then case "${field_list[field_count-1]}" in @@ -489,13 +492,14 @@ load_state() { field_list=( "${recovered_file_name}" "${field_list[field_count-1]}" ) ;; *) + ## Prefer the 3-field reading (field_count-3) so a filename ending + ## in an octal chunk is not mistaken for a 4-field capability tail. mode_index='' - for (( field_index=1; field_index < field_count; field_index++ )); do - if [[ "${field_list[field_index]}" =~ ^0?[0-7]{3,4}$ ]]; then - mode_index="${field_index}" - break - fi - done + if (( field_count >= 4 )) && [[ "${field_list[field_count-3]}" =~ ^0?[0-7]{3,4}$ ]]; then + mode_index="$(( field_count - 3 ))" + elif (( field_count >= 5 )) && [[ "${field_list[field_count-4]}" =~ ^0?[0-7]{3,4}$ ]]; then + mode_index="$(( field_count - 4 ))" + fi if [ -n "${mode_index}" ] && (( mode_index > 1 )); then recovered_file_name="$(IFS=' '; printf '%s' "${field_list[*]:0:mode_index}")" field_list=( "${recovered_file_name}" "${field_list[@]:mode_index}" ) From fca4984733e4aff6b90e8dd248d38b8cbb3889cc Mon Sep 17 00:00:00 2001 From: "claude (reviewdrain4 8d32e35d-2a57-4379-829c-6ef986f68cd4)" Date: Fri, 14 Aug 2026 12:40:52 -0400 Subject: [PATCH 19/23] permission-hardener confs: correct gst-ptp-helper path/pkg and #8633 fix credit - 30_default.conf: gst-ptp-helper ships in libgstreamer1.0-0, not gstreamer1.0-plugins-base; fix the disabled entry's path to .../gstreamer1.0/gstreamer-1.0/... (first component was gstreamer-1.0, which does not exist). Verified via dpkg -S. - 25_default_whitelist_qubes.conf: the #8633 EoP fix is qfile-unpacker (qubes-core-agent-linux) refusing a UID change other than the caller's; libqubes-pure path/symlink sanitization (qubes-linux-utils PR #87) is a separate layer. The comment credited only PR #87. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../25_default_whitelist_qubes.conf#security-misc-shared | 8 +++++--- .../30_default.conf#security-misc-shared | 7 +++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/usr/lib/permission-hardener.d/25_default_whitelist_qubes.conf#security-misc-shared b/usr/lib/permission-hardener.d/25_default_whitelist_qubes.conf#security-misc-shared index 34656b92..614aca7e 100644 --- a/usr/lib/permission-hardener.d/25_default_whitelist_qubes.conf#security-misc-shared +++ b/usr/lib/permission-hardener.d/25_default_whitelist_qubes.conf#security-misc-shared @@ -12,9 +12,11 @@ ## the recipient dir and setfsuid()/drop to the unprivileged user before writing ## -- both need root, so SUID cannot simply be stripped. It parses attacker- ## influenced input from another qube => high-value local-EoP target. The issue -## below (#8633) is now CLOSED: upstream HARDENED the unpacker (path sanitization, -## qubes-linux-utils PR #87) rather than dropping SUID. Keep the package patched; -## residual risk is the parser itself. +## below (#8633) is now CLOSED: upstream HARDENED the unpacker rather than +## dropping SUID -- qfile-unpacker (qubes-core-agent-linux) now refuses a UID +## change to anything but the caller's own UID, and libqubes-pure adds path, +## symlink and filename sanitization (qubes-linux-utils PR #87). Keep the +## package patched; residual risk is the parser itself. ## https://github.com/QubesOS/qubes-core-agent-linux/blob/master/qubes-rpc/qfile-unpacker.c ## https://github.com/QubesOS/qubes-issues/issues/8633 (historic local-root EoP) ## diff --git a/usr/lib/permission-hardener.d/30_default.conf#security-misc-shared b/usr/lib/permission-hardener.d/30_default.conf#security-misc-shared index cab227f4..01dda03a 100644 --- a/usr/lib/permission-hardener.d/30_default.conf#security-misc-shared +++ b/usr/lib/permission-hardener.d/30_default.conf#security-misc-shared @@ -124,12 +124,11 @@ ## anon-apps-config does this. #/usr/bin/ping 0744 root root none -## gst-ptp-helper (gstreamer1.0-plugins-base): started by gst_ptp_init() for PTP +## gst-ptp-helper (libgstreamer1.0-0): started by gst_ptp_init() for PTP ## (IEEE 1588) network clock sync; SUID root or cap_net_bind_service+cap_net_admin ## to bind privileged UDP ports 319/320, then drops privileges. Removing it only ## disables PTP clock sync; ordinary audio/video playback is unaffected, so this ## is a low-risk hardening candidate (cf. Red Hat bug 1724677). Prefer stripping -## to capabilities over setuid-root if kept. Path corrected below (was -## gstreamer1.0/grstreamer-1.0, which does not exist). +## to capabilities over setuid-root if kept. ## TODO-HUMAN-DEVELOPER-ONLY: enabling this SUID/caps strip is a human trust decision. -#/usr/lib/x86_64-linux-gnu/gstreamer-1.0/gstreamer-1.0/gst-ptp-helper 0744 root root none +#/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper 0744 root root none From 780561e61a6bf1c8d110d1105a5102b59ceb14ea Mon Sep 17 00:00:00 2001 From: "claude (reviewdrain4 8d32e35d-2a57-4379-829c-6ef986f68cd4)" Date: Fri, 14 Aug 2026 12:49:10 -0400 Subject: [PATCH 20/23] permission-hardener: fix disable-all directive + numeric-owner misparse Both surfaced by a fresh-claude review of the right-anchor change. - whitelists_disable_all=true was unreachable: the character-class check (which excludes '=') ran before the directive check, so the documented line tripped "invalid characters" and aborted the whole run (exit 200). Recognize the directive first. - Numeric octal-looking owner in a capability entry (e.g. ' 0744 0755 root cap'): right-anchoring folded the owner into the mode position, the folded path did not exist, and the entry was silently dropped -- file left un-hardened. Skip recovery when the line is already a well-formed no-space entry (octal mode at field 1, count 4 or 5). - Correct the stale field-order comment: the tail is 'filemode user_owner group_owner [capability]', mode first (per add_to_policy). - Regression tests: numeric-owner case in the whitespace test; new test_whitelists_disable_all.sh. Both fail on the pre-fix code. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_whitelists_disable_all.sh | 82 +++++++++++++++++++ .../test_whitespace_filename.sh | 13 ++- .../permission-hardener#security-misc-shared | 30 +++++-- 3 files changed, 114 insertions(+), 11 deletions(-) create mode 100755 ci/tests/permission_hardener/test_whitelists_disable_all.sh diff --git a/ci/tests/permission_hardener/test_whitelists_disable_all.sh b/ci/tests/permission_hardener/test_whitelists_disable_all.sh new file mode 100755 index 00000000..5a0dc5b0 --- /dev/null +++ b/ci/tests/permission_hardener/test_whitelists_disable_all.sh @@ -0,0 +1,82 @@ +#!/bin/bash +## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC +## See the file COPYING for copying conditions. + +## AI-Assisted + +## Regression test: the documented special directive 'whitelists_disable_all=true' +## must be honored, not rejected. +## +## The line contains '=', which is outside the config character-class allow-set. +## The directive check must run BEFORE that character-class validation; otherwise +## the documented line trips "invalid characters" and aborts the whole hardener +## (exit 200), so every other hardening rule in every conf is dropped too. +## +## Drives the REAL script via 'print-policy' with a conf that holds the directive +## plus one ordinary entry, and asserts print-policy exits 0 with the ordinary +## entry present. +## +## Requires root: writes a temporary config under /etc/permission-hardener.d/ +## and needs helper-scripts installed (sourced by permission-hardener). + +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" +ph_bin="${PERMISSION_HARDENER_BIN:-${repo_root}/usr/bin/permission-hardener#security-misc-shared}" + +if [ ! -f "${ph_bin}" ]; then + printf '%s\n' "FAIL: permission-hardener not found at '${ph_bin}'." >&2 + exit 1 +fi + +test_dir="$(mktemp -d -t ph-wda-test.XXXXXX)" +ordinary_file="${test_dir}/ordinary" +touch -- "${ordinary_file}" + +config_dir="/etc/permission-hardener.d" +config_file="${config_dir}/zz-ai-whitelists-disable-all-test.conf" +mkdir -p -- "${config_dir}" + +## invoked indirectly via 'trap ... EXIT' +# shellcheck disable=SC2317 +cleanup() { + safe-rm -f -- "${config_file}" + safe-rm -rf -- "${test_dir}" +} +trap cleanup EXIT + +{ + printf '%s\n' 'whitelists_disable_all=true' + printf '%s\n' "${ordinary_file} 0744 root root" +} > "${config_file}" + +## The directive line aborts the whole run (exit 200) on the buggy ordering; +## capture the status via an 'if' condition (errexit stays enabled). +if policy_output="$( "${ph_bin}" print-policy 2>/dev/null )"; then + ph_rc=0 +else + ph_rc=$? +fi + +test_status=0 +if [ "${ph_rc}" -ne 0 ]; then + printf '%s\n' "FAIL: print-policy exited ${ph_rc} (directive rejected as invalid characters)." >&2 + test_status=1 +fi +if printf '%s\n' "${policy_output}" | grep -qF -- "${ordinary_file}"; then + printf '%s\n' "PASS: whitelists_disable_all=true accepted; ordinary entry present." +else + printf '%s\n' "FAIL: ordinary entry '${ordinary_file}' missing from print-policy output." >&2 + test_status=1 +fi + +if [ "${test_status}" -ne 0 ]; then + printf '%s\n' "----- print-policy output -----" >&2 + printf '%s\n' "${policy_output}" >&2 +fi +exit "${test_status}" diff --git a/ci/tests/permission_hardener/test_whitespace_filename.sh b/ci/tests/permission_hardener/test_whitespace_filename.sh index eb39a65e..ed0397f8 100755 --- a/ci/tests/permission_hardener/test_whitespace_filename.sh +++ b/ci/tests/permission_hardener/test_whitespace_filename.sh @@ -48,6 +48,14 @@ touch -- "${spaced_file}" octal_chunk_file="${test_dir}/a 744 name" touch -- "${octal_chunk_file}" +## A space-free filename with a 4-option (capability) tail whose OWNER is a +## numeric id that looks octal ('0755'). Right-anchoring must NOT treat that +## owner as the mode: it would fold the real mode into the filename, the folded +## path does not exist, and the entry is silently dropped -- leaving the file +## un-hardened. The line is already well formed, so no recovery must happen. +numeric_owner_file="${test_dir}/numowner" +touch -- "${numeric_owner_file}" + config_dir="/etc/permission-hardener.d" config_file="${config_dir}/zz-ai-whitespace-regression-test.conf" mkdir -p -- "${config_dir}" @@ -60,10 +68,11 @@ cleanup() { } trap cleanup EXIT -## mode-form entries: +## mode-form entries: [capability] { printf '%s\n' "${spaced_file} 0744 root root" printf '%s\n' "${octal_chunk_file} 0744 root root" + printf '%s\n' "${numeric_owner_file} 0744 0755 root cap_net_raw" } > "${config_file}" ## A parse failure on any line aborts the whole run with exit 200; capture the @@ -80,7 +89,7 @@ if [ "${ph_rc}" -ne 0 ]; then printf '%s\n' "FAIL: print-policy exited ${ph_rc} (parse aborted the whole run)." >&2 test_status=1 fi -for expected_file in "${spaced_file}" "${octal_chunk_file}"; do +for expected_file in "${spaced_file}" "${octal_chunk_file}" "${numeric_owner_file}"; do if printf '%s\n' "${policy_output}" | grep -qF -- "${expected_file}"; then printf '%s\n' "PASS: filename '${expected_file}' parsed and present in policy." else diff --git a/usr/bin/permission-hardener#security-misc-shared b/usr/bin/permission-hardener#security-misc-shared index 1444328d..2a517d19 100755 --- a/usr/bin/permission-hardener#security-misc-shared +++ b/usr/bin/permission-hardener#security-misc-shared @@ -423,14 +423,14 @@ load_state() { ## Config format: ## path options ## where options is one of: - ## user_owner group_owner filemode [capability-setting] + ## filemode user_owner group_owner [capability-setting] ## [nosuid|exactwhitelist|matchwhitelist|disablewhitelist] ## ## Additionally, the special value 'whitelists_disable_all=true' is understood ## to mean that all whitelisting should be ignored. local config_file line field_list policy_nosuid_file_item policy_file_item - local field_count recovered_file_name mode_index + local field_count recovered_file_name mode_index already_wellformed ## Load configuration, deferring whitelist handling until later for config_file in \ @@ -454,6 +454,15 @@ load_state() { continue fi + ## Recognize the special directive before the character-class check below, + ## whose allowed set excludes '=' and would otherwise reject and abort on + ## this documented line. + if [ "${line}" = 'whitelists_disable_all=true' ]; then + whitelists_disable_all=true + log info "whitelists_disable_all=true" + continue + fi + if ! [[ "${line}" =~ ^[-0-9a-zA-Z._/[:space:]]*$ ]]; then exit_code=200 log error "Line contains invalid characters: '${line}'" >&2 @@ -462,12 +471,6 @@ load_state() { exit "${exit_code}" fi - if [ "${line}" = 'whitelists_disable_all=true' ]; then - whitelists_disable_all=true - log info "whitelists_disable_all=true" - continue - fi - processed_config_line="${line}" IFS=' ' read -r -a field_list <<< "${line}" @@ -485,7 +488,16 @@ load_state() { ## supported; a line whose field count is still invalid after recovery is ## rejected loudly (exit 200 below), not silently skipped. field_count="${#field_list[@]}" - if (( field_count > 2 )); then + ## A no-space entry already has an octal mode at field 1 (total fields 4 or + ## 5). Recovering such a line would fold a numeric owner into the mode + ## position, so only attempt filename recovery when it is NOT already well + ## formed. + already_wellformed=false + if (( field_count == 4 || field_count == 5 )) \ + && [[ "${field_list[1]}" =~ ^0?[0-7]{3,4}$ ]]; then + already_wellformed=true + fi + if (( field_count > 2 )) && [ "${already_wellformed}" = 'false' ]; then case "${field_list[field_count-1]}" in exactwhitelist|matchwhitelist|disablewhitelist|nosuid) recovered_file_name="$(IFS=' '; printf '%s' "${field_list[*]:0:field_count-1}")" From 73cc7889b271e2f3733daed276ba235dde04dc20 Mon Sep 17 00:00:00 2001 From: "claude (reviewdrain4 8d32e35d-2a57-4379-829c-6ef986f68cd4)" Date: Fri, 14 Aug 2026 13:00:51 -0400 Subject: [PATCH 21/23] permission-hardener: disambiguate octal-ambiguous entries by file existence Fresh-claude review found the earlier field-1 guard reintroduced a symmetric misparse: a spaced filename whose second component is a bare octal (e.g. '/srv/a 744 0644 root root') got dropped because the guard mistook the octal component for the mode. The 5-field reading is genuinely ambiguous lexically -- '/a 744 0644 root root' (spaced name) vs '/foo 0744 1000 root cap' (numeric owner). Resolve by which candidate filename exists on disk, consistent with the tool skipping nonexistent targets. Keep the no-recovery reading only when field 1 is an octal mode and only the un-recovered name exists. - Replace the lexical guard with the existence check inside the right-anchor branch; the whitelist-keyword branch is no longer gated (it was wrongly skipped for '/x 744 y matchwhitelist'). - Regression test: spaced octal-second-component case; the assertion now matches the whole first column (trailing TAB) so '/a 744' cannot substring-match the '/a 744 name' entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_whitelists_disable_all.sh | 2 +- .../test_whitespace_filename.sh | 15 ++++++++-- .../permission-hardener#security-misc-shared | 30 +++++++++++-------- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/ci/tests/permission_hardener/test_whitelists_disable_all.sh b/ci/tests/permission_hardener/test_whitelists_disable_all.sh index 5a0dc5b0..0957a6b2 100755 --- a/ci/tests/permission_hardener/test_whitelists_disable_all.sh +++ b/ci/tests/permission_hardener/test_whitelists_disable_all.sh @@ -68,7 +68,7 @@ if [ "${ph_rc}" -ne 0 ]; then printf '%s\n' "FAIL: print-policy exited ${ph_rc} (directive rejected as invalid characters)." >&2 test_status=1 fi -if printf '%s\n' "${policy_output}" | grep -qF -- "${ordinary_file}"; then +if printf '%s\n' "${policy_output}" | grep -qF -- "${ordinary_file}"$'\t'; then printf '%s\n' "PASS: whitelists_disable_all=true accepted; ordinary entry present." else printf '%s\n' "FAIL: ordinary entry '${ordinary_file}' missing from print-policy output." >&2 diff --git a/ci/tests/permission_hardener/test_whitespace_filename.sh b/ci/tests/permission_hardener/test_whitespace_filename.sh index ed0397f8..def1f2a3 100755 --- a/ci/tests/permission_hardener/test_whitespace_filename.sh +++ b/ci/tests/permission_hardener/test_whitespace_filename.sh @@ -56,6 +56,13 @@ touch -- "${octal_chunk_file}" numeric_owner_file="${test_dir}/numowner" touch -- "${numeric_owner_file}" +## The mirror-image of the numeric-owner case: a SPACED filename whose second +## component (field index 1) is a bare octal, with a 3-field tail. Here field 1 +## is NOT the mode, so recovery must happen. Existence disambiguates: the spaced +## name exists, its no-recovery prefix does not. +octal_second_file="${test_dir}/a 744" +touch -- "${octal_second_file}" + config_dir="/etc/permission-hardener.d" config_file="${config_dir}/zz-ai-whitespace-regression-test.conf" mkdir -p -- "${config_dir}" @@ -73,6 +80,7 @@ trap cleanup EXIT printf '%s\n' "${spaced_file} 0744 root root" printf '%s\n' "${octal_chunk_file} 0744 root root" printf '%s\n' "${numeric_owner_file} 0744 0755 root cap_net_raw" + printf '%s\n' "${octal_second_file} 0644 root root" } > "${config_file}" ## A parse failure on any line aborts the whole run with exit 200; capture the @@ -89,8 +97,11 @@ if [ "${ph_rc}" -ne 0 ]; then printf '%s\n' "FAIL: print-policy exited ${ph_rc} (parse aborted the whole run)." >&2 test_status=1 fi -for expected_file in "${spaced_file}" "${octal_chunk_file}" "${numeric_owner_file}"; do - if printf '%s\n' "${policy_output}" | grep -qF -- "${expected_file}"; then +## print-policy prints tab-separated columns (FileUser...), so match +## each filename as the whole first field (trailing TAB). A bare substring match +## would let '/a 744' spuriously match the '/a 744 name' entry. +for expected_file in "${spaced_file}" "${octal_chunk_file}" "${numeric_owner_file}" "${octal_second_file}"; do + if printf '%s\n' "${policy_output}" | grep -qF -- "${expected_file}"$'\t'; then printf '%s\n' "PASS: filename '${expected_file}' parsed and present in policy." else printf '%s\n' "FAIL: filename '${expected_file}' missing from print-policy output." >&2 diff --git a/usr/bin/permission-hardener#security-misc-shared b/usr/bin/permission-hardener#security-misc-shared index 2a517d19..f17c55a1 100755 --- a/usr/bin/permission-hardener#security-misc-shared +++ b/usr/bin/permission-hardener#security-misc-shared @@ -430,7 +430,7 @@ load_state() { ## to mean that all whitelisting should be ignored. local config_file line field_list policy_nosuid_file_item policy_file_item - local field_count recovered_file_name mode_index already_wellformed + local field_count recovered_file_name mode_index ## Load configuration, deferring whitelist handling until later for config_file in \ @@ -484,20 +484,16 @@ load_state() { ## (mode owner group capability) -- so anchor the mode from the RIGHT. A ## left-to-right scan misanchors when a space-delimited filename chunk is ## itself a bare octal (e.g. '/path/a 744 name 0744 root root'). + ## Two readings can be lexically valid when a filename component OR a + ## numeric owner looks octal ('/a 744 0644 root root' wants the spaced + ## name; '/foo 0744 1000 root cap' wants no recovery). Disambiguate by + ## which candidate exists on disk -- the tool skips nonexistent targets + ## anyway -- keeping the no-recovery reading when field 1 is really the mode. ## Limitation: consecutive spaces collapse and quoting/escaping is not ## supported; a line whose field count is still invalid after recovery is ## rejected loudly (exit 200 below), not silently skipped. field_count="${#field_list[@]}" - ## A no-space entry already has an octal mode at field 1 (total fields 4 or - ## 5). Recovering such a line would fold a numeric owner into the mode - ## position, so only attempt filename recovery when it is NOT already well - ## formed. - already_wellformed=false - if (( field_count == 4 || field_count == 5 )) \ - && [[ "${field_list[1]}" =~ ^0?[0-7]{3,4}$ ]]; then - already_wellformed=true - fi - if (( field_count > 2 )) && [ "${already_wellformed}" = 'false' ]; then + if (( field_count > 2 )); then case "${field_list[field_count-1]}" in exactwhitelist|matchwhitelist|disablewhitelist|nosuid) recovered_file_name="$(IFS=' '; printf '%s' "${field_list[*]:0:field_count-1}")" @@ -514,7 +510,17 @@ load_state() { fi if [ -n "${mode_index}" ] && (( mode_index > 1 )); then recovered_file_name="$(IFS=' '; printf '%s' "${field_list[*]:0:mode_index}")" - field_list=( "${recovered_file_name}" "${field_list[@]:mode_index}" ) + ## Keep the no-recovery reading when field 1 is an octal mode and + ## only the un-recovered name exists (field 1 is the mode, not a + ## filename component). + if (( field_count == 4 || field_count == 5 )) \ + && [[ "${field_list[1]}" =~ ^0?[0-7]{3,4}$ ]] \ + && [ ! -e "${recovered_file_name}" ] \ + && [ -e "${field_list[0]}" ]; then + true 'DEBUG: field 1 is the mode; no filename recovery' + else + field_list=( "${recovered_file_name}" "${field_list[@]:mode_index}" ) + fi fi ;; esac From 6dcffee4bf717bfac9a4c13153dcaa1a25cdc89f Mon Sep 17 00:00:00 2001 From: "claude (reviewdrain4 8d32e35d-2a57-4379-829c-6ef986f68cd4)" Date: Fri, 14 Aug 2026 13:12:25 -0400 Subject: [PATCH 22/23] permission-hardener: unify mode anchoring across all field widths Fresh-claude follow-up: the field_count==4/5 existence override did not cover a 6-field line (spaced filename + numeric octal-looking owner + capability), which anchored the mode on the owner and silently dropped the entry. No hardening bypass -- numeric owners are rejected later against passwd names -- but a silent drop instead of a loud rejection, and the ==4 disjunct was dead code. Replace the special-case override with one existence-based loop: among the octal candidates at field_count-3 and field_count-4, pick the one whose recovered filename exists; a mode at field 1 means no-space (no recovery); fall back to the rightmost octal candidate when neither name exists. Handles the 3-field, 4-field, spaced, numeric-owner and 6-field cases uniformly. - Regression test: 6-field spaced numeric-owner case; the suite now distinguishes this fix from all three prior parser iterations. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_whitespace_filename.sh | 10 ++++- .../permission-hardener#security-misc-shared | 40 ++++++++++--------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/ci/tests/permission_hardener/test_whitespace_filename.sh b/ci/tests/permission_hardener/test_whitespace_filename.sh index def1f2a3..0743dd3a 100755 --- a/ci/tests/permission_hardener/test_whitespace_filename.sh +++ b/ci/tests/permission_hardener/test_whitespace_filename.sh @@ -63,6 +63,13 @@ touch -- "${numeric_owner_file}" octal_second_file="${test_dir}/a 744" touch -- "${octal_second_file}" +## Six fields: a SPACED filename plus a numeric octal-looking owner and a +## capability tail. The rightmost octal candidate (field_count-3) is the owner, +## not the mode; anchoring there folds the mode into the filename and drops the +## entry. Existence must select field_count-4 (the real mode) instead. +numeric_owner_spaced_file="${test_dir}/x y" +touch -- "${numeric_owner_spaced_file}" + config_dir="/etc/permission-hardener.d" config_file="${config_dir}/zz-ai-whitespace-regression-test.conf" mkdir -p -- "${config_dir}" @@ -81,6 +88,7 @@ trap cleanup EXIT printf '%s\n' "${octal_chunk_file} 0744 root root" printf '%s\n' "${numeric_owner_file} 0744 0755 root cap_net_raw" printf '%s\n' "${octal_second_file} 0644 root root" + printf '%s\n' "${numeric_owner_spaced_file} 0744 0755 root cap_net_raw" } > "${config_file}" ## A parse failure on any line aborts the whole run with exit 200; capture the @@ -100,7 +108,7 @@ fi ## print-policy prints tab-separated columns (FileUser...), so match ## each filename as the whole first field (trailing TAB). A bare substring match ## would let '/a 744' spuriously match the '/a 744 name' entry. -for expected_file in "${spaced_file}" "${octal_chunk_file}" "${numeric_owner_file}" "${octal_second_file}"; do +for expected_file in "${spaced_file}" "${octal_chunk_file}" "${numeric_owner_file}" "${octal_second_file}" "${numeric_owner_spaced_file}"; do if printf '%s\n' "${policy_output}" | grep -qF -- "${expected_file}"$'\t'; then printf '%s\n' "PASS: filename '${expected_file}' parsed and present in policy." else diff --git a/usr/bin/permission-hardener#security-misc-shared b/usr/bin/permission-hardener#security-misc-shared index f17c55a1..7e794682 100755 --- a/usr/bin/permission-hardener#security-misc-shared +++ b/usr/bin/permission-hardener#security-misc-shared @@ -430,7 +430,7 @@ load_state() { ## to mean that all whitelisting should be ignored. local config_file line field_list policy_nosuid_file_item policy_file_item - local field_count recovered_file_name mode_index + local field_count recovered_file_name mode_index fallback_index candidate_index ## Load configuration, deferring whitelist handling until later for config_file in \ @@ -500,27 +500,29 @@ load_state() { field_list=( "${recovered_file_name}" "${field_list[field_count-1]}" ) ;; *) - ## Prefer the 3-field reading (field_count-3) so a filename ending - ## in an octal chunk is not mistaken for a 4-field capability tail. + ## The octal mode sits at field_count-3 (3-field tail) or + ## field_count-4 (4-field tail with capability). Both positions can + ## look octal -- a numeric owner, or a filename component -- so pick + ## the candidate whose recovered filename exists on disk (the tool + ## skips nonexistent targets anyway). A mode at field 1 means a + ## single-field, no-space filename: nothing to recover. Fall back to + ## the first (rightmost) octal candidate when neither name exists. mode_index='' - if (( field_count >= 4 )) && [[ "${field_list[field_count-3]}" =~ ^0?[0-7]{3,4}$ ]]; then - mode_index="$(( field_count - 3 ))" - elif (( field_count >= 5 )) && [[ "${field_list[field_count-4]}" =~ ^0?[0-7]{3,4}$ ]]; then - mode_index="$(( field_count - 4 ))" - fi + fallback_index='' + for candidate_index in "$(( field_count - 3 ))" "$(( field_count - 4 ))"; do + (( candidate_index >= 1 )) || continue + [[ "${field_list[candidate_index]}" =~ ^0?[0-7]{3,4}$ ]] || continue + [ -z "${fallback_index}" ] && fallback_index="${candidate_index}" + recovered_file_name="$(IFS=' '; printf '%s' "${field_list[*]:0:candidate_index}")" + if (( candidate_index == 1 )) || [ -e "${recovered_file_name}" ]; then + mode_index="${candidate_index}" + break + fi + done + [ -z "${mode_index}" ] && mode_index="${fallback_index}" if [ -n "${mode_index}" ] && (( mode_index > 1 )); then recovered_file_name="$(IFS=' '; printf '%s' "${field_list[*]:0:mode_index}")" - ## Keep the no-recovery reading when field 1 is an octal mode and - ## only the un-recovered name exists (field 1 is the mode, not a - ## filename component). - if (( field_count == 4 || field_count == 5 )) \ - && [[ "${field_list[1]}" =~ ^0?[0-7]{3,4}$ ]] \ - && [ ! -e "${recovered_file_name}" ] \ - && [ -e "${field_list[0]}" ]; then - true 'DEBUG: field 1 is the mode; no filename recovery' - else - field_list=( "${recovered_file_name}" "${field_list[@]:mode_index}" ) - fi + field_list=( "${recovered_file_name}" "${field_list[@]:mode_index}" ) fi ;; esac From f2c1e7517cf8a8017db91ef7cf42784e93e14ae8 Mon Sep 17 00:00:00 2001 From: "claude (reviewdrain4 8d32e35d-2a57-4379-829c-6ef986f68cd4)" Date: Fri, 14 Aug 2026 13:28:37 -0400 Subject: [PATCH 23/23] permission-hardener: read spaced paths back from the state DB Fresh-claude review of the whitespace feature found the write/read sides disagree: parsing now accepts space-containing filenames into the policy, but load_state_without_policy split the dpkg-statoverride DB line on whitespace and required exactly 4 fields, silently dropping any spaced path. A spaced SUID file then got hardened but could never be un-hardened ('disable' never found it), and repeated 'enable' failed with exit 203. - Read the three fixed fields (owner group mode) and take the remainder as the path, matching the DB's 'owner group mode path' format. - Regression test test_state_spaced_filename.sh: seed a spaced entry, disable, assert the mode is restored (fails on the old reader: file stays un-restored). - Test hygiene: print-policy captures modes into the state DB, so the two print-policy tests now remove the entries they create instead of leaving stale root-owned overrides for deleted temp files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_state_spaced_filename.sh | 68 +++++++++++++++++++ .../test_whitelists_disable_all.sh | 7 ++ .../test_whitespace_filename.sh | 12 ++++ .../permission-hardener#security-misc-shared | 19 +++--- 4 files changed, 98 insertions(+), 8 deletions(-) create mode 100755 ci/tests/permission_hardener/test_state_spaced_filename.sh diff --git a/ci/tests/permission_hardener/test_state_spaced_filename.sh b/ci/tests/permission_hardener/test_state_spaced_filename.sh new file mode 100755 index 00000000..520e6e4d --- /dev/null +++ b/ci/tests/permission_hardener/test_state_spaced_filename.sh @@ -0,0 +1,68 @@ +#!/bin/bash +## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC +## See the file COPYING for copying conditions. + +## AI-Assisted + +## Regression test: a hardened file whose path contains spaces must be +## un-hardenable via 'disable'. +## +## load_state_without_policy() reads the dpkg-statoverride state DB, one entry +## per line as 'owner group mode path'. A plain 4-field split drops any spaced +## path, so 'disable' never finds the entry and the file can never be restored. +## +## Seeds one spaced entry (mode 0744) into the state DB, sets the file to 0600, +## runs 'disable', and asserts the mode is restored to 0744 -- which only +## happens if load_state_without_policy read the spaced path back. +## +## Requires root: writes the state DB and chmod/chowns the target file. + +set -o errexit +set -o nounset +set -o pipefail +set -o errtrace +shopt -s inherit_errexit +shopt -s shift_verbose + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)" +ph_bin="${PERMISSION_HARDENER_BIN:-${repo_root}/usr/bin/permission-hardener#security-misc-shared}" + +if [ ! -f "${ph_bin}" ]; then + printf '%s\n' "FAIL: permission-hardener not found at '${ph_bin}'." >&2 + exit 1 +fi + +## Must match store_dir in the script under test. +existing_mode_admindir='/var/lib/permission-hardener-v2/existing_mode' +mkdir -p -- "${existing_mode_admindir}" + +test_dir="$(mktemp -d -t ph-state-test.XXXXXX)" +spaced_file="${test_dir}/spaced tool" +touch -- "${spaced_file}" +chmod 0600 -- "${spaced_file}" + +## invoked indirectly via 'trap ... EXIT' +# shellcheck disable=SC2317 +cleanup() { + ## undo touches the master and new_mode DBs, not existing_mode, so remove the + ## seeded entry explicitly. + dpkg-statoverride --admindir "${existing_mode_admindir}" --remove \ + "${spaced_file}" >/dev/null 2>&1 || true + safe-rm -rf -- "${test_dir}" +} +trap cleanup EXIT + +## Seed the state DB: this is the record 'enable' would have written. +dpkg-statoverride --admindir "${existing_mode_admindir}" --add \ + root root 0744 "${spaced_file}" + +"${ph_bin}" disable "${spaced_file}" >/dev/null 2>&1 || true + +actual_mode="$(stat -c '%a' -- "${spaced_file}")" +if [ "${actual_mode}" = '744' ]; then + printf '%s\n' "PASS: spaced-path state entry read back; mode restored to 744." + exit 0 +fi + +printf '%s\n' "FAIL: mode is '${actual_mode}', expected '744' -- spaced state entry not read back, file cannot be un-hardened." >&2 +exit 1 diff --git a/ci/tests/permission_hardener/test_whitelists_disable_all.sh b/ci/tests/permission_hardener/test_whitelists_disable_all.sh index 0957a6b2..d3727b83 100755 --- a/ci/tests/permission_hardener/test_whitelists_disable_all.sh +++ b/ci/tests/permission_hardener/test_whitelists_disable_all.sh @@ -42,9 +42,16 @@ config_dir="/etc/permission-hardener.d" config_file="${config_dir}/zz-ai-whitelists-disable-all-test.conf" mkdir -p -- "${config_dir}" +## print-policy captures the file's current mode into this DB (must match +## store_dir in the script under test); remove the entry so a test run does not +## leave a stale root-owned override pointing at a deleted temp file. +existing_mode_admindir='/var/lib/permission-hardener-v2/existing_mode' + ## invoked indirectly via 'trap ... EXIT' # shellcheck disable=SC2317 cleanup() { + dpkg-statoverride --admindir "${existing_mode_admindir}" --remove \ + "${ordinary_file}" >/dev/null 2>&1 || true safe-rm -f -- "${config_file}" safe-rm -rf -- "${test_dir}" } diff --git a/ci/tests/permission_hardener/test_whitespace_filename.sh b/ci/tests/permission_hardener/test_whitespace_filename.sh index 0743dd3a..2011edff 100755 --- a/ci/tests/permission_hardener/test_whitespace_filename.sh +++ b/ci/tests/permission_hardener/test_whitespace_filename.sh @@ -74,9 +74,21 @@ config_dir="/etc/permission-hardener.d" config_file="${config_dir}/zz-ai-whitespace-regression-test.conf" mkdir -p -- "${config_dir}" +## print-policy captures each file's current mode into this DB (must match +## store_dir in the script under test); remove the entries so a test run does +## not leave stale root-owned overrides pointing at deleted temp files. +existing_mode_admindir='/var/lib/permission-hardener-v2/existing_mode' + ## invoked indirectly via 'trap ... EXIT' # shellcheck disable=SC2317 cleanup() { + local leaked_file + for leaked_file in "${spaced_file}" "${octal_chunk_file}" \ + "${numeric_owner_file}" "${octal_second_file}" \ + "${numeric_owner_spaced_file}"; do + dpkg-statoverride --admindir "${existing_mode_admindir}" --remove \ + "${leaked_file}" >/dev/null 2>&1 || true + done safe-rm -f -- "${config_file}" safe-rm -rf -- "${test_dir}" } diff --git a/usr/bin/permission-hardener#security-misc-shared b/usr/bin/permission-hardener#security-misc-shared index 7e794682..342a0649 100755 --- a/usr/bin/permission-hardener#security-misc-shared +++ b/usr/bin/permission-hardener#security-misc-shared @@ -400,21 +400,24 @@ load_late_nosuid_policy() { } load_state_without_policy() { - local line field_list + local line sf_owner sf_group sf_mode sf_path - ## Load the state file from disk + ## Load the state file from disk. Each dpkg-statoverride entry is + ## 'owner group mode path'; the path is last and may contain spaces, so read + ## the three fixed fields and take the remainder as the path. A plain 4-way + ## split would drop any spaced path -- it could then never be un-hardened. if [ -f "${state_file}" ]; then while read -r line; do - read -r -a field_list <<< "${line}" - if (( ${#field_list[@]} != 4 )); then + read -r sf_owner sf_group sf_mode sf_path <<< "${line}" + if [ -z "${sf_path}" ]; then log info \ "Invalid number of fields in state file line: '${line}'. Skipping." continue fi - state_user_owner_list+=( "${field_list[0]}" ) - state_group_owner_list+=( "${field_list[1]}" ) - state_mode_list+=( "${field_list[2]}" ) - state_file_list+=( "${field_list[3]}" ) + state_user_owner_list+=( "${sf_owner}" ) + state_group_owner_list+=( "${sf_group}" ) + state_mode_list+=( "${sf_mode}" ) + state_file_list+=( "${sf_path}" ) done < "${state_file}" fi }