diff --git a/README.adoc b/README.adoc index 7084d44..aa77bc0 100644 --- a/README.adoc +++ b/README.adoc @@ -232,4 +232,15 @@ Direct PanLL panel scripts (schemata generation, workspace layout validation) ar This project is licensed under the Mozilla Public License, v. 2.0. See the `LICENSE` file for details. -SPDX-License-Identifier: CC-BY-SA-4.0 \ No newline at end of file +SPDX-License-Identifier: CC-BY-SA-4.0 +== Language Compliance Reports + +`check-language-compliance.sh` requires Bash and Julia (using only Julia's +standard library). It writes a dated report under +`${XDG_STATE_HOME}/scripts/language-compliance` when `XDG_STATE_HOME` is absolute, +or `$HOME/.local/state/scripts/language-compliance` otherwise. +The logger rejects symlinks in the state path and requires the state base, +`scripts`, and `language-compliance` directories to belong to the invoking user. +Reports are private (directory mode `0700`, log mode `0600`). + +Run the logging regression checks with `bash tests/check-language-compliance.sh`. diff --git a/check-language-compliance-log.jl b/check-language-compliance-log.jl new file mode 100644 index 0000000..3be4924 --- /dev/null +++ b/check-language-compliance-log.jl @@ -0,0 +1,98 @@ +#!/usr/bin/env julia +# SPDX-License-Identifier: MPL-2.0 +# POSIX descriptor-based logger for check-language-compliance.sh. + +using Base.Filesystem: JL_O_RDONLY, JL_O_WRONLY, JL_O_CREAT, JL_O_DIRECTORY, + JL_O_NOFOLLOW, JL_O_NONBLOCK, JL_O_CLOEXEC + +""" +Open `name` relative to directory descriptor `dirfd` using POSIX `flags` and +return a file descriptor that the caller must close. Absolute names ignore +`dirfd`. When creating a file, `mode` specifies permissions subject to the umask. +Throw a `SystemError` if the operating system rejects the open. +""" +function openat(dirfd, name, flags, mode=0o600) + result = ccall(:openat, Cint, (Cint, Cstring, Cint, Cuint), dirfd, name, flags, mode) + Base.systemerror("openat $name", result == -1) + return result +end + +""" +Create or truncate a private report and return a writable stream for the caller +to close. `state_dir` is the full, absolute report directory, normally ending in +`scripts/language-compliance`; `log_name` uses eight digits followed by `.log` +(the calendar date is not validated). + +Create missing directories with mode `0700`, subject to the umask, and set the +final directory to `0700`. Reject `.` and `..` components and directory symlinks; +the final three directory components must belong to the invoking user. The log +must be a regular file owned by that user with one hard link, and must not be a +symlink. Set its mode to `0600` and truncate it only after validation. + +Throw on invalid arguments or unsafe paths/files, and propagate errors from +opening, inspecting, changing permissions or truncating. Directory creation and +permission changes may remain after failure; no fallback log is returned. +""" +function private_log(state_dir, log_name) + isabspath(state_dir) || error("state directory must be absolute") + occursin(r"^[0-9]{8}\.log$", log_name) || error("invalid log name") + uid = ccall(:getuid, Cuint, ()) + dirflags = JL_O_RDONLY | JL_O_DIRECTORY | JL_O_NOFOLLOW | JL_O_CLOEXEC + # Start at / and walk every component through its open parent. No directory + # symlinks are accepted, including in XDG_STATE_HOME and HOME. + dirfd = ccall(:open, Cint, (Cstring, Cint), "/", dirflags) + Base.systemerror("open /", dirfd == -1) + try + parts = split(state_dir, '/'; keepempty=false) + for (index, part) in enumerate(parts) + part in (".", "..") && error("state directory must not contain . or ..") + # mkdirat applies 0700 at creation, without a permissive interval. + # If it already exists, openat and fstat below validate the object. + ccall(:mkdirat, Cint, (Cint, Cstring, Cuint), dirfd, part, 0o700) + nextfd = openat(dirfd, part, dirflags) + ccall(:close, Cint, (Cint,), dirfd) + dirfd = nextfd + # The state base, scripts and language-compliance must be ours; + # system ancestors such as /home may belong to root. + if index >= length(parts) - 2 + stat(RawFD(dirfd)).uid == uid || error("state directory is not owned by invoking user") + end + end + Base.systemerror("fchmod state directory", ccall(:fchmod, Cint, (Cint, Cuint), dirfd, 0o700) == -1) + # Do not truncate until the opened inode has passed validation. NONBLOCK + # prevents a planted FIFO from hanging the process before fstat. + logfd = openat(dirfd, log_name, JL_O_WRONLY | JL_O_CREAT | JL_O_NOFOLLOW | JL_O_NONBLOCK | JL_O_CLOEXEC) + log = Base.fdio(logfd, true) + try + info = stat(log) + isfile(info) && info.uid == uid && info.nlink == 1 || error("unsafe log file") + Base.systemerror("fchmod log", ccall(:fchmod, Cint, (Cint, Cuint), logfd, 0o600) == -1) + truncate(log, 0) + return log + catch + close(log) + rethrow() + end + finally + ccall(:close, Cint, (Cint,), dirfd) + end +end + +try + length(ARGS) == 2 || error("expected state directory and dated log name") + log = private_log(ARGS...) + try + while !eof(stdin) + data = readavailable(stdin) + write(log, data) == length(data) || error("incomplete log write") + flush(log) + write(stdout, data) + flush(stdout) + end + finally + close(log) + end +catch err + println(stderr, "Language compliance logging failed: ", sprint(showerror, err)) + exit(1) +end diff --git a/check-language-compliance.sh b/check-language-compliance.sh index 0680ba2..4a9f194 100755 --- a/check-language-compliance.sh +++ b/check-language-compliance.sh @@ -5,11 +5,23 @@ set -euo pipefail REPOS_BASE="${REPOS_BASE:-/var$REPOS_DIR}" -LOG_FILE="/tmp/language-compliance-$(date +%Y%m%d).log" +# Per-user XDG state, not /tmp: this log is a kept, dated compliance report +# (copied to stdout for the whole run), and a world-writable /tmp path +# with a predictable name lets another local user pre-create or tamper with +# it (CWE-377). Not a launcher, so no PID file and no launch-scaffolder/ +# segment here. +case "${XDG_STATE_HOME:-}" in + /*) STATE_DIR="$XDG_STATE_HOME/scripts/language-compliance" ;; + *) STATE_DIR="$HOME/.local/state/scripts/language-compliance" ;; +esac +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -echo "=== Language Compliance Check ===" | tee "$LOG_FILE" -echo "Started: $(date)" | tee -a "$LOG_FILE" -echo "" | tee -a "$LOG_FILE" +# The logger opens the report once using a verified directory descriptor. +{ + +echo "=== Language Compliance Check ===" +echo "Started: $(date)" +echo "" # Initialize counters API_VIOLATIONS=0 @@ -18,20 +30,25 @@ FFI_VIOLATIONS=0 COMPLIANT_REPOS=0 TOTAL_REPOS=0 -# Check a single repository +# Scan repository directory $1 recursively for potential API/ABI/FFI language +# violations and print the findings to stdout. Update API_VIOLATIONS, +# ABI_VIOLATIONS, FFI_VIOLATIONS, COMPLIANT_REPOS and TOTAL_REPOS in the current +# shell; initialise these counters before calling. A completed call returns the +# final echo's status, not a compliance result. With set -e active, incrementing +# a zero counter returns status 1 and can terminate the scan early. check_repo() { local repo_path="$1" local repo_name="$(basename "$repo_path")" local violations=0 - echo "Checking: $repo_name" | tee -a "$LOG_FILE" + echo "Checking: $repo_name" # Check for non-V APIs (exclude internal scripts) if find "$repo_path" -name "*.ex" -o -name "*.exs" | grep -q .; then - echo " ✓ Elixir found - checking API compliance" | tee -a "$LOG_FILE" + echo " ✓ Elixir found - checking API compliance" # Elixir repos should use V for external APIs if [ ! -f "$repo_path/api/vlang" ] && find "$repo_path" -name "*.ex" | head -1 | grep -q .; then - echo " ⚠️ Potential API violation: Elixir repo without V API layer" | tee -a "$LOG_FILE" + echo " ⚠️ Potential API violation: Elixir repo without V API layer" ((API_VIOLATIONS++)) ((violations++)) fi @@ -39,10 +56,10 @@ check_repo() { # Check for non-Idris2 ABIs if find "$repo_path" -name "*.zig" | grep -q .; then - echo " ✓ Zig found - checking ABI compliance" | tee -a "$LOG_FILE" + echo " ✓ Zig found - checking ABI compliance" # Zig repos should use Idris2 for ABIs if [ ! -f "$repo_path/abi/idris2" ] && find "$repo_path" -name "*.zig" | head -1 | grep -q .; then - echo " ⚠️ Potential ABI violation: Zig repo without Idris2 ABI layer" | tee -a "$LOG_FILE" + echo " ⚠️ Potential ABI violation: Zig repo without Idris2 ABI layer" ((ABI_VIOLATIONS++)) ((violations++)) fi @@ -50,8 +67,8 @@ check_repo() { # Check for non-Zig FFIs if find "$repo_path" -name "*.c" -o -name "*.h" | grep -q .; then - echo " ⚠️ Potential FFI violation: C headers found" | tee -a "$LOG_FILE" - echo " Should use Zig with C compatibility layer only" | tee -a "$LOG_FILE" + echo " ⚠️ Potential FFI violation: C headers found" + echo " Should use Zig with C compatibility layer only" ((FFI_VIOLATIONS++)) ((violations++)) fi @@ -59,21 +76,21 @@ check_repo() { # Check for proper C compatibility warnings if grep -r "c_compat" "$repo_path" 2>/dev/null | grep -q .; then if ! grep -r "compileError.*C.*compatibility" "$repo_path" 2>/dev/null | grep -q .; then - echo " ⚠️ C compatibility without proper warnings" | tee -a "$LOG_FILE" + echo " ⚠️ C compatibility without proper warnings" ((FFI_VIOLATIONS++)) ((violations++)) fi fi if [ $violations -eq 0 ]; then - echo " ✅ Compliant" | tee -a "$LOG_FILE" + echo " ✅ Compliant" ((COMPLIANT_REPOS++)) else - echo " ❌ $violations violations found" | tee -a "$LOG_FILE" + echo " ❌ $violations violations found" fi ((TOTAL_REPOS++)) - echo "" | tee -a "$LOG_FILE" + echo "" } # Export function for parallel execution @@ -81,7 +98,7 @@ export -f check_repo export REPOS_BASE # Find all repositories -echo "Scanning repositories in $REPOS_BASE..." | tee -a "$LOG_FILE" +echo "Scanning repositories in $REPOS_BASE..." # Check core repositories first for repo in hypatia gitbot-fleet ".git-private-farm"; do @@ -96,27 +113,30 @@ find "$REPOS_BASE" -maxdepth 1 -type d ! -name "." ! -name ".." ! -name ".git*" done # Summary -echo "" | tee -a "$LOG_FILE" -echo "=== Summary ===" | tee -a "$LOG_FILE" -echo "Total repositories: $TOTAL_REPOS" | tee -a "$LOG_FILE" -echo "Compliant: $COMPLIANT_REPOS" | tee -a "$LOG_FILE" -echo "" | tee -a "$LOG_FILE" -echo "Violations:" | tee -a "$LOG_FILE" -echo " API (non-V): $API_VIOLATIONS" | tee -a "$LOG_FILE" -echo " ABI (non-Idris2): $ABI_VIOLATIONS" | tee -a "$LOG_FILE" -echo " FFI (non-Zig): $FFI_VIOLATIONS" | tee -a "$LOG_FILE" -echo "" | tee -a "$LOG_FILE" +echo "" +echo "=== Summary ===" +echo "Total repositories: $TOTAL_REPOS" +echo "Compliant: $COMPLIANT_REPOS" +echo "" +echo "Violations:" +echo " API (non-V): $API_VIOLATIONS" +echo " ABI (non-Idris2): $ABI_VIOLATIONS" +echo " FFI (non-Zig): $FFI_VIOLATIONS" +echo "" COMPLIANCE_PERCENT=$(( (COMPLIANT_REPOS * 100) / TOTAL_REPOS )) -echo "Compliance: $COMPLIANCE_PERCENT%" | tee -a "$LOG_FILE" +echo "Compliance: $COMPLIANCE_PERCENT%" if [ $COMPLIANCE_PERCENT -ge 90 ]; then - echo "Status: ✅ PASSING" | tee -a "$LOG_FILE" + echo "Status: ✅ PASSING" exit 0 elif [ $COMPLIANCE_PERCENT -ge 70 ]; then - echo "Status: ⚠️ WARNING" | tee -a "$LOG_FILE" + echo "Status: ⚠️ WARNING" exit 1 else - echo "Status: ❌ FAILING" | tee -a "$LOG_FILE" + echo "Status: ❌ FAILING" exit 2 -fi \ No newline at end of file +fi + +} | julia --startup-file=no --history-file=no \ + "$SCRIPT_DIR/check-language-compliance-log.jl" "$STATE_DIR" "$(date +%Y%m%d).log" diff --git a/tests/check-language-compliance.sh b/tests/check-language-compliance.sh new file mode 100644 index 0000000..430f15c --- /dev/null +++ b/tests/check-language-compliance.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail + +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +fixture=$(mktemp -d) +trap 'rm -rf -- "$fixture"' EXIT +logger="$repo_dir/check-language-compliance-log.jl" +log_name="$(date +%Y%m%d).log" + +# Print all arguments as a failure message to stderr and exit the test with status 1. +fail() { echo "FAIL: $*" >&2; exit 1; } + +# Write a sample report through $logger using $1 as the state directory and +# $log_name as the filename. Forward logger output and return the pipeline status. +run_logger() { + printf 'report\n' | julia --startup-file=no --history-file=no "$logger" "$1" "$log_name" +} + +# Assert that logging to state directory $1 fails without writing to stdout. +# Capture output under $fixture; use $2 to describe the rejected case on failure. +reject() { + if run_logger "$1" > "$fixture/stdout" 2> "$fixture/stderr"; then + fail "accepted $2" + fi + [[ ! -s "$fixture/stdout" ]] || fail "wrote output for $2" +} + +# Exercise real shell-to-logger wiring. The existing scanner exits 1 on its +# first counter increment under set -e; this test checks logging up to that point. +for selection in unset empty relative absolute; do + case_dir="$fixture/$selection" + mkdir -p "$case_dir/home" "$case_dir/repos" "$case_dir/cwd" + ( + cd "$case_dir/cwd" + export HOME="$case_dir/home" REPOS_BASE="$case_dir/repos" + case "$selection" in + unset) unset XDG_STATE_HOME ;; + empty) export XDG_STATE_HOME='' ;; + relative) export XDG_STATE_HOME='relative-state' ;; + absolute) export XDG_STATE_HOME="$case_dir/absolute state" ;; + esac + bash "$repo_dir/check-language-compliance.sh" + ) > "$case_dir/stdout" 2> "$case_dir/stderr" && fail 'expected existing scanner exit 1' + state="$case_dir/home/.local/state/scripts/language-compliance" + [[ "$selection" != absolute ]] || state="$case_dir/absolute state/scripts/language-compliance" + [[ -f "$state/$log_name" ]] || fail "missing report for $selection" + cmp "$case_dir/stdout" "$state/$log_name" + grep -q '✅ Compliant' "$state/$log_name" + [[ ! -e "$case_dir/cwd/relative-state" ]] || fail 'used relative XDG state' + [[ $(stat -c %a "$state") == 700 ]] || fail 'directory permissions' + [[ $(stat -c %a "$state/$log_name") == 600 ]] || fail 'log permissions' +done + +state="$fixture/state/scripts/language-compliance" +(umask 000; run_logger "$state") > "$fixture/stdout" +[[ $(stat -c %a "$state") == 700 ]] || fail 'permissive umask exposed directory' +[[ $(stat -c %a "$state/$log_name") == 600 ]] || fail 'permissive umask exposed log' +printf 'old report longer than new\n' > "$state/$log_name" +chmod 755 "$state" +chmod 644 "$state/$log_name" +run_logger "$state" > "$fixture/stdout" +cmp "$fixture/stdout" "$state/$log_name" +[[ $(cat "$state/$log_name") == report ]] || fail 'did not truncate existing safe log' +[[ $(stat -c %a "$state") == 700 ]] || fail 'did not restrict existing directory' +[[ $(stat -c %a "$state/$log_name") == 600 ]] || fail 'did not restrict existing log' + +rm "$state/$log_name" +printf 'untouched\n' > "$fixture/target" +ln -s "$fixture/target" "$state/$log_name" +reject "$state" 'symlink log' +[[ $(cat "$fixture/target") == untouched ]] || fail 'changed symlink target' +rm "$state/$log_name" +ln "$fixture/target" "$state/$log_name" +reject "$state" 'hard-linked log' +[[ $(cat "$fixture/target") == untouched ]] || fail 'changed hard link target' +rm "$state/$log_name" +mkfifo "$state/$log_name" +if timeout 10 bash -c 'printf report | julia --startup-file=no "$1" "$2" "$3"' _ "$logger" "$state" "$log_name"; then + fail 'accepted FIFO' +else + [[ $? != 124 ]] || fail 'blocked on FIFO' +fi +rm "$state/$log_name" +mkdir "$state/$log_name" +reject "$state" 'directory log' +rmdir "$state/$log_name" + +mv "$state" "$state.saved" +ln -s "$state.saved" "$state" +reject "$state" 'symlink state directory' +rm "$state" +mv "$state.saved" "$state" +mv "$fixture/state/scripts" "$fixture/state/scripts.saved" +ln -s "$fixture/state/scripts.saved" "$fixture/state/scripts" +reject "$state" 'symlink parent directory' +rm "$fixture/state/scripts" +mv "$fixture/state/scripts.saved" "$fixture/state/scripts" + +# Optional privilege is only for constructing foreign-owned attack fixtures. +if [[ $(id -u) != 0 ]] && sudo -n true 2>/dev/null; then + chmod 777 "$state" + sudo -n chown 0 "$state" + reject "$state" 'foreign-owned directory' + [[ $(stat -c %a "$state") == 777 ]] || fail 'changed foreign directory mode' + sudo -n chown "$(id -u)" "$state" + printf 'foreign\n' > "$state/$log_name" + chmod 666 "$state/$log_name" + sudo -n chown 0 "$state/$log_name" + reject "$state" 'foreign-owned log' + [[ $(cat "$state/$log_name") == foreign ]] || fail 'truncated foreign log' + sudo -n chown "$(id -u)" "$state/$log_name" +else + echo 'SKIP: foreign ownership fixtures require non-root user with passwordless sudo' +fi + +# Once opened, replacing the directory pathname must not redirect later writes. +rm -f "$state/$log_name" +mkfifo "$fixture/input" +julia --startup-file=no "$logger" "$state" "$log_name" < "$fixture/input" > "$fixture/stdout" & +logger_pid=$! +exec 4> "$fixture/input" +printf 'first\n' >&4 +for ((attempt=0; attempt<100; attempt++)); do + [[ -f "$state/$log_name" ]] && [[ $(cat "$state/$log_name") == first ]] && break + sleep 0.1 +done +[[ -f "$state/$log_name" ]] && [[ $(cat "$state/$log_name") == first ]] || fail 'logger not ready' +mv "$state" "$state.saved" +mkdir "$fixture/replacement" +printf 'untouched\n' > "$fixture/replacement/$log_name" +ln -s "$fixture/replacement" "$state" +printf 'second\n' >&4 +exec 4>&- +wait "$logger_pid" +[[ $(cat "$fixture/replacement/$log_name") == untouched ]] || fail 'reopened replaced path' +printf 'first\nsecond\n' > "$fixture/expected" +cmp "$fixture/expected" "$state.saved/$log_name" +cmp "$fixture/expected" "$fixture/stdout" + +echo 'PASS: compliance state and logging regressions'