diff --git a/scripts/security/README.md b/scripts/security/README.md new file mode 100644 index 00000000..db0af966 --- /dev/null +++ b/scripts/security/README.md @@ -0,0 +1,45 @@ + +# Native Linux deployment preflight (B0) + +This is a **read-only inventory**, not a security certification or a startup +gate. Run on each intended **Linux application and worker host**, as the +account that would run the service or worker. It requires only POSIX `sh` +and conventional Linux `/proc` and `/sys` interfaces; it does not run +`sudo`, enable SELinux, install policy, open network connections, inspect +secret material or change the host. It prints no hostname or username. + +```sh +sh scripts/security/linux-preflight.sh +sh test/security/test-linux-preflight.sh +``` + +The report contains three tab-separated columns (`check`, `observation`, +`interpretation`). Keep the result with its deployment review and separately +record the **host identity, date, runtime account and command version** in a +protected location. Do not infer those details from this deliberately redacted +output. CI runs show only CI-runner capabilities, **never the eventual host's**. + +## Interpret conservatively + +- `selinux_mode=Enforcing` reports a global setting, **not** whether the Julia, + R or SSH worker is confined to an appropriate SELinux domain. A host + administrator must install/activate policy and label files as necessary. +- `apparmor_process` and `process_security_label` describe only the probe's + own process. Inspect and test the **actual service and worker processes**. +- `landlock=advertised` only means the kernel LSM list contains Landlock. It + does not prove the intended ABI is usable or that a child process is + restricted; test that separately by creating and checking a restriction. +- `cgroup_v2=detected` does not establish delegated controllers or resource + quotas. `ulimit` values describe this shell, not future services. +- Authentication, HTTPS ingress, authorisation, storage isolation, remote + SSH host keys, backup, logging and audit are **not assessed** here. +- A missing or inaccessible `/proc`/`/sys` interface is reported as + *not observable*, not as proof of insecurity or lack of support. +- The report is diagnostic. **The secure multi-user service cannot be accepted + without application-level trusted identity, default-deny authorisation, + owner-bound jobs, protected cross-tenant data paths and adversarial tests.** + Optional host confinement adds defence in depth, not a replacement. + +Use the security coordination issue for the deployment decision and the +required identity/tenancy architecture. Do not make a pass/fail security claim +from this report alone, and do not require containers, VMs or root access. diff --git a/scripts/security/label-state.sh b/scripts/security/label-state.sh new file mode 100644 index 00000000..42212b96 --- /dev/null +++ b/scripts/security/label-state.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# SPDX-License-Identifier: MPL-2.0 +# Pure text classifiers for observed Linux security labels; not a security gate. +classify_apparmor_label() { + case "$1" in + '') printf '%s' unknown ;; + unconfined|'unconfined ('*) printf '%s' unconfined ;; + *) printf '%s' label-present ;; + esac +} +classify_generic_label() { + case "$1" in + '') printf '%s' unknown ;; + unconfined|'unconfined ('*) printf '%s' unconfined ;; + *:*) printf '%s' label-present ;; + *) printf '%s' unknown ;; + esac +} diff --git a/scripts/security/linux-preflight.sh b/scripts/security/linux-preflight.sh new file mode 100644 index 00000000..b1f2f829 --- /dev/null +++ b/scripts/security/linux-preflight.sh @@ -0,0 +1,138 @@ +#!/bin/sh +# SPDX-License-Identifier: MPL-2.0 +# Read-only evidence for a native Linux security deployment review. +# This script DOES NOT certify a deployment or enforce host settings. +set -eu +. "$(dirname "$0")/label-state.sh" + +if [ "${1-}" = '--help' ]; then + printf '%s\n' 'Usage: sh scripts/security/linux-preflight.sh' \ + 'Print tab-separated check, observation, and explanation records.' \ + 'Read-only. Inspect the actual application/worker process separately.' + exit 0 +fi +if [ "$#" -ne 0 ]; then + printf '%s\n' 'Unexpected arguments; use --help' >&2 + exit 2 +fi + +# Write $1 with newlines, carriage returns and tabs replaced by spaces, capped +# at 160 bytes. Other control characters are not removed. Do not print +# hostnames, usernames, filesystem paths, secrets, or environment variables. +clean() { + printf '%s' "$1" | LC_ALL=C tr '\n\r\t' ' ' | LC_ALL=C cut -c 1-160 +} +# Write a tab-separated record: $1 is the unchanged check name; $2 and $3 are +# the observation and interpretation, cleaned and capped by clean. +record() { + printf '%s\t%s\t%s\n' "$1" "$(clean "$2")" "$(clean "$3")" +} +# Write the first line of $1 without its newline, or nothing if it is not +# readable. Read failures are ignored and may leave partial data; output write +# failures propagate. Sets answer without evaluating the file contents as code. +read_one() { + answer='' + if [ -r "$1" ]; then + IFS= read -r answer < "$1" || : + printf '%s' "${answer-}" + fi +} + +printf '# check\tobservation\tinterpretation (no deployment assurance implied)\n' +if [ "$(uname -s)" != 'Linux' ]; then + record os unsupported 'Native Linux preflight requires Linux' + exit 1 +fi +record os Linux 'Current process is running on Linux' +record kernel "$(uname -r)" 'Kernel version is descriptive, not a feature guarantee' + +# SELinux can be enabled globally while THIS process is unconfined. In +# particular, `getenforce` never proves the Julia/worker process is confined. +if command -v getenforce >/dev/null 2>&1; then + selinux=$(getenforce 2>/dev/null || printf 'unknown') +elif [ -r /sys/fs/selinux/enforce ]; then + case "$(read_one /sys/fs/selinux/enforce)" in + 1) selinux=Enforcing ;; + 0) selinux=Permissive ;; + *) selinux=unknown ;; + esac +else + selinux=not-detected +fi +record selinux_mode "$selinux" 'Global mode only; process confinement not established' + +lsm=$(read_one /sys/kernel/security/lsm) +if [ -n "$lsm" ]; then + record active_lsms "$lsm" 'Kernel LSM list; not proof of application confinement' +else + record active_lsms not-observable 'LSM interface may be inaccessible or unmounted' +fi + +# An AppArmor label may appear in either the module-specific attr or the +# generic LSM attr, depending on the kernel. Only use the latter as an +# AppArmor label when the LSM inventory explicitly advertises AppArmor. +if [ -r /proc/self/attr/apparmor/current ]; then + aa=$(read_one /proc/self/attr/apparmor/current) + record apparmor_process "$(classify_apparmor_label "$aa")" 'Observed on this probe process, not on Julia' +else + case ",$lsm," in + *,apparmor,*) + if [ -r /proc/self/attr/current ]; then + aa=$(read_one /proc/self/attr/current) + record apparmor_process "$(classify_apparmor_label "$aa")" 'Generic LSM attr fallback for this probe; verify worker separately' + else + record apparmor_process not-observable 'No readable AppArmor process attribute' + fi ;; + *) + record apparmor_process not-observable 'AppArmor profile not established by observable kernel interfaces' ;; + esac +fi +if [ -r /proc/self/attr/current ]; then + label=$(read_one /proc/self/attr/current) + record process_security_label "$(classify_generic_label "$label")" 'A label is not evidence of effective worker confinement' +else + record process_security_label not-observable 'Current security label cannot be read' +fi + +if [ -r /proc/self/status ]; then + nnp=$(sed -n 's/^NoNewPrivs:[[:space:]]*//p' /proc/self/status | head -n 1) + case "$nnp" in + 1) nnp_state=enabled ;; + 0) nnp_state=disabled ;; + *) nnp_state=unknown ;; + esac + record no_new_privs "$nnp_state" 'Only for this probe; worker launcher must set and verify its own' +else + record no_new_privs not-observable 'No proc status available' +fi + +case ",$lsm," in + *,landlock,*) record landlock advertised 'Kernel LSM lists Landlock; syscall usability not tested' ;; + *) record landlock not-verified 'Do not infer unsupported merely because kernel LSM list is inaccessible' ;; +esac + +if [ -r /sys/fs/cgroup/cgroup.controllers ]; then + ctrls=$(read_one /sys/fs/cgroup/cgroup.controllers) + record cgroup_v2 detected 'Controller presence does not mean delegated writable quotas' + if [ -n "$ctrls" ]; then + record cgroup_controllers "$ctrls" 'Available controllers; no delegation test performed' + fi +else + record cgroup_v2 not-observable 'Root controller file not available; delegation not established' +fi + +# Portable POSIX shell limits. Values are environmental observations only. +record open_file_limit "$(ulimit -n 2>/dev/null || printf unknown)" 'Soft limit for this probe process' +record cpu_time_limit "$(ulimit -t 2>/dev/null || printf unknown)" 'Soft CPU seconds limit for this probe process' +record file_size_limit "$(ulimit -f 2>/dev/null || printf unknown)" 'Soft file-size limit for this probe process' +record umask "$(umask)" 'Effective creation mask of this probe process' +if command -v ssh >/dev/null 2>&1; then + record ssh_client available 'No connection or host-key verification attempted' +else + record ssh_client unavailable 'SSH remote worker requires supported client' +fi +record tls_ingress not-assessed 'Must independently verify actual browser-facing HTTPS and identity boundary' +record user_authentication not-assessed 'Application identity, sessions and authorisation require runtime tests' +record data_isolation not-assessed 'App/DB/worker cross-tenant isolation requires separate adversarial tests' +record audit_durability not-assessed 'Audit tamper model and crash recovery require separate tests' +record result informational-only 'No permissions, files, network settings, policy or host state changed' diff --git a/test/security/test-linux-preflight.sh b/test/security/test-linux-preflight.sh new file mode 100644 index 00000000..f89a5fbe --- /dev/null +++ b/test/security/test-linux-preflight.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# SPDX-License-Identifier: MPL-2.0 +# Smoke/format regression: run from repository root, no elevated privileges. +set -eu +script=scripts/security/linux-preflight.sh +[ -f "$script" ] || { echo "Missing $script" >&2; exit 1; } +sh -n "$script" +help=$(sh "$script" --help) +case "$help" in *'Read-only'*) : ;; *) echo 'Missing read-only help contract' >&2; exit 1 ;; esac +if sh "$script" --invalid >/dev/null 2>&1; then + echo 'Invalid option unexpectedly succeeded' >&2 + exit 1 +fi +# Pure label fixtures: do not mistake a confined profile named "unconfined_*" +# for an actually unconfined task, or an unreadable value for evidence. +. scripts/security/label-state.sh +[ "$(classify_apparmor_label '')" = unknown ] +[ "$(classify_apparmor_label unconfined)" = unconfined ] +[ "$(classify_apparmor_label 'unconfined (complain)')" = unconfined ] +[ "$(classify_apparmor_label 'unconfined_worker (enforce)')" = label-present ] +[ "$(classify_apparmor_label 'myprofile (enforce)')" = label-present ] +[ "$(classify_generic_label 'unconfined_worker (enforce)')" = unknown ] +[ "$(classify_generic_label 'user_u:user_r:user_t:s0')" = label-present ] +[ "$(classify_generic_label '')" = unknown ] +out=$(sh "$script") +# Every non-comment row must have exactly three tab-separated fields. +printf '%s\n' "$out" | awk -F '\t' '/^#/ { next } NF != 3 { bad = 1; print "Bad record: " $0 > "/dev/stderr" } END { exit bad }' +for key in os selinux_mode active_lsms apparmor_process process_security_label no_new_privs landlock cgroup_v2 open_file_limit cpu_time_limit file_size_limit umask ssh_client tls_ingress user_authentication data_isolation audit_durability result; do + count=$(printf '%s\n' "$out" | awk -F '\t' -v key="$key" '$1 == key { n++ } END { print n+0 }') + [ "$count" -eq 1 ] || { echo "Expected one $key record; got $count" >&2; exit 1; } +done +printf '%s\n' "$out" | grep -q '^result[[:space:]]informational-only[[:space:]]' || { echo 'Missing non-certification result' >&2; exit 1; } +echo 'linux-preflight: PASS (format and safety-contract smoke tests)'