diff --git a/.githooks/validate-actions-lock.sh b/.githooks/validate-actions-lock.sh index d93ec8e..74650e8 100755 --- a/.githooks/validate-actions-lock.sh +++ b/.githooks/validate-actions-lock.sh @@ -1,255 +1,92 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 -# Actions lockfile coverage: every SHA-pinned `uses:` ref in .github/workflows/ -# AND in .github/actions/*/action.yml must have a matching entry in -# .github/workflows/actions.lock. -# -# WHY THIS EXISTS -# -# GitHub validates a reusable workflow against the CALLEE repo's own -# actions.lock. A lockfile that has drifted from its workflows therefore -# does not break this repo -- it breaks every repo that calls into it, with -# `failure` (not `startup_failure`), 0 jobs, and no reason in either the -# REST or the GraphQL payload; only the run page says why. actionlint -# passes either way. Dependabot bumps a `uses:` SHA without regenerating -# the lock, and Dependabot never runs pre-commit -- so CI is the only place -# this is caught before it reaches the callers. -# -# WHY NOT `gh actions-lock --verify-local`, measured 2026-09-15 on v0.1.6: -# -# * It WRITES. It rewrote `uses: ./.github/actions/signed-push` to the -# invalid `uses: $/.github/actions/signed-push` -- a ref that kills the -# workflow at startup -- while running in a mode whose own help text -# calls it read-only and "ideal for pre-commit hooks". -# --no-migrate-local-actions suppresses that, but then the tool stops -# descending into local composite actions and misreports their -# dependencies as stale. Safety and accuracy are in conflict there. -# * Its coverage is REPO-scoped, not SHA-exact. Bumping ONE of a workflow's -# two refs to the same action leaves the old lock key still referenced by -# the other: nothing reported stale, nothing reported missing, check -# green, workflow broken. That mutant survives it and dies here. -# -# SCOPE -- stated, not implied: -# * Only 40-hex SHA-pinned refs are checked. Tag refs (@v2, @main) are a -# different question, gated by validate-sha-pins.sh. -# * Reusable WORKFLOW refs (owner/repo/.github/workflows/x.yml@sha) are -# skipped: the lockfile models actions and keys no entry for them. -# * Local refs (./...) are skipped; they carry no SHA. -# * Sub-path actions normalise to their repo -- codeql-action/init@X is -# keyed once as codeql-action@X, never once per sub-path. -# * Comparison is case-insensitive: workflows say Swatinem/rust-cache while -# the lockfile stores swatinem/rust-cache. -# * Local COMPOSITE actions (.github/actions/*/action.yml) are scanned too. -# Their deps are keyed in the lock under the workflow that uses them, and -# Dependabot bumps them like any other ref; scanning only the workflow -# directory leaves that drift invisible. -# * Membership is GLOBAL, not per-workflow-section. A ref present in some -# other workflow's lock section satisfies this check. A workflow with -# SHA-pinned refs but no lock section of its own is therefore NOT -# detected here -- see the PR notes. - +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Native coverage plus the published canonical reusable/local-action lock-sync +# boundary. No native YAML is generated, patched, or inferred here. set -euo pipefail -REPO_ROOT="${INPUT_PATH:-.}" -LOCKFILE="$REPO_ROOT/.github/workflows/actions.lock" -WORKFLOW_DIR="$REPO_ROOT/.github/workflows" -ACTIONS_DIR="$REPO_ROOT/.github/actions" - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' - -# Refs deliberately absent from the lockfile. Each entry is an exact -# normalised owner/repo@sha plus the reason it is absent. This is an explicit -# list, never a pattern: anything not named here fails, so the list cannot -# quietly widen to cover an accident. An entry that stops being used is -# reported as stale, so it cannot rot either. -EXPECTED_ABSENT=( - # Empty: the denoland/setup-deno exception was removed 2026-09-22 after - # the last consumer went away (the validator itself reported it stale). - # Estate doctrine is still bun-only; if a deno ref ever returns, the - # cure is to remove the consumer, not to re-add an exception. -) - -if [ ! -f "$LOCKFILE" ]; then - echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE not found${NC}" >&2 - exit 1 -fi - -# An unreadable lockfile must abort, never silently pass. -if [ ! -r "$LOCKFILE" ]; then - echo -e "${RED}[validate-actions-lock] ERROR: $LOCKFILE unreadable${NC}" >&2 - exit 1 -fi - -# Parse the lockfile's keys into an explicit set ONCE, rather than substring -# -matching against the whole file. A substring test over a blob fails open in -# ways that are hard to see: if the blob is empty or truncated, every ref is -# reported "missing" and the operator is told to regenerate a lockfile that -# was actually fine. An explicit set can be counted, and is counted below. -mapfile -t LOCK_KEYS < <( - grep -oE "'[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@[0-9a-fA-F]{40}'" "$LOCKFILE" \ - | tr -d "'" | tr '[:upper:]' '[:lower:]' | sort -u -) - -# Positive control: the lockfile is known to key at least one action. If the -# parse yields nothing, the FILE FORMAT changed or the read failed -- say so, -# rather than reporting every ref in the repo as missing. -if [ "${#LOCK_KEYS[@]}" -eq 0 ]; then - echo -e "${RED}[validate-actions-lock] ERROR: parsed 0 keys from $LOCKFILE${NC}" >&2 - echo " The lockfile exists but no 'owner/repo@sha' keys were found." >&2 - echo " This is a parser/format failure, NOT a coverage failure." >&2 - exit 1 -fi - -lock_has() { - local needle="$1" k - for k in "${LOCK_KEYS[@]}"; do - [ "$k" = "$needle" ] && return 0 - done - return 1 -} - -# Leaf action entries in `dependencies:` whose `ref:` equals their own 40-hex -# SHA pin fail GitHub runner lockfile verification during `Set up job` -# ("Unable to resolve action ...: invalid lockfile pin", standards#1271). -mapfile -t SELF_SHA_KEYS < <( - awk ' - /^dependencies:/ { in_dep = 1; next } - /^[a-z_]+:/ { in_dep = 0; cur = ""; next } - !in_dep { next } - /^ \x27[^\x27]+\x27:$/ { - cur = $0; sub(/^ \x27/, "", cur); sub(/\x27:$/, "", cur) - next - } - cur != "" && /^ ref: \x27[^\x27]+\x27$/ { - r = $0; sub(/^ ref: \x27/, "", r); sub(/\x27$/, "", r) - n = index(cur, "@") - pin = (n > 0) ? substr(cur, n + 1) : "" - if (length(r) == 40 && r ~ /^[0-9a-fA-F]+$/ && tolower(r) == tolower(pin)) { - print tolower(cur) - } - cur = "" - } - ' "$LOCKFILE" | sort -u -) - -# Check a normalized owner/repo@sha key against the parsed self-SHA entries. -# Return 0 if its dependency ref equals its own SHA pin, or 1 if absent. -lock_has_self_sha() { - local needle="$1" k - for k in ${SELF_SHA_KEYS[@]+"${SELF_SHA_KEYS[@]}"}; do - [ "$k" = "$needle" ] && return 0 - done - return 1 +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +assert_clean_checkout() { + local checkout=$1 git_state + # Git's normal status can hide modified consumed files behind these flags. + # NUL records keep unusual filenames from changing the flag interpretation. + git -C "$checkout" ls-files -v -z | + while IFS= read -r -d '' entry; do + case "${entry:0:1}" in + [a-z]|S) + echo "validate-actions-lock: unsafe index flags in $checkout; source bytes are unqualified" >&2 + exit 2 + ;; + *) : ;; # Other index flags continue through the full cleanliness check. + esac + done + git_state="$(git -C "$checkout" status --porcelain --untracked-files=all --ignored=matching)" || + { echo "validate-actions-lock: cannot inspect source state in $checkout" >&2; return 2; } + [[ -z "$git_state" ]] || + { echo "validate-actions-lock: tracked, untracked or ignored changes in $checkout; use an isolated clean checkout" >&2; return 2; } } - -ERRORS=0 -CHECKED=0 -declare -a SEEN_ABSENT=() - -# Collect every SHA-pinned uses: ref across all workflow files. -# -# Read with yq, never grep (YAML-POLICY Y-1). A line grep for `uses:` only -# sees block style: on a KYAML file (`uses: "owner/repo@sha", # v1`) it -# captured the quote and comma into the ref and reported a pinned, locked -# action as missing. The parser returns the scalar VALUE whatever the style, -# and never matches a `uses:` that is text inside a `run:` body. -# Measured 2026-10-01: same 29-ref set as the old grep on the block tree. -if ! command -v yq >/dev/null 2>&1; then - echo -e "${RED}[validate-actions-lock] ERROR: yq not found -- it reads the workflows (YAML-POLICY Y-1)${NC}" >&2 - exit 1 -fi -declare -a UNPARSED=() -mapfile -t RAW < <( - for f in "$WORKFLOW_DIR"/*.yml "$WORKFLOW_DIR"/*.yaml \ - "$ACTIONS_DIR"/*/action.yml "$ACTIONS_DIR"/*/action.yaml; do - [ -f "$f" ] || continue - yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$f" \ - || printf '\001UNPARSED\001%s\n' "$f" - done | grep -E $'@[0-9a-fA-F]{40}$|^\001UNPARSED\001' | sort -u -) -# A file yq cannot parse is a file whose refs went unchecked: fail closed. -for ref in "${RAW[@]}"; do - case "$ref" in $'\001UNPARSED\001'*) UNPARSED+=("${ref#$'\001UNPARSED\001'}") ;; esac +STANDARDS_REF=087f6bff5135ce3da6f287c83752256ce4a8efc1 +NATIVE_SHA256=79ec84c191667fd935a277e6fd3662cf5d3669cccc9e99998731d8f306f373b6 +: "${STANDARDS_ROOT:?checkout the pinned metadatastician/standards source; see docs/CI-CD.adoc}" +: "${GH_ACTIONS_LOCK_BIN:?path to the official gh-actions-lock v0.2.0 linux-amd64 binary}" +for tool in git jq yq sha256sum diff; do + command -v "$tool" >/dev/null || { echo "validate-actions-lock: missing $tool" >&2; exit 2; } done -if [ "${#UNPARSED[@]}" -gt 0 ]; then - echo -e "${RED}[validate-actions-lock] ERROR: yq could not parse: ${UNPARSED[*]}${NC}" >&2 - exit 1 -fi - -# A zero-input pass is the classic fake green: if ref extraction ever breaks, -# this script would report success having checked nothing. If the lockfile -# already names workflows, finding no refs is a contradiction, not an empty -# repo -- fail instead of passing vacuously. The success line below also -# prints the number checked, so a silent collapse to near-zero is visible. -if [ "${#RAW[@]}" -eq 0 ]; then - if grep -qE "^ '\\.github/workflows/" "$LOCKFILE"; then - echo -e "${RED}[validate-actions-lock] ERROR: no SHA-pinned refs extracted, yet the lockfile names workflows -- extraction is broken${NC}" >&2 - exit 1 +[[ "$GH_ACTIONS_LOCK_BIN" = /* && -f "$GH_ACTIONS_LOCK_BIN" && -x "$GH_ACTIONS_LOCK_BIN" ]] || + { echo 'validate-actions-lock: official native binary is unavailable' >&2; exit 2; } +printf '%s %s\n' "$NATIVE_SHA256" "$GH_ACTIONS_LOCK_BIN" | sha256sum --check --status || + { echo 'validate-actions-lock: native binary digest mismatch' >&2; exit 2; } +STANDARDS_ROOT="$(cd "$STANDARDS_ROOT" && pwd -P)" +[[ "$(git -C "$STANDARDS_ROOT" rev-parse --show-toplevel)" = "$STANDARDS_ROOT" ]] || + { echo 'validate-actions-lock: Standards path must be its repository root' >&2; exit 2; } +[[ "$(git -C "$STANDARDS_ROOT" rev-parse HEAD)" = "$STANDARDS_REF" ]] || + { echo 'validate-actions-lock: wrong Standards source revision' >&2; exit 2; } +assert_clean_checkout "$STANDARDS_ROOT" + +cd "$ROOT" +[[ "$(git rev-parse --show-toplevel)" = "$ROOT" ]] || + { echo 'validate-actions-lock: consumer path must be its repository root' >&2; exit 2; } +assert_clean_checkout "$ROOT" +[[ -f .github/workflows/actions.lock ]] || + { echo 'validate-actions-lock: missing native lock; use canonical recovery' >&2; exit 1; } +snapshot="$(mktemp -d)" +cp -a .github/workflows "$snapshot/workflows" +cleanup() { + local status=$? + if ! diff -qr "$snapshot/workflows" .github/workflows; then + echo 'validate-actions-lock: verification changed workflow/lock bytes; refusing success' >&2 + status=1 fi - echo -e "${YELLOW}[validate-actions-lock] no SHA-pinned refs found -- nothing to check${NC}" - exit 0 -fi - -for ref in "${RAW[@]}"; do - case "$ref" in - ./*|.\\*) continue ;; # local action, carries no SHA - */.github/workflows/*) continue ;; # reusable workflow, not keyed - esac - case "$ref" in - *.yml@*|*.yaml@*) continue ;; # reusable workflow, any path - esac - - sha="${ref##*@}" - path="${ref%@*}" - owner="${path%%/*}" - rest="${path#*/}" - repo="${rest%%/*}" - [ -n "$owner" ] && [ -n "$repo" ] || continue - norm="$(printf '%s/%s@%s' "$owner" "$repo" "$sha" | tr '[:upper:]' '[:lower:]')" - - skip=0 - for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do - ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" - if [ "$norm" = "$ex_lc" ]; then - SEEN_ABSENT+=("$ex_lc") - skip=1 - break - fi - done - [ "$skip" -eq 1 ] && continue - - CHECKED=$((CHECKED + 1)) - if ! lock_has "$norm"; then - echo -e "${RED}[validate-actions-lock] ERROR: not in actions.lock: ${ref}${NC}" >&2 - echo " normalised to: $norm" >&2 - ERRORS=$((ERRORS + 1)) - elif lock_has_self_sha "$norm"; then - echo -e "${RED}[validate-actions-lock] ERROR: self-SHA ref in actions.lock dependencies: ${norm}${NC}" >&2 - echo " leaf action entries must carry a tag/branch ref, not their own commit SHA (standards#1271)" >&2 - ERRORS=$((ERRORS + 1)) + rm -rf "$snapshot" + if [[ "$status" -eq 0 ]]; then + echo 'validate-actions-lock: canonical native verification and lock sync passed; live GitHub admission is not established' fi -done - -# A doctrine exception that is no longer used must be removed, or the list -# becomes a place where real coverage gaps can hide. -for ex in ${EXPECTED_ABSENT[@]+"${EXPECTED_ABSENT[@]}"}; do - ex_lc="$(printf '%s' "$ex" | tr '[:upper:]' '[:lower:]')" - found=0 - for s in ${SEEN_ABSENT[@]+"${SEEN_ABSENT[@]}"}; do - [ "$s" = "$ex_lc" ] && found=1 && break - done - if [ "$found" -eq 0 ]; then - echo -e "${YELLOW}[validate-actions-lock] WARNING: stale exception, no workflow uses ${ex} -- remove it from EXPECTED_ABSENT${NC}" >&2 - fi -done - -if [ "$ERRORS" -gt 0 ]; then - echo -e "${RED}[validate-actions-lock] ${ERRORS} ref(s) missing from the lockfile${NC}" >&2 - echo " Regenerate with the LOCKFILE ONLY, and verify the *.yml diff is empty:" >&2 - echo " gh actions-lock --no-migrate-local-actions --no-narrow" >&2 - echo " git diff --stat -- '.github/workflows/*.yml' # MUST be empty" >&2 - exit 1 -fi - -echo -e "${GREEN}[validate-actions-lock] ✅ ${CHECKED} SHA-pinned ref(s) found among ${#LOCK_KEYS[@]} lockfile keys, ${#EXPECTED_ABSENT[@]} doctrine exception(s)${NC}" -exit 0 + exit "$status" +} +trap cleanup EXIT +export GH_ACTIONS_LOCK_BIN +export ACTIONS_LOCK_EVIDENCE_DIR="${ACTIONS_LOCK_EVIDENCE_DIR:-$snapshot/evidence}" +mkdir -p "$ACTIONS_LOCK_EVIDENCE_DIR" +printf 'not_run\n' > "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit" +jq -n \ + --arg subject "$(git rev-parse HEAD)" \ + --arg tree "$(git rev-parse HEAD^{tree})" \ + --arg authority "$STANDARDS_REF" \ + --arg native_sha256 "$NATIVE_SHA256" \ + --arg run_id "${GITHUB_RUN_ID:-}" \ + --arg run_attempt "${GITHUB_RUN_ATTEMPT:-}" \ + '{subject_commit:$subject,subject_tree:$tree,standards_commit:$authority, + native_version:"v0.2.0",native_sha256:$native_sha256, + run_id:$run_id,run_attempt:$run_attempt}' \ + > "$ACTIONS_LOCK_EVIDENCE_DIR/source.json" + +GH_BIN="$ROOT/scripts/ci/actions-lock-transport.sh" \ + bash "$STANDARDS_ROOT/scripts/update-actions-lock.sh" --verify-local +case "$(cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit")" in + 0|1) ;; + *) + echo 'validate-actions-lock: helper returned without a completed native verifier result' >&2 + exit 1 + ;; +esac +bash "$STANDARDS_ROOT/scripts/check-lock-sync.sh" .github/workflows diff --git a/.github/actions-allowlist.json b/.github/actions-allowlist.json index e2f08d0..121d072 100644 --- a/.github/actions-allowlist.json +++ b/.github/actions-allowlist.json @@ -65,6 +65,11 @@ "leanprover/lean-action@*", "ludeeus/action-shellcheck@*", "lycheeverse/lychee-action@*", + "metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12", + "metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12", + "metadatastician/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7", + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12", + "metadatastician/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7", "mlugg/setup-zig@*", "ocaml/setup-ocaml@*", "orhun/git-cliff-action@*", @@ -83,6 +88,7 @@ "rustsec/audit-check@*", "shivammathur/setup-php@*", "slsa-framework/slsa-github-generator@*", + "slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a", "softprops/action-gh-release@*", "SonarSource/sonarcloud-github-action@*", "SonarSource/sonarqube-scan-action@*", diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 761942e..784b7d6 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -27,6 +27,7 @@ workflows: - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' '.github/workflows/ci-integrity.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' '.github/workflows/codeql.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'github/codeql-action@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' @@ -46,15 +47,15 @@ workflows: - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/governance.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/hypatia-scan.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/instant-sync.yml': - 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697' '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] '.github/workflows/mirror.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/pages.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346' @@ -82,13 +83,13 @@ workflows: - 'softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64' - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/rust-ci.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' '.github/workflows/scan-and-report.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' '.github/workflows/scorecard.yml': - - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7' + - 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7' '.github/workflows/secret-scanner-vendored.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/secret-scanner.yml': [] @@ -107,61 +108,36 @@ dependencies: commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' owner_id: 44036562 repo_id: 215566462 - 'actions/cache@v6.1.0': - ref: 'v6.1.0' - commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - owner_id: 44036562 - repo_id: 215566462 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' owner_id: 44036562 repo_id: 197814629 - 'actions/checkout@v7.0.1': - ref: 'v7.0.1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 44036562 - repo_id: 197814629 'actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d': ref: 'v6.0.0' commit: 'sha1-45bfe0192ca1faeb007ade9deae92b16b8254a0d' owner_id: 44036562 repo_id: 513659658 - 'actions/configure-pages@v6.0.0': - ref: 'v6.0.0' - commit: 'sha1-45bfe0192ca1faeb007ade9deae92b16b8254a0d' - owner_id: 44036562 - repo_id: 513659658 'actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294': ref: 'v5.0.0' commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294' owner_id: 44036562 repo_id: 476372928 - 'actions/dependency-review-action@v5.0.0': - ref: 'v5.0.0' - commit: 'sha1-a1d282b36b6f3519aa1f3fc636f609c47dddb294' - owner_id: 44036562 - repo_id: 476372928 'actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346': ref: 'v5.0.1' commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' owner_id: 44036562 repo_id: 438112499 - 'actions/deploy-pages@v5.0.1': - ref: 'v5.0.1' - commit: 'sha1-368f82528645a54fb793d4d04e342629a3f51346' - owner_id: 44036562 - repo_id: 438112499 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333': ref: 'v8.0.2' commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' owner_id: 44036562 repo_id: 192626254 - 'actions/download-artifact@v8.0.2': - ref: 'v8.0.2' - commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' + 'actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08': + ref: 'v4.6.0' + commit: 'sha1-65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08' owner_id: 44036562 - repo_id: 192626254 + repo_id: 192625955 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': ref: 'v7.0.0' commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' @@ -172,11 +148,6 @@ dependencies: commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' owner_id: 44036562 repo_id: 192625955 - 'actions/upload-artifact@v7.0.2': - ref: 'v7.0.2' - commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' - owner_id: 44036562 - repo_id: 192625955 'actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9': ref: 'v5.0.0' commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' @@ -184,13 +155,6 @@ dependencies: repo_id: 496012378 uses: - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' - 'actions/upload-pages-artifact@v5.0.0': - ref: 'v5.0.0' - commit: 'sha1-fc324d3547104276b827a68afc52ff2a11cc49c9' - owner_id: 44036562 - repo_id: 496012378 - uses: - - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed': ref: 'v2.0.5' commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' @@ -201,11 +165,6 @@ dependencies: commit: 'sha1-683b588c487a69d01f6c6ddf40f036ff682cc2c8' owner_id: 1940490 repo_id: 260749683 - 'dtolnay/rust-toolchain@v1': - ref: 'v1' - commit: 'sha1-683b588c487a69d01f6c6ddf40f036ff682cc2c8' - owner_id: 1940490 - repo_id: 260749683 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393': ref: 'v3.0.0' commit: 'sha1-51f63319f592f97930c73d9c46184d20bd206393' @@ -226,11 +185,6 @@ dependencies: commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' owner_id: 9919 repo_id: 259445878 - 'github/codeql-action@v4.39.0': - ref: 'v4.39.0' - commit: 'sha1-6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' - owner_id: 9919 - repo_id: 259445878 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406': ref: 'v2.2.1' commit: 'sha1-abea47f85e598557f500fa1fd2ab7464fcb39406' @@ -241,29 +195,32 @@ dependencies: commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' owner_id: 75048950 repo_id: 623796603 - 'haskell-actions/setup@v2.12.1': - ref: 'v2.12.1' - commit: 'sha1-0f8e8c99d88aeb3fbfd523f1ef2c6f762d10d64d' - owner_id: 75048950 - repo_id: 623796603 'hyperpolymath/smtp-notify-action@c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef': ref: 'v0.5.0' commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' owner_id: 6759885 repo_id: 1352485172 - 'hyperpolymath/smtp-notify-action@v0.5.0': - ref: 'v0.5.0' - commit: 'sha1-c1c9fa07992a02c1fd3d67a0dc1b08cccb852aef' - owner_id: 6759885 - repo_id: 1352485172 - 'hyperpolymath/standards@333b06a01db1874ffedb2de1bfabb195640927c7': + 'metadatastician/standards@333b06a01db1874ffedb2de1bfabb195640927c7': ref: '333b06a01db1874ffedb2de1bfabb195640927c7' commit: 'sha1-333b06a01db1874ffedb2de1bfabb195640927c7' - owner_id: 6759885 + owner_id: 297579217 + repo_id: 1116521501 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' + - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' + - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + 'metadatastician/standards@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12': + ref: 'a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' + commit: 'sha1-a797d308c0517d4e2d21cc7f1cb08d41f82b1a12' + owner_id: 297579217 repo_id: 1116521501 uses: - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' - 'actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9' - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' - 'dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8' @@ -271,10 +228,8 @@ dependencies: - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' - 'goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406' - - 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e' - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' - - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + - 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0' 'ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e': ref: 'v2.5.0' commit: 'sha1-fa7117db83c52d787ae5a89f350999701505d78e' @@ -290,41 +245,35 @@ dependencies: commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' owner_id: 18365890 repo_id: 220359305 - 'peter-evans/repository-dispatch@v4.0.1': - ref: 'v4.0.1' - commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' - owner_id: 18365890 - repo_id: 220359305 'slsa-framework/slsa-github-generator@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a': + ref: 'f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' + commit: 'sha1-f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' + owner_id: 80431187 + repo_id: 475074978 + uses: + - 'actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08' + - 'slsa-framework/slsa-github-generator@v2.1.0' + - 'softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda' + 'slsa-framework/slsa-github-generator@v2.1.0': ref: 'v2.1.0' commit: 'sha1-f7dd8c54c2067bafc12ca7a55595d5ee9b75204a' owner_id: 80431187 repo_id: 475074978 - 'softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64': - ref: 'v3.0.3' - commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' + 'softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda': + ref: 'v2.2.1' + commit: 'sha1-c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda' owner_id: 2242 repo_id: 204253808 - 'softprops/action-gh-release@v3.0.3': + 'softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64': ref: 'v3.0.3' commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' owner_id: 2242 repo_id: 204253808 - 'swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6': - ref: 'v2.9.2' - commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' - owner_id: 580492 - repo_id: 298565987 'swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0': ref: 'v2.9.3' commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' owner_id: 580492 repo_id: 298565987 - 'swatinem/rust-cache@v2.9.3': - ref: 'v2.9.3' - commit: 'sha1-d1527c8f67ef9af357290475d4af4b0fab8f12e0' - owner_id: 580492 - repo_id: 298565987 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': ref: 'v0.10.0' commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' diff --git a/.github/workflows/ci-integrity.yml b/.github/workflows/ci-integrity.yml index f2228c4..20e1e8a 100644 --- a/.github/workflows/ci-integrity.yml +++ b/.github/workflows/ci-integrity.yml @@ -1,35 +1,9 @@ # SPDX-License-Identifier: MPL-2.0 # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell # -# ci-integrity.yml — gates on the CI *system* itself, not on the code. -# -# WHY THIS WORKFLOW EXISTS -# ------------------------ -# On 2026-10-25 this repository's CI went dark for four hours and the red was -# unreadable: 12 of 12 pull_request workflows concluded `startup_failure` with -# zero jobs and no log. The cause was not code. It was two policy mismatches -# between this repo and the estate's own settings canon: -# -# 1. `uses: metadatastician/standards/...` — the canonical allowlist -# (rhodium-standard-repositories/actions-allowlist/allowed-actions.json) -# permits `hyperpolymath/*` and does NOT permit `metadatastician/*`. -# 2. tag-pinned `uses:` (actions/checkout@v7.0.1) while the estate posture is -# `sha_pinning_required = true`. -# -# A run that never starts cannot be diagnosed from its log, cannot be -# satisfyingly re-run, and does not report a check at all — so a required -# context silently stops arriving and the branch looks healthier than it is. -# These four gates make each cause of that failure legible *before* it happens: -# -# sha-pins every third-party `uses:` names a 40-hex commit -# lock-coverage every SHA-pinned action is keyed in .github/workflows/actions.lock -# allowlist every `uses:` is covered by the estate allowlist -# yaml-parse every workflow actually parses, and has a jobs: map -# -# NO `paths:` FILTER, deliberately: this check is meant to be REQUIRED, and a -# required check that filters itself off never reports (see standards -# required-contexts.yml). It is a checkout plus a few seconds of bash. - +# Source integrity checks cannot establish GitHub's effective live Actions +# policy or explain a zero-job startup failure without its actual diagnostic. +# Native lock generation belongs to the published canonical recovery chain. name: CI Integrity on: @@ -37,7 +11,7 @@ on: push: branches: [main, master] schedule: - - cron: '17 5 * * 1' # Mondays, off the hour + - cron: '17 5 * * 1' workflow_dispatch: permissions: @@ -48,8 +22,8 @@ concurrency: cancel-in-progress: true env: - # Pinned: this is the parser the canon validators require (YAML-POLICY Y-1). YQ_VERSION: v4.54.1 + STANDARDS_REF: 087f6bff5135ce3da6f287c83752256ce4a8efc1 jobs: integrity: @@ -57,41 +31,69 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: + - name: Initialize runner-local paths and evidence + shell: bash + run: | + set -euo pipefail + standards_root="$RUNNER_TEMP/panic-ci-integrity-standards" + evidence_dir="$RUNNER_TEMP/panic-ci-integrity-evidence" + mkdir -p "$evidence_dir" + printf 'not_run\n' > "$evidence_dir/native.exit" + { + printf 'STANDARDS_ROOT=%s\n' "$standards_root" + printf 'ACTIONS_LOCK_EVIDENCE_DIR=%s\n' "$evidence_dir" + } >> "$GITHUB_ENV" + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Install yq + - name: Install checksum-qualified YAML and native lock tools + run: | + set -euo pipefail + tool_dir="$RUNNER_TEMP/panic-ci-integrity-bin" + mkdir -p "$tool_dir" + curl --fail --show-error --silent --location --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 --max-time 60 \ + "https://github.com/mikefarah/yq/releases/download/$YQ_VERSION/yq_linux_amd64" -o "$tool_dir/yq" + printf '%s %s\n' '8e34fc298390875de416e6a4afcb8cabeceb25d9aa8506c1a2f9353cf702ea5f' "$tool_dir/yq" | sha256sum --check + curl --fail --show-error --silent --location --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 --max-time 60 \ + https://github.com/github/gh-actions-lock/releases/download/v0.2.0/linux-amd64 -o "$tool_dir/gh-actions-lock" + printf '%s %s\n' '79ec84c191667fd935a277e6fd3662cf5d3669cccc9e99998731d8f306f373b6' "$tool_dir/gh-actions-lock" | sha256sum --check + chmod u+x "$tool_dir/yq" "$tool_dir/gh-actions-lock" + "$tool_dir/yq" --version + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + printf 'GH_ACTIONS_LOCK_BIN=%s\n' "$tool_dir/gh-actions-lock" >> "$GITHUB_ENV" + + - name: Fetch unchanged published canonical helpers run: | set -euo pipefail - url="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" - curl -fsSL "$url" -o /usr/local/bin/yq - chmod +x /usr/local/bin/yq - yq --version + git init --quiet "$STANDARDS_ROOT" + timeout 120 git -C "$STANDARDS_ROOT" fetch --quiet --no-tags --depth=1 \ + https://github.com/metadatastician/standards.git "$STANDARDS_REF" + git -C "$STANDARDS_ROOT" checkout --quiet --detach FETCH_HEAD + test "$(git -C "$STANDARDS_ROOT" rev-parse HEAD)" = "$STANDARDS_REF" + + - name: CI integrity regression controls + run: bash scripts/ci/test-ci-integrity.sh - name: SHA pins (canon rule 10) run: bash .githooks/validate-sha-pins.sh - - name: Lockfile is derived from the workflows - run: python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json --check + - name: Canonical executable workflow coordinates + run: bash "$STANDARDS_ROOT/scripts/check-workflow-coordinates.sh" .github/workflows - - name: Lockfile covers every SHA-pinned action + - name: Native lock coverage and canonical lock sync run: bash .githooks/validate-actions-lock.sh - - name: Actions allowlist (would this run start?) + - name: Exact tree allowlist (live Settings unverified) run: bash scripts/ci/check-allowed-actions.sh .github/actions-allowlist.json .github/workflows - # The cause no file scan can see: a `uses:` that is well-formed, correctly - # pinned, and still refused at startup (`referenced_workflows: []`, zero - # jobs, no log) because the repository's allowed-actions policy does not - # cover it — or because the referenced repository was renamed and nothing - # covers the new owner. Both are named here, with the evidence. - - name: Startup-failure triage + - name: Startup-failure source triage env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ "${{ github.event_name }}" = workflow_dispatch ]; then bash scripts/ci/triage-startup-failure.sh else bash scripts/ci/triage-startup-failure.sh --report @@ -103,32 +105,42 @@ jobs: fail=0 for f in .github/workflows/*.yml .github/workflows/*.yaml; do [ -e "$f" ] || continue - case "$f" in *.lock) continue ;; esac if ! yq -e '.' "$f" >/dev/null 2>&1; then echo "::error file=$f::does not parse as YAML" fail=1 continue fi - # A file that parses to a scalar (unclosed quote swallowing the - # document) has no jobs at all and dies at startup. Refuse it. - if [ "$(yq -r '.jobs | tag' "$f" 2>/dev/null)" != '!!map' ]; then + if [ "$(yq -r '.jobs | tag' "$f")" != '!!map' ]; then echo "::error file=$f::parses but declares no jobs: map" fail=1 fi done exit "$fail" + - name: Retain raw native result and immutable source identity + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ci-integrity-native-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/panic-ci-integrity-evidence + if-no-files-found: error + retention-days: 14 + - name: Summary if: always() run: | { - echo "## CI integrity" - echo "" - echo "| Gate | What it prevents |" - echo "|------|------------------|" - echo "| SHA pins | tag-pinned \`uses:\` refused at startup under \`sha_pinning_required\` |" - echo "| Lockfile | a reusable caller dying with \`failure\`, 0 jobs, no reason |" - echo "| Allowlist | \`startup_failure\` from an action outside the estate allowlist |" - echo "| YAML parse | a workflow that parses to nothing and never runs |" - echo "| Startup triage | \`startup_failure\` with zero jobs: unpinned, renamed, missing pin, or refused by the repo's actions policy |" + echo '## CI integrity' + echo '' + echo 'Step conclusions record the result; this table describes each gate.' + echo '' + echo '| Gate | Scope |' + echo '|------|-------|' + echo '| SHA pins and coordinates | Immutable refs and canonical executable repository names |' + echo '| Native lock | Official v0.2.0 result, unchanged published reconciliation and bidirectional lock sync |' + echo '| Tree allowlist | Complete action/workflow path and exact SHA, without a live Settings claim |' + echo '| YAML parse | Readable workflow documents with a jobs map |' + echo '| Startup triage | Source/API observations; no inferred platform root cause from zero jobs alone |' + echo '' + echo 'The raw artifact records not_run if consumer verification never executed, or running if no native exit was recorded.' } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 08448a5..4c698b6 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -17,4 +17,4 @@ permissions: jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/governance-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 720ee31..c7cdcac 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -20,4 +20,4 @@ permissions: jobs: hypatia: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/hypatia-scan-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 5ec8af8..952687b 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -15,7 +15,7 @@ permissions: jobs: mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/mirror-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 secrets: GITLAB_SSH_KEY: ${{ secrets.GITLAB_SSH_KEY }} BITBUCKET_SSH_KEY: ${{ secrets.BITBUCKET_SSH_KEY }} diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 73b1435..43cadab 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -3,7 +3,7 @@ # This workflow is managed by gh actions-lock. # This workflow is managed by gh actions-lock. # Rust CI — thin wrapper calling the shared estate reusable in -# hyperpolymath/standards. Configure once, propagate everywhere. +# metadatastician/standards. Configure once, propagate everywhere. # See: docs/CI-REUSABLE-WORKFLOWS.adoc in standards. name: Rust CI @@ -18,4 +18,4 @@ permissions: jobs: rust-ci: - uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # re-pinned 2026-07-01: toolchain-required-input fix (standards#439) + uses: metadatastician/standards/.github/workflows/rust-ci-reusable.yml@a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 # canonical standards: reviewed reusable closure diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 398c75d..b7eeb0c 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -15,7 +15,7 @@ permissions: jobs: scorecard: - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 + uses: metadatastician/standards/.github/workflows/scorecard-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 # 333b06a01db1874ffedb2de1bfabb195640927c7 permissions: actions: read # required by scorecard-reusable.yml to read workflow metadata contents: read diff --git a/.github/workflows/secret-scanner-vendored.yml b/.github/workflows/secret-scanner-vendored.yml index 9df24e2..bce8a0c 100644 --- a/.github/workflows/secret-scanner-vendored.yml +++ b/.github/workflows/secret-scanner-vendored.yml @@ -25,8 +25,8 @@ # WHAT THIS IS NOT # ---------------- # This is not a weakened or stubbed check: it is the canon file at commit -# 333b06a01db1874ffedb2de1bfabb195640927c7, byte-for-byte, with only the -# `name:` line changed. Same pinned, checksum-verified gitleaks binary, same +# 333b06a01db1874ffedb2de1bfabb195640927c7, adapted for local reuse with a +# local name and explicit baseline source pins. Same checksum-verified binary, same # three jobs (gitleaks working-tree + git-history + AsciiDoc mirror, Rust # hardcoded-secret scan, shell-secret scan), same estate baseline, same gate. # Nothing is suppressed that the reusable did not already suppress, and the @@ -42,6 +42,8 @@ # `docs/ci/PROMPT-actions-policy.md` is the prerequisite for removing it. # # VENDORED-FROM: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@333b06a01db1874ffedb2de1bfabb195640927c7 +# Runtime baseline fetches use the canonical metadatastician/standards coordinate +# at that same immutable commit; the historical source record above is retained. name: Secret Scanner (reusable, vendored) @@ -156,7 +158,7 @@ jobs: if: hashFiles('.gitleaks.toml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: hyperpolymath/standards + repository: metadatastician/standards ref: 333b06a01db1874ffedb2de1bfabb195640927c7 # canon SHA this file was copied from; job.workflow_sha would name THIS repo's commit when the reusable is local path: .standards-gitleaks sparse-checkout: | @@ -233,7 +235,7 @@ jobs: if: steps.estate_baseline.outputs.referenced == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: hyperpolymath/standards + repository: metadatastician/standards ref: 333b06a01db1874ffedb2de1bfabb195640927c7 # canon SHA this file was copied from; job.workflow_sha would name THIS repo's commit when the reusable is local path: .estate-baseline-checkout sparse-checkout: config/gitleaks diff --git a/Cargo.lock b/Cargo.lock index d10e538..4ba54d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3068,6 +3068,8 @@ dependencies = [ "tempfile", "toml_edit", "tree-sitter", + "tree-sitter-css", + "tree-sitter-html", "tree-sitter-php", "ureq", "walkdir", @@ -4301,6 +4303,26 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-css" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cbc5e18f29a2c6d6435891f42569525cf95435a3e01c2f1947abcde178686f" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index a412e58..447a091 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ repository = "https://github.com/hyperpolymath/panic-attack" [dependencies] tree-sitter = "=0.27.0" tree-sitter-php = "=0.25.1" +tree-sitter-html = "=0.23.2" +tree-sitter-css = "=0.25.0" clap = { version = "4.6", features = ["derive"] } clap_complete = "4.6" clap_complete_nushell = "4.6" @@ -42,7 +44,7 @@ sha2 = "0.11" hex = "0.4" getrandom = "0.4" walkdir = "2.5" -toml_edit = { version = "0.23", default-features = false, features = ["parse"], optional = true } +toml_edit = { version = "0.23", default-features = false, features = ["parse"] } dirs = "7.0" ed25519-dalek = { version = "3.0", optional = true } ureq = { version = "3.3", optional = true } @@ -50,7 +52,7 @@ ureq = { version = "3.3", optional = true } [features] default = [] signing = ["ed25519-dalek"] -http = ["ureq", "dep:toml_edit"] +http = ["ureq"] sarif = [] # Native GUI viewer (eframe/egui). Opt-in because eframe raises MSRV above the # 1.90 baseline; default builds remain pure-CLI and MSRV-clean. diff --git a/Justfile b/Justfile index f3a5ef4..365c6d9 100644 --- a/Justfile +++ b/Justfile @@ -273,26 +273,27 @@ fmt-check: test-local: cargo test --locked -# CI integrity: SHA pins, lockfile coverage, allowlist coverage, YAML parse. -# Needs `yq` (mikefarah) and `python3`; see scripts/ci/. +# CI integrity: requires the pinned clean Standards checkout and official binary. +# See docs/CI-CD.adoc for STANDARDS_ROOT and GH_ACTIONS_LOCK_BIN. integrity: #!/usr/bin/env bash set -euo pipefail command -v yq >/dev/null || { echo "integrity: yq not found (mikefarah/yq)"; exit 2; } bash .githooks/validate-sha-pins.sh - python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json --check bash .githooks/validate-actions-lock.sh + bash "$STANDARDS_ROOT/scripts/check-workflow-coordinates.sh" .github/workflows bash scripts/ci/check-allowed-actions.sh .github/actions-allowlist.json .github/workflows - for f in .github/workflows/*.yml; do - case "$f" in *.lock) continue ;; esac - [ "$(yq -r '.jobs | tag' "$f")" = '!!map' ] || { echo "integrity: $f has no jobs map"; exit 1; } - done - echo "integrity: OK" + echo "integrity: source gates passed; live GitHub admission remains unverified" -# After changing any `uses:`: re-resolve tag -> commit and rewrite the lockfile. +# Native generation is an explicitly requested canonical recovery operation. +# Printing the subject is not generation or authorization to dispatch. pins: - bash scripts/ci/resolve-action-shas.sh - python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json + #!/usr/bin/env bash + set -euo pipefail + echo "Native lock generation moved to hyperpolymath/proven-cicd Actions Lock Recovery." + printf 'Immutable source candidate: %s\n' "$(git rev-parse HEAD)" + echo "Follow docs/CI-CD.adoc; no workflow or lock bytes were changed." >&2 + exit 2 # ── Evidence-graded testing (proven-tests-and-benches) ─────────────────────── diff --git a/docs/CI-CD.adoc b/docs/CI-CD.adoc index 03076c9..ddae0c9 100644 --- a/docs/CI-CD.adoc +++ b/docs/CI-CD.adoc @@ -5,60 +5,31 @@ :toclevels: 3 :sectnums: -== The failure this replaces - -On 2026-10-25 this repository's CI went dark for four hours. Twelve -`pull_request` workflows concluded `startup_failure`: **zero jobs, no log, no -annotation, no check run**. Nothing in the GitHub UI said why, and the only -diagnosis available was to read the estate's settings canon and compare it with -our own workflow files. - -Four causes, all policy-level, none visible from the code: - -tag-pinned `uses:`, e.g. `actions/checkout@v7.0.1`:: The estate posture is -`sha_pinning_required = true`. Under it a tag-pinned `uses:` is refused **at -startup** — the same opaque death. The control is worth having, so the fix is to -pin properly: every `uses:` in this repository now names a 40-hex commit with a -`# ` comment, and `ci-integrity.yml` refuses a regression. - -this repository's allowed-actions policy:: The remaining six — `rust-ci`, -`governance`, `hypatia-scan`, `secret-scanner`, `scorecard`, `mirror` — call the -estate's reusable workflows, and every run of them concludes `startup_failure` -with zero jobs. The estate allowlist already covers `hyperpolymath/*`, and -sibling repositories call the *same* reusable at the *same* pin successfully, so -the refusal is scoped to this repository: `allowed_actions: selected` with -`verified_allowed` passes GitHub-owned and marketplace-verified actions and -refuses a non-verified cross-repository `uses:`. That is the exact pattern here — -checkout, cache and `dtolnay/rust-toolchain` work; every estate call dies. -`secret-scanner.yml` is the serious one: `main` requires the context -**`scan / gitleaks`**, which can therefore never arrive, and no pull request can -merge until it does. This needs repository admin (`allowed_actions: all`, the -canon default, or `hyperpolymath/*` in `patterns_allowed`); the policy endpoints -are 403 for an ordinary token. See `docs/ci/PROMPT-actions-policy.md`. - -a `uses:` pinned to a commit that no longer exists upstream:: The caller dies -before its first job, exactly like the other two. Cheap to check, so it is. - -The signature that distinguishes all three from an ordinary build failure is in -the runs API: `jobs: []` and `referenced_workflows: []` on a run whose workflow -has a job at all. `scripts/ci/triage-startup-failure.sh` (run by -`ci-integrity.yml`, strict on `workflow_dispatch`) resolves every referenced -repository's canonical name and every reusable pin, so the next occurrence is one -command away from a sentence: - -[source,sh] ----- -scripts/ci/triage-startup-failure.sh -# hyperpolymath/standards is now metadatastician/standards — allowlist patterns -# match the canonical name, so a `uses:` written with the old owner is refused at -# startup (signature: startup_failure with referenced_workflows: []). ----- - -The lesson generalises and is the reason this document exists: *a run that never -starts is invisible to every gate that watches runs*. It produces no check run, -so a required context silently stops arriving and the branch looks **healthier** -than a broken one. Two of the three causes are now gated before they can bite -(see <>); the third is named, tracked, and evidenced. +== What the observed startup failures establish + +At source `5dfc5c70a74190706312d87343959615ac77484a`, the fourteen +pull-request runs created on 2026-11-04 conclude `startup_failure` and have +empty job lists. For example, +https://github.com/hyperpolymath/panic-attack/actions/runs/41912719217[CI Integrity] +has no job log to inspect. These observations do not identify the platform's +rejection reason. Effective GitHub Actions Settings and the actual startup +diagnostic have not been verified. + +There are independently observable source defects. The five Standards callers +now use canonical `metadatastician/standards` coordinates, but their committed +lock entries still name `hyperpolymath/standards@333b06a...`. Three callers +also changed their source pin to `a797d308...`. The tree allowlist omits the +canonical calls, and its former checker discarded both subpaths and refs. +The removed Python generator manufactured native dependency records by +combining old identity fields with newly inferred pins. Those facts require +repair; they do not by themselves prove the cause of all fourteen startup +failures. + +The source gates below distinguish immutable references, native coverage, +canonical coordinate identity and offline policy matching. A successful source +check does not certify live admission, billing, or a particular GitHub startup +diagnostic. Missing required contexts remain missing until real runs publish +them; source repairs are not substitutes for those runs. == The shape of the pipeline @@ -72,8 +43,8 @@ than a broken one. Two of the three causes are now gated before they can bite | every PR, every push | `ci-integrity.yml` -| Can the CI itself start? SHA pins, lockfile coverage, allowlist coverage, - YAML parseability. +| Are immutable source refs, native coverage, exact tree policy and YAML + consistent? Live GitHub admission remains a separate observation. | every PR, every push, Mondays | `proven-tests.yml` @@ -114,52 +85,112 @@ another repository can remove this repository's entire build signal. `verify` depends on nothing but actions this repository pins itself, so the "does it compile and pass" answer survives any policy change elsewhere. -<> === CI integrity - -`ci-integrity.yml` runs four gates, each of which corresponds to a way a -workflow dies before its first job: - -SHA pins:: `.githooks/validate-sha-pins.sh` (canon). Every third-party `uses:` -names a 40-hex commit plus a `# ` comment. -Lockfile coverage:: `.githooks/validate-actions-lock.sh` (canon), plus -`scripts/ci/pin-workflow-uses.py --check`, which derives the lockfile from the -workflows. A caller pinned to a commit whose lockfile has drifted dies with -`failure`, 0 jobs and *no reason in the API* - which is why this is a gate and -not a courtesy. -Allowlist coverage:: `scripts/ci/check-allowed-actions.sh` (canon) against the -vendored `.github/actions-allowlist.json`. No API, no admin: it lists exactly -which `uses:` the estate policy does not cover. -YAML parseability:: every workflow parses *and* declares a `jobs:` map. A file -whose quoting swallowed the document parses to a scalar and never runs. -Startup-failure triage:: `scripts/ci/triage-startup-failure.sh` checks every -`uses:`: 40-hex pin, canonical repository name after any rename, and that each -reusable pin still exists. It also states the one cause no file can show — a -repository-scoped allowed-actions policy refusing a cross-repository reference — -and can print sibling repositories' run conclusions as the discriminator. -Report-only on a pull request (the finding is an owner action, not a code -change), strict on `workflow_dispatch`. - -Maintenance: after changing any `uses:`, run - -[source,sh] +[[integrity]] +=== CI integrity + +The required job context remains `CI integrity (pins, lock, allowlist, YAML)`, +with the same triggers and read-only repository permission. + +SHA pins:: `.githooks/validate-sha-pins.sh` checks external action and +reusable refs. The offline allowlist also requires a complete 40-hex SHA before +matching an external coordinate. + +Canonical names:: The unchanged published Standards +`scripts/check-workflow-coordinates.sh` rejects executable former-owner +`hyperpolymath/standards` coordinates, including runtime checkouts. +Historical comments are not executable references. + +Native lock coverage:: `.githooks/validate-actions-lock.sh` invokes the +official `gh-actions-lock v0.2.0` binary through the unchanged published +`scripts/update-actions-lock.sh --verify-local`, then +`scripts/check-lock-sync.sh`. The latter checks reusable and local composite +references in both directions. No consumer code emits native lock records. +The gate checks the native executable digest, the exact clean Standards +checkout, a clean consumer commit, and unchanged workflow/lock bytes. Both +checkouts reject assume-unchanged/skip-worktree index flags and ignored inputs; +Git status alone cannot bind hidden working bytes to the recorded commit. + +Tree allowlist:: `scripts/ci/check-allowed-actions.sh` parses YAML, resolves +aliases, and reads job/step `uses` values. Owner/repository spelling is +case-insensitive; the complete subpath and ref must match. The five canonical +Standards permissions name only the existing reviewed paths and SHAs. +Existing unrelated patterns are retained. The GitHub-owned flag is respected; +Marketplace verification and effective service Settings are not inferred. + +YAML and triage:: Unparseable workflows, invalid ref structures and an empty +input population fail. Printed/commented examples are not executable refs. +The source/API triage reports observations rather than claiming that zero jobs +prove an allowlist failure. + +The helper source is +https://github.com/metadatastician/standards/tree/087f6bff5135ce3da6f287c83752256ce4a8efc1[Standards `087f6bff...`]. +CI fetches that immutable public commit outside the consumer checkout. +The official Linux amd64 executable is release asset 634768078, with SHA-256 +`79ec84c191667fd935a277e6fd3662cf5d3669cccc9e99998731d8f306f373b6`. +The YAML reader is mikefarah yq v4.54.1, release asset 597222595, SHA-256 +`8e34fc298390875de416e6a4afcb8cabeceb25d9aa8506c1a2f9353cf702ea5f`. + +For local verification, supply absolute paths to the same clean helper +checkout and checksum-matching binary, with yq, jq, Bash and Git available: + +[source,shell] ---- -bash scripts/ci/resolve-action-shas.sh # network: resolves tag -> commit -python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json +export STANDARDS_ROOT=/absolute/path/to/standards +export GH_ACTIONS_LOCK_BIN=/absolute/path/to/gh-actions-lock +just integrity ---- -`action-pins.json` is committed on purpose: it records which tag a pin stood for -on the day it was taken, so a reviewer can tell an intentional bump from a -silent re-point. - -One hard-won rule about `actions.lock`: it is a **superset** of what `grep -uses:` finds. `gh actions-lock` also records actions that composite actions -resolve internally — `actions/upload-pages-artifact` runs -`actions/upload-artifact@bbbca2dd…` itself — and GitHub validates the lockfile -at *job setup*. A regenerator that rebuilds the file from the workflows deletes -those entries, and the only symptom is `lockfile missing pin for …` in a job -that never runs a step. `scripts/ci/pin-workflow-uses.py` therefore MERGES its -`dependencies:` section instead of rebuilding it, and `--check` (which CI runs) -is idempotent over the merged form. +==== Temporary process-status adapter + +The published helper can promote a valid-looking report even when the native +process exits 2 or higher. The separate upstream repair is tracked in +https://github.com/metadatastician/standards/pull/1394[Standards #1394]. +Its unsigned, unqualified candidate is not copied into this consumer. + +`scripts/ci/actions-lock-transport.sh` accepts only +`actions-lock --verify-local` and the same command with +`--json=valid,findings`. It invokes the official binary without ambient +authentication or alternate workflow-directory settings. Native exit 0/1 and +their unmodified stdout reach the published helper. For every other exit, +stdout is retained as raw failure evidence but is not offered to that helper +as a reconcilable report. The native status remains nonzero. This adapter +neither parses findings nor changes the published reconciliation rules. + +The artifact retains actual stdout, stderr, exit status, consumer commit/tree, +helper revision and executable digest. `not_run` means consumer verification +did not execute; `running` means no native exit was recorded after invocation. A native exit 1 can pass only through the existing canonical +source reconciliation and its actual findings. No report is replaced with an +empty success. This status guard does not claim to repair every upstream +report-shape defect. Remove it after #1394's signed, hosted-qualified fix is +adopted at a reviewed canonical pin. + +`scripts/ci/test-ci-integrity.sh` includes exact path/SHA negatives, +quoted/flow YAML controls, deliberately injected valid-JSON/exit-2 and +missing-tool/timeout statuses, malformed reports and unsupported argv. +Those mocked protocol cases are distinguished from the real official +malformed-lock control and from actual consumer verification. Their presence +in source is not an execution receipt; hosted results must record execution. + +==== Updating the native lock + +Commit the reviewed source change first. Use the existing +https://github.com/hyperpolymath/proven-cicd/actions/workflows/actions-lock-recovery.yml[canonical Actions Lock Recovery workflow] +for `hyperpolymath/panic-attack` at that exact 40-character commit. +Dispatch is a separate authorized operation; `just pins` prints the +candidate SHA and exits nonzero without generating or dispatching anything. + +The recovery workflow runs the official binary and the published canonical +`regen_lock` chain in its isolated subject checkout. It restores workflow +bytes and produces a candidate artifact. Before applying that artifact, +verify its exact source identity, actual execution result, canonical lock-sync +result, preserved workflow bytes and native dependency identities. Commit only +the reviewed generated lock after those checks. Reconcile a changed subject +before generating again. + +The legacy `action-pins.json` and resolver record historical tag references; +they are not native identity evidence and no longer drive generation. +Do not reconstruct native owner/repository IDs, transitive edges or ref fields, +and do not remove workflow coverage to obtain a passing report. == Testing: evidence-graded, not declared diff --git a/docs/input-coverage.adoc b/docs/input-coverage.adoc new file mode 100644 index 0000000..0f302cb --- /dev/null +++ b/docs/input-coverage.adoc @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Configuration, template and build input coverage +:toc: + +== Contract + +The discovery registry and execution dispatcher select the same input adapter. +Discovery is an inventory, not an acknowledgement that checks ran. An input is +`analyzed` only after its bounded read and all selected adapter checks finish. +Its record retains a BLAKE3 digest of the actual bytes and the adapter's depth. +Read, parse and unsupported-dialect errors remain explicit gaps. Other successfully +analysed inputs and their findings remain in the report. + +`coverage.status == "complete"` means that every candidate in the declared +inventory finished its stated adapter. It does not mean complete semantic +security coverage. Parsing establishes a syntactic input for local checks; it +does not establish application-schema validity, deployment safety, an execution +trace, interprocedural dataflow or a successful build. A completed adapter can +produce critical findings. `--fail-on-incomplete` checks completion only; a +consumer's severity gate is independent and must still inspect active findings. + +The existing named build/vendor pruning, explicit symlink exclusions, 16 MiB +read bound and unsupported-format inventory remain. No test-directory exclusion +is added. XML, Swift, F# and other selected formats without adapters remain gaps. +Ordinary F# in an ambiguous `.fs` file is not silently treated as Forth. +Containerfile/Dockerfile variants and `.just` files now enter the inventory too. +The registry is bounded, not a universal detector of custom source formats. + +== Declared adapters + +[cols="1,3,3",options="header"] +|=== +|Input |Checks that run |Remaining limits + +|JSON, YAML, TOML +|Real parsing, unique-key enforcement, recursive credential-field, disabled +certificate-verification, privilege, plaintext-endpoint and Cargo revision +checks. All YAML documents are inspected with the parser's resource budgets. +|No complete application schema, deployment, secret-validity or dataflow proof. +Schema namespace strings are not endpoints; nested objects remain checked. +Possible credential values are not copied into findings. + +|Actions YAML and package.json +|Recognised workflow and composite `run` steps use existing shell, Python or +JavaScript checks. Step, job and workflow shell precedence is observed. Known +non-Windows runner defaults and container jobs select shell checks; unresolved +or unimplemented interpreters leave gaps. npm scripts receive POSIX-style shell +heuristics, with the unresolved runtime shell stated in findings and coverage. +|No Actions expression expansion, referenced-action loading, matrix evaluation +or execution. npm can use cmd.exe or externally configured script-shell; these +checks do not establish which runtime shell runs. Generic fields called `run` +are not automatically executable code. Source fields are named without invented +physical line numbers. + +|HTML, HTM +|Pinned tree-sitter-html parsing; duplicate-attribute, executable-URL, +plaintext-resource and remote-script integrity checks. Inline JavaScript/event +handlers use existing JS heuristics. Styles use the CSS parser. Declared JSON +script bodies use the JSON checks. +|No browser, template expansion, dataflow or fetched resource checks. Recovered +parse errors, unsupported embedded types and encoded executable/URL attributes +whose interpretation is not implemented remain gaps. + +|CSS +|Pinned tree-sitter-css parsing, expression-function and insecure/executable +URL checks, including embedded style blocks. +|No computed style, browser execution or complete CSS escape resolution. + +|Containerfile, Dockerfile and their dotted variants +|Logical instruction extraction, FROM/stage-reference and remote ADD digest +checks, COPY source-stage/image checks, literal ENV/ARG/LABEL credential fields, +explicit-root-user checks, JSON exec-vector validation, and existing +interpreter heuristics for executable source blocks. Plain exec argv is not +misrepresented as shell source. +|No full Docker grammar, image resolution, image contents or build execution. +Heredocs, ONBUILD, unknown instructions/interpreters and unsupported escape +directives remain gaps. Declarative instructions have instruction-level +validation, not full application semantics. + +|Justfile, justfile, .just +|Recipe/declaration extraction and existing shell/Python/JavaScript checks for +recipe bodies, respecting explicit shebangs and known default interpreters. +|No complete Just grammar, import resolution, interpolation or task execution. +Unknown interpreters, executable top-level substitutions and unhandled script +declarations remain gaps. Imported .just files are inventoried separately when +they are inside the declared traversal. + +|Forth .fs, .fth, .forth, .4th +|Word-level evaluate/system/raw-memory hazards with comments and quoted data +masked. Ambiguous .fs conservatively requires a leading named colon definition +with matched definition boundaries; independent type/list punctuation, a +different prelude, and unfinished lexical forms remain gaps. +|No Forth grammar, dictionary/stack interpretation or execution proof. A lexical +witness distinguishes this adapter's bounded scope; it is not a type check. +|=== + +HTML and CSS grammars are exact dependencies `tree-sitter-html = 0.23.2` and +`tree-sitter-css = 0.25.0`. Cargo.lock is generated through Cargo and contains +their registry checksums. Both grammar packages use MIT licensing. Existing +toml_edit is now available to normal assail builds as well as Patch Bridge. + +The existing 49 source-language dispatchers keep their previous depth; adding +structured input adapters does not upgrade those heuristic checks into parsers. +See link:php-coverage.adoc[PHP and schema 2.6 coverage] for PHP and the common +inventory/report contract. + +Registered build-file names take precedence over coincidental source suffixes, +and supported ASCII extensions are recognised even when the rest of a Unix +filename is non-UTF-8. Discovery and execution use that same choice. The shared +literal-credential check also covers named HTML attributes, CSS declarations, +Just literals and Forth string constants; it never scans comment text as those +fields. A field match is a review signal, not proof that a credential is live. +Build-input environment references are distinguished from literal values only +in their format context; escaped or single-quoted dollar signs remain literal. +Adapter inputs remain in coverage but do not vote for a repository's source +language or disable its language-specific metrics. + +Container exec forms recognise clustered shell flags; script arguments remain +arguments. Malformed JSON command text follows BuildKit's shell fallback, while +valid arrays containing non-string elements remain errors. SRI checking requires +a supported algorithm and correctly sized base64 metadata, without fetching or +verifying the remote resource. Encoded inline style attributes remain unsupported. + +== Regression evidence + +`configuration_coverage_tests`, `template_coverage_tests` and +`build_input_coverage_tests` exercise real adapter entry points through assail. +They include planted hazards, benign comment/string/data controls, malformed +and duplicate structures, unsupported interpreters, non-pinned dependencies, +direct-exec distinctions and preservation of unrelated unsupported candidates. +`coverage_php_tests` continues to test the shared inventory and CLI boundary. + +Repository scans are additional evidence about those exact bytes, not a reason +to remove failing candidates. Real malformed configuration must be repaired at +the consumer. An empty report placeholder is not a clean scan result. A proof +fixture's unfinished theorem remains a finding even when its path is under +`tests/`; reviewed fixture intent and actual consuming-test execution evidence +are separate from scanner completion. + +== Primary format references + +* https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax[GitHub Actions workflow syntax and shell defaults] +* https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax[GitHub Actions composite metadata] +* https://docs.npmjs.com/cli/v11/using-npm/scripts/[npm script execution and runtime shell] +* https://github.com/tree-sitter/tree-sitter-html/tree/v0.23.2[tree-sitter HTML grammar 0.23.2] +* https://github.com/tree-sitter/tree-sitter-css/tree/v0.25.0[tree-sitter CSS grammar 0.25.0] +* https://github.com/moby/buildkit/blob/master/frontend/dockerfile/parser/line_parsers.go[BuildKit command-form parsing] +* https://www.gnu.org/software/bash/manual/html_node/Invoking-Bash.html[Bash invocation and option processing] +* https://www.w3.org/TR/sri/[Subresource Integrity metadata] +* https://forth-standard.org/standard/core/Sq[Forth plain strings] +* https://forth-standard.org/standard/core/Seq[Forth escaped strings] diff --git a/docs/php-coverage.adoc b/docs/php-coverage.adoc index f2fc875..73a736c 100644 --- a/docs/php-coverage.adoc +++ b/docs/php-coverage.adoc @@ -75,7 +75,7 @@ their results are not upgraded to AST/dataflow evidence by PHP's parser. The fixtures reproduce empty WordPress crypto implementations and the Aegis algorithm-substitution pattern from the 25 October 2026 audit. CLI tests cover -clean PHP without finding counters, malformed PHP, unsupported config input, +clean PHP without finding counters, malformed PHP, unsupported source input, empty roots, symlink cycles and legacy report decoding. Next: contextual PHP taint, Composer/WordPress bootstrap and symbol resolution, @@ -87,3 +87,8 @@ this change does not claim native parsing for them. Do not infer PHP engine patch status from these source checks. Runtime provenance, startup configuration and deployment isolation require independent evidence. +Configuration, template and build-file adapters are described separately in +link:input-coverage.adoc[Input coverage]. Unsupported source controls still +exercise the same inventory boundary; adding an adapter does not exclude a +previously failing candidate or upgrade parser acceptance into a security proof. + diff --git a/scripts/ci/actions-lock-transport.sh b/scripts/ci/actions-lock-transport.sh new file mode 100755 index 0000000..dcae1ed --- /dev/null +++ b/scripts/ci/actions-lock-transport.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Temporary process-status boundary for the unchanged published Standards +# verifier. Remove after standards#1394 is signed, hosted-qualified and adopted. +# This adapter does not interpret findings or implement lock reconciliation. +set -euo pipefail + +if ! { [[ $# -eq 2 && $1 = actions-lock && $2 = --verify-local ]] || + [[ $# -eq 3 && $1 = actions-lock && $2 = --verify-local && + $3 = --json=valid,findings ]]; }; then + echo 'actions-lock transport: only the two read-only verifier argv forms are permitted' >&2 + exit 2 +fi +shift +: "${GH_ACTIONS_LOCK_BIN:?official binary path is required}" +: "${ACTIONS_LOCK_EVIDENCE_DIR:?raw evidence directory is required}" +mkdir -p "$ACTIONS_LOCK_EVIDENCE_DIR" +printf 'running\n' > "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit" +set +e +env -u GH_ACTIONS_LOCK_WORKFLOWS_DIR -u GH_HOST -u GH_REPO \ + -u GH_TOKEN -u GITHUB_TOKEN -u GH_ENTERPRISE_TOKEN -u GITHUB_ENTERPRISE_TOKEN \ + "$GH_ACTIONS_LOCK_BIN" "$@" \ + > "$ACTIONS_LOCK_EVIDENCE_DIR/native.stdout" \ + 2> "$ACTIONS_LOCK_EVIDENCE_DIR/native.stderr" +status=$? +set -e +printf '%s\n' "$status" > "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit" +cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.stderr" >&2 +case "$status" in + 0|1) cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.stdout" ;; + *) + cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.stdout" >&2 + echo "actions-lock transport: tool failed (exit $status); report not offered for reconciliation" >&2 + ;; +esac +exit "$status" diff --git a/scripts/ci/check-allowed-actions.sh b/scripts/ci/check-allowed-actions.sh index ed09274..6a32743 100755 --- a/scripts/ci/check-allowed-actions.sh +++ b/scripts/ci/check-allowed-actions.sh @@ -1,68 +1,108 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 -# check-allowed-actions.sh — make the un-squabble-able startup_failure legible. -# -# When a repo sets `allowed_actions: selected` (RSR default) and a workflow -# `uses:` an action or reusable-workflow the allowlist does not permit, the run -# dies at STARTUP with no job and no log — an opaque red you cannot "squabble" -# with (RSR-PHILOSOPHY: reach green by satisfying the gate, never admin-override). -# -# This check turns that into a concrete, satisfiable gate: it lists exactly which -# `uses:` are not covered by the canonical allowlist, so the fix is obvious -# (add the pattern, or run set-allowed-actions.sh). Needs NO API and NO admin — -# safe to run in CI, in rhodibot, or locally. -# -# Usage: check-allowed-actions.sh [ALLOWED_ACTIONS_JSON] [WORKFLOWS_DIR] -# Exit: 0 = every `uses:` is covered; 1 = one or more gaps (printed). +# Offline tree policy: match the whole action/reusable coordinate and ref. +# This cannot read effective GitHub Settings or verify Marketplace publishers. set -euo pipefail -CANON="${1:-rhodium-standard-repositories/actions-allowlist/allowed-actions.json}" +POLICY="${1:-.github/actions-allowlist.json}" WF_DIR="${2:-.github/workflows}" - -if [ ! -f "$CANON" ]; then - # fall back to the published canonical list in standards - CANON_URL="https://raw.githubusercontent.com/hyperpolymath/standards/main/rhodium-standard-repositories/actions-allowlist/allowed-actions.json" - TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT - curl -fsSL "$CANON_URL" -o "$TMP" && CANON="$TMP" || { echo "!! no allowlist ($CANON)"; exit 2; } +YQ_BIN="${YQ_BIN:-yq}" +[[ -f "$POLICY" && -d "$WF_DIR" ]] || + { echo 'check-allowed-actions: policy file and workflow directory are required' >&2; exit 2; } +command -v "$YQ_BIN" >/dev/null && command -v jq >/dev/null || + { echo 'check-allowed-actions: mikefarah yq v4 and jq are required' >&2; exit 2; } +if ! jq -e -s 'length == 1 and (.[0] | type == "object" and + (.github_owned_allowed | type == "boolean") and + (.patterns_allowed | type == "array" and all(.[]; type == "string" and length > 0 and + (test("\\s") | not) and (contains("\u0000") | not)))) +' "$POLICY" >/dev/null; then + echo 'check-allowed-actions: malformed tree policy' >&2 + exit 2 fi +owned="$(jq -r '.github_owned_allowed' "$POLICY")" +mapfile -t patterns < <(jq -r '.patterns_allowed[]' "$POLICY") +probe="$(printf '%s\n' '{jobs: {probe: {steps: [{uses: "Fixture/Action@0123456789012345678901234567890123456789"}]}}}' | + "$YQ_BIN" -o=json 'explode(.)' - | jq -r '.jobs.probe.steps[0].uses')" || + { echo 'check-allowed-actions: YAML parser probe failed' >&2; exit 2; } +[[ "$probe" = 'Fixture/Action@0123456789012345678901234567890123456789' ]] || + { echo 'check-allowed-actions: YAML parser did not read the known ref' >&2; exit 2; } -python3 - "$CANON" "$WF_DIR" <<'PY' -import json, sys, os, re, glob, fnmatch -canon = json.load(open(sys.argv[1])) -patterns = canon.get("patterns_allowed", []) -gh_owned = {"actions", "github"} # github_owned_allowed - -def owner_repo(u): - base = u.split('@', 1)[0] - # GitHub owner/repository names are case-insensitive. - parts = base.lower().split('/') - return '/'.join(parts[:2]), parts[0] - -def covered(u): - # $/ is gh actions-lock's inherently-pinned same-repo form (like ./) - if u.startswith('./') or u.startswith('$/') or u.startswith('docker://'): - return True - orp, owner = owner_repo(u) - if owner in gh_owned: # github-owned (actions/*, github/*) - return True - for p in patterns: - base = p.split('@', 1)[0].lower() - if base == f"{owner}/*": # owner-wide (covers reusable workflows) - return True - if base == orp or fnmatch.fnmatch(orp, base): - return True - return False - -uses = set() -for f in sorted(glob.glob(os.path.join(sys.argv[2], '*.yml')) + - glob.glob(os.path.join(sys.argv[2], '*.yaml'))): - for line in open(f, encoding='utf-8'): - m = re.search(r'^\s*(?:-\s*)?uses:\s*([^\s#]+)', line) - if m: - uses.add(m.group(1).strip().strip('"\'')) +covered() { + local ref=$1 action sha owner tail repo suffix pattern base allowed_ref p_owner p_tail p_repo p_suffix + case "$ref" in + ./*) return 0 ;; + docker://*) [[ "$ref" =~ ^docker://[^@]+@sha256:[0-9a-f]{64}$ ]]; return $? ;; + '$/'*) return 1 ;; + *) : ;; # Remote coordinates must pass the immutable-ref checks below. + esac + [[ "$ref" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*@[0-9a-fA-F]{40}$ ]] || return 1 + action="${ref%@*}" + sha="${ref##*@}" + sha="${sha,,}" + case "$action" in + */../*|*/./*|*/..|*/.) return 1 ;; + *) : ;; # Ordinary coordinates still require a full policy match. + esac + owner="${action%%/*}"; tail="${action#*/}"; repo="${tail%%/*}" + suffix="${tail#"$repo"}" + action="${owner,,}/${repo,,}$suffix" + if [[ "$owned" = true && ( "${owner,,}" = actions || "${owner,,}" = github ) ]]; then + return 0 + fi + for pattern in "${patterns[@]}"; do + base="${pattern%%@*}" + if [[ "$pattern" = *@* ]]; then + allowed_ref="${pattern#*@}" + if [[ "$allowed_ref" =~ ^[0-9a-fA-F]{40}$ ]]; then + allowed_ref="${allowed_ref,,}" + fi + [[ "$sha" = $allowed_ref ]] || continue + fi + [[ "$base" = */* ]] || continue + p_owner="${base%%/*}"; p_tail="${base#*/}"; p_repo="${p_tail%%/*}" + p_suffix="${p_tail#"$p_repo"}" + base="${p_owner,,}/${p_repo,,}$p_suffix" + [[ "$action" = $base ]] && return 0 + done + return 1 +} -gaps = sorted(u for u in uses if not covered(u)) -for u in gaps: - print(f"GAP {u} (add its owner/* or owner/repo@* pattern, or run set-allowed-actions.sh)") -print(f"checked {len(uses)} `uses:` refs across {sys.argv[2]} — {len(gaps)} not covered by the allowlist") -sys.exit(1 if gaps else 0) -PY +scratch="$(mktemp)" +trap 'rm -f "$scratch"' EXIT +shopt -s nullglob +files=("$WF_DIR"/*.yml "$WF_DIR"/*.yaml) +[[ ${#files[@]} -gt 0 ]] || + { echo 'check-allowed-actions: no workflow files examined' >&2; exit 2; } +checked=0 +gaps=0 +for file in "${files[@]}"; do + if ! "$YQ_BIN" -o=json 'explode(.)' "$file" > "$scratch"; then + echo "check-allowed-actions: unparseable workflow: $file" >&2 + exit 2 + fi + # A scalar, invalid jobs/steps node, null/non-string uses, or whitespace in + # a coordinate is unexamined input, never an empty passing ref population. + if ! jq -e -s ' + def nodes: .jobs[] | ., ((.steps // [])[]); + length == 1 and (.[0] | + type == "object" and (.jobs | type == "object" and length > 0) and + all(.jobs[]; type == "object" and + ((has("steps") | not) or (.steps | type == "array" and all(.[]; type == "object")))) and + all(nodes | select(has("uses")); .uses | type == "string" and length > 0 and + (test("\\s") | not) and (contains("\u0000") | not))) + ' "$scratch" >/dev/null; then + echo "check-allowed-actions: invalid workflow/ref structure: $file" >&2 + exit 2 + fi + refs="$(jq -r '.jobs[] | ., ((.steps // [])[]) | select(has("uses")) | .uses' "$scratch")" + while IFS= read -r ref; do + [[ -n "$ref" ]] || continue + checked=$((checked + 1)) + if ! covered "$ref"; then + printf 'GAP %s: %s (requires an immutable ref and an exact tree-policy match)\n' "$file" "$ref" >&2 + gaps=$((gaps + 1)) + fi + done <<< "$refs" +done +printf 'checked %s executable uses refs in %s workflow files; %s tree-policy gaps (live policy unverified)\n' \ + "$checked" "${#files[@]}" "$gaps" +[[ "$gaps" -eq 0 ]] diff --git a/scripts/ci/pin-workflow-uses.py b/scripts/ci/pin-workflow-uses.py deleted file mode 100755 index b3e4778..0000000 --- a/scripts/ci/pin-workflow-uses.py +++ /dev/null @@ -1,355 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MPL-2.0 -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -"""Pin every `uses:` ref in .github/workflows to a full commit SHA, and -regenerate .github/workflows/actions.lock in the format `gh actions-lock` -emits. - -Why this exists as a checked-in script rather than a one-off edit: - - * the estate runs `actions_permissions.sha_pinning_required = true` - (standards config/settings/repo.json). Under that posture a tag-pinned - `uses:` is refused at workflow STARTUP: zero jobs, no log, an opaque - "workflow file issue". That is the same class of outage as an empty - allowlist, and it is invisible in a green-looking PR. - * `gh actions-lock` will not touch a workflow whose refs it did not write - itself, and it rewrites local composite refs into invalid `$/` syntax in - some modes. Pinning is therefore done here, deterministically, and the - lockfile is derived from the workflows rather than trusted. - -Usage: - scripts/ci/pin-workflow-uses.py --map pins.json [--check] - -`pins.json` maps `owner/repo@ref` -> {"sha": ..., "tag": ...}. It is produced -by scripts/ci/resolve-action-shas.sh so that this script needs no network. - ---check exits 1 if any `uses:` is not a 40-hex SHA or if the lockfile would -change; CI runs that mode so drift cannot land. -""" -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path - -WORKFLOW_DIR = Path(".github/workflows") -LOCKFILE = WORKFLOW_DIR / "actions.lock" - -# `- uses: owner/repo@ref # comment` / `uses: owner/repo/path@ref` -USES_RE = re.compile( - r"^(?P\s*(?:-\s+)?)uses:\s*(?P[^\s#]+)(?P\s*#\s*(?P.*))?$" -) -SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") -SHA_COMMENT_RE = re.compile(r"^\s*[#]?\s*(v?\d[\w.\-+]*)\s*$") - - -def normalise(ref: str) -> tuple[str, str, str]: - """Split `owner/repo[/sub/path...]@ref` into (owner/repo, path, ref).""" - path, _, gitref = ref.partition("@") - parts = path.split("/") - slug = "/".join(parts[:2]) - return slug, path, gitref - - -def workflow_files() -> list[Path]: - files = sorted(WORKFLOW_DIR.glob("*.yml")) + sorted(WORKFLOW_DIR.glob("*.yaml")) - return [f for f in files if f.is_file()] - - -def rewrite(workflow: Path, pins: dict[str, dict[str, str]], check: bool) -> tuple[str, bool]: - text = workflow.read_text(encoding="utf-8") - changed = False - out_lines: list[str] = [] - - for line in text.splitlines(keepends=True): - stripped = line.lstrip() - if stripped.startswith("#") or "uses:" not in line: - out_lines.append(line) - continue - m = USES_RE.match(line.rstrip("\n")) - if not m: - out_lines.append(line) - continue - ref = m.group("ref") - if ref.startswith("./") or ref.startswith("docker://") or "/" not in ref: - out_lines.append(line) - continue - - slug, _, gitref = normalise(ref) - comment = (m.group("comment") or "").strip() - - if SHA_RE.match(gitref): - # Already pinned. Ensure a version comment names the tag we know. - tag = None - for key, info in pins.items(): - if normalise(key)[0].lower() == slug.lower() and info.get("sha", "").lower() == gitref.lower(): - tag = info.get("tag") - break - if tag and (not comment or SHA_RE.match(comment)): - new_line = f"{m.group('lead')}uses: {ref} # {tag}\n" - if new_line != line: - changed = True - out_lines.append(new_line) - continue - out_lines.append(line) - continue - - key = f"{slug}@{gitref}" - info = pins.get(key) or next( - (v for k, v in pins.items() if k.lower() == key.lower()), None - ) - if not info or not SHA_RE.match(info.get("sha", "")): - print(f"::error file={workflow}::no SHA known for {key}", file=sys.stderr) - out_lines.append(line) - continue - - pin = info["sha"].lower() - tag = info.get("tag") or gitref - tail = f" # {tag}" - head = f"{m.group('lead')}uses: {ref.replace('@' + gitref, '@' + pin, 1)}" - new_line = head + tail + "\n" - if new_line != line: - changed = True - out_lines.append(new_line) - - return "".join(out_lines), changed - - -def refs_in(workflow: Path) -> list[str]: - refs = [] - for line in workflow.read_text(encoding="utf-8").splitlines(): - stripped = line.lstrip() - if stripped.startswith("#") or "uses:" not in line: - continue - m = USES_RE.match(line) - if not m: - continue - ref = m.group("ref") - if ref.startswith("./") or ref.startswith("docker://") or "/" not in ref: - continue - refs.append(ref) - return sorted(set(refs)) - - -def parse_lock(text: str) -> tuple[dict[str, list[str]], list[str]]: - """Split an existing lockfile into its dependency BLOCKS. - - The blocks are kept as raw lines because `gh actions-lock` writes things - this script does not compute — `owner_id`/`repo_id`, tag-keyed aliases, and - crucially a `uses:` list for composite actions that resolve OTHER actions at - run time (`actions/upload-pages-artifact` pulls - `actions/upload-artifact@bbbca2dd…`). Discarding those is not cosmetic: - GitHub validates the lockfile at job setup and refuses the workflow with - "lockfile missing pin for …", which is how a regenerated lockfile took the - Pages deploy down. - """ - blocks: dict[str, list[str]] = {} - current: str | None = None - in_deps = False - for line in text.splitlines(): - if line.startswith("dependencies:"): - in_deps = True - current = None - continue - if not in_deps: - continue - m = re.match(r"^ '([^']+)':$", line) - if m: - current = m.group(1) - blocks[current] = [line] - continue - if current is not None: - blocks[current].append(line) - # Trim trailing blank lines accidentally captured. - for key, lines in blocks.items(): - while lines and not lines[-1].strip(): - lines.pop() - return blocks - - -def block_fields(lines: list[str]) -> dict[str, str]: - out: dict[str, str] = {} - for line in lines[1:]: - m = re.match(r"^ ([a-z_]+): '?([^'\n]*)'?$", line) - if m: - out[m.group(1)] = m.group(2) - return out - - -def build_lock(pins: dict[str, dict[str, str]], existing: str) -> str: - """Emit actions.lock: `workflows:` regenerated from the files on disk; the - `dependencies:` section MERGED into what is already committed. - - Merge, not rebuild, on purpose: the lockfile is a superset of what the - workflows name — `gh actions-lock` also records the actions that composite - actions resolve internally, and it records tag-keyed aliases. A generator - that reconstructs the file from `grep uses:` silently deletes both, and the - deletion is only visible as a startup failure inside the one workflow that - relied on it. - """ - preserved = parse_lock(existing) - - # Identity knowledge: owner/repo ids, and sha -> tag, taken from the - # preserved entries so a new pin can inherit them instead of guessing. - id_by_key: dict[str, tuple[str, str]] = {} - id_by_tag: dict[tuple[str, str], tuple[str, str]] = {} - by_sha: dict[tuple[str, str], str] = {} - for key, lines in preserved.items(): - fields = block_fields(lines) - slug, _, ref = normalise(key) - if fields.get("owner_id"): - id_by_key[key.lower()] = (fields["owner_id"], fields.get("repo_id", "")) - if fields.get("ref"): - id_by_tag[(slug.lower(), fields["ref"])] = ( - fields["owner_id"], - fields.get("repo_id", ""), - ) - if SHA_RE.match(ref) and not SHA_RE.match(fields["ref"]): - by_sha[(slug.lower(), ref.lower())] = fields["ref"] - for key, info in pins.items(): - slug, _, tag = normalise(key) - sha = info.get("sha", "") - if SHA_RE.match(sha) and tag and not SHA_RE.match(tag): - by_sha[(slug.lower(), sha.lower())] = tag - - comment_tag: dict[tuple[str, str], str] = {} - for wf in workflow_files(): - for line in wf.read_text(encoding="utf-8").splitlines(): - m = USES_RE.match(line) - if not m: - continue - slug, path, gitref = normalise(m.group("ref")) - comment = (m.group("comment") or "").strip() - if not SHA_RE.match(gitref) or not comment or ".github/workflows/" in path: - continue - if re.match(r"^v?\d[\w.\-+]*$", comment): - comment_tag[(slug.lower(), gitref.lower())] = comment - - per_file: dict[str, list[str]] = {} - new_blocks: dict[str, list[str]] = {} - missing: list[str] = [] - for wf in workflow_files(): - entries = [] - for ref in refs_in(wf): - slug, path, gitref = normalise(ref) - entries.append(f"{slug.lower()}@{gitref}") - if ".github/workflows/" in path or not SHA_RE.match(gitref): - continue - key = f"{slug.lower()}@{gitref.lower()}" - if key in preserved: - continue # keep the committed block verbatim - tag = ( - comment_tag.get((slug.lower(), gitref.lower())) - or by_sha.get((slug.lower(), gitref.lower())) - or pins.get(f"{slug}@{gitref}", {}).get("tag", "") - ) - owner_id, repo_id = id_by_key.get(key, ("", "")) - if not (owner_id and repo_id): - owner_id, repo_id = id_by_tag.get((slug.lower(), tag or ""), ("", "")) - if not (tag and not SHA_RE.match(tag) and owner_id and repo_id): - missing.append(f"{slug}@{gitref}") - continue - new_blocks[key] = [ - f" '{key}':", - f" ref: '{tag}'", - f" commit: 'sha1-{gitref.lower()}'", - f" owner_id: {owner_id}", - f" repo_id: {repo_id}", - ] - per_file[f".github/workflows/{wf.name}"] = sorted(set(entries)) - - if missing: - raise SystemExit( - "pin-workflow-uses: cannot key these refs in actions.lock " - "(need a tag and owner/repo ids; see scripts/ci/resolve-action-shas.sh):\n " - + "\n ".join(sorted(set(missing))) - ) - - out = [ - "# This file is machine-generated by `gh actions-lock`.", - "# Do not edit by hand; run `gh actions-lock` to update.", - "# Docs: https://gh.io/actions-lockfile", - "version: 'v0.0.3'", - "workflows:", - ] - for path in sorted(per_file): - if not per_file[path]: - out.append(f" '{path}': []") - continue - out.append(f" '{path}':") - for e in per_file[path]: - out.append(f" - '{e}'") - out.append("dependencies:") - merged = dict(preserved) - merged.update(new_blocks) - - # A composite action's own dependencies are recorded on whichever entry - # `gh actions-lock` happened to write them against — in practice the - # TAG-keyed one. GitHub resolves them from the entry the workflow actually - # names, which after a re-pin is the SHA-keyed one. Carry the `uses:` list - # across to every entry describing the same commit, or a re-pinned workflow - # dies in Set up job with "lockfile missing pin for ". - deps_by_commit: dict[str, list[str]] = {} - for lines in merged.values(): - fields = block_fields(lines) - if "uses" in fields or any(l.lstrip().startswith("uses:") for l in lines): - pass - try: - idx = next(i for i, l in enumerate(lines) if l.strip() == "uses:") - except StopIteration: - continue - commit = fields.get("commit", "") - if commit: - deps_by_commit.setdefault(commit, lines[idx:]) - - for key, lines in merged.items(): - if any(l.strip() == "uses:" for l in lines): - continue - commit = block_fields(lines).get("commit", "") - extra = deps_by_commit.get(commit) - if extra: - merged[key] = lines + list(extra) - - for key in sorted(merged): - out.extend(merged[key]) - return "\n".join(out) + "\n" - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--map", required=True, help="pins JSON (see module docstring)") - ap.add_argument("--check", action="store_true", help="verify only; never write") - args = ap.parse_args() - - pins = json.loads(Path(args.map).read_text(encoding="utf-8")) - failures = 0 - - for wf in workflow_files(): - new, changed = rewrite(wf, pins, args.check) - if changed: - failures += 1 - if args.check: - print(f"::error file={wf}::unpinned or un-commented `uses:` (run scripts/ci/pin-workflow-uses.py)", file=sys.stderr) - else: - wf.write_text(new, encoding="utf-8") - print(f"pinned {wf}") - - existing = LOCKFILE.read_text(encoding="utf-8") if LOCKFILE.exists() else "" - lock = build_lock(pins, existing) - if lock != existing: - failures += 1 - if args.check: - print("::error file=.github/workflows/actions.lock::lockfile is stale (regenerate)", file=sys.stderr) - else: - LOCKFILE.write_text(lock, encoding="utf-8") - print("regenerated actions.lock") - - if args.check and failures: - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/ci/resolve-action-shas.sh b/scripts/ci/resolve-action-shas.sh index a3aed73..5ad3aee 100755 --- a/scripts/ci/resolve-action-shas.sh +++ b/scripts/ci/resolve-action-shas.sh @@ -3,7 +3,8 @@ # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell # # Resolve every tag/branch `uses:` ref in .github/workflows to a full commit -# SHA and write the mapping to a JSON file for scripts/ci/pin-workflow-uses.py. +# SHA and write a legacy tag-reference index. This is not a native lock +# generator; native updates use the canonical recovery route in docs/CI-CD.adoc. # # Needs `gh` (authenticated) and `jq`. One API call per distinct ref. # diff --git a/scripts/ci/test-ci-integrity.sh b/scripts/ci/test-ci-integrity.sh new file mode 100755 index 0000000..429bd39 --- /dev/null +++ b/scripts/ci/test-ci-integrity.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Isolated tree-policy controls and an explicitly injected process-status fault. +# Mock reports qualify adapter behaviour, not actual native lock coverage. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +: "${STANDARDS_ROOT:?pinned canonical checkout required}" +: "${GH_ACTIONS_LOCK_BIN:?official binary required for the malformed-lock control}" +official_native=$GH_ACTIONS_LOCK_BIN +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/workflows" "$work/empty" "$work/repo/.github/workflows" +git -C "$work/repo" init --quiet +checker="$ROOT/scripts/ci/check-allowed-actions.sh" +transport="$ROOT/scripts/ci/actions-lock-transport.sh" +policy="$ROOT/.github/actions-allowlist.json" +checks=0 +expect() { + local expected=$1 label=$2 status=0 + shift 2 + "$@" > "$work/stdout" 2> "$work/stderr" || status=$? + if [[ "$status" -ne "$expected" ]]; then + cat "$work/stdout" "$work/stderr" >&2 + printf 'FAIL %s: expected %s, got %s\n' "$label" "$expected" "$status" >&2 + exit 1 + fi + checks=$((checks + 1)) + printf 'PASS %s\n' "$label" +} +workflow() { + local ref=$1 + jq -n --arg ref "$ref" \ + '{on:{push:{}},jobs:{gate:{uses:$ref}}}' > "$work/workflows/test.yml" +} +check() { bash "$checker" "$policy" "$work/workflows"; } +canonical() { bash "$STANDARDS_ROOT/scripts/check-workflow-coordinates.sh" "$work/workflows"; } +old_pin=333b06a01db1874ffedb2de1bfabb195640927c7 +current_pin=a797d308c0517d4e2d21cc7f1cb08d41f82b1a12 +for name in governance hypatia-scan rust-ci; do + workflow "metadatastician/standards/.github/workflows/$name-reusable.yml@$current_pin" + expect 0 "exact canonical $name coordinate" check +done +for name in mirror scorecard; do + workflow "metadatastician/standards/.github/workflows/$name-reusable.yml@$old_pin" + expect 0 "exact canonical $name coordinate" check +done +digest=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +for ref in './local/action' './local/action@main' "docker://registry.example/image:tag@sha256:$digest"; do + workflow "$ref" + expect 0 "accept local or digest-pinned coordinate: $ref" check +done +for ref in \ + 'docker://registry.example/image' \ + 'docker://registry.example/image:latest' \ + "docker://registry.example/image@sha256:${digest:0:63}" \ + "docker://registry.example/image@sha256:${digest}0" \ + "docker://registry.example/image@sha256:${digest:0:63}g" \ + "docker://registry.example/image@sha512:$digest" \ + "docker://@sha256:$digest" \ + "docker://registry.example/image@sha256:$digest:tag"; do + workflow "$ref" + expect 1 "reject unpinned or malformed Docker coordinate: $ref" check +done +workflow "METADATastician/STANDARDS/.github/workflows/rust-ci-reusable.yml@$current_pin" +expect 0 'owner and repository case is immaterial' check +for ref in \ + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml@$old_pin" \ + "metadatastician/standards/.github/workflows/not-approved.yml@$current_pin" \ + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml.extra@$current_pin" \ + "metadatastician/standards/.github/workflows/Rust-ci-reusable.yml@$current_pin" \ + "metadatastician-other/standards/.github/workflows/rust-ci-reusable.yml@$current_pin" \ + "metadatastician/standards-other/.github/workflows/rust-ci-reusable.yml@$current_pin" \ + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml@main" \ + "metadatastician/standards/.github/workflows/rust-ci-reusable.yml@${current_pin}0" \ + "actions/checkout@v7.0.1" \ + '$/.github/actions/example'; do + workflow "$ref" + expect 1 "reject coordinate: $ref" check +done +workflow "hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@$current_pin" +expect 1 'published coordinate gate rejects the former owner despite the inherited owner-wide policy' canonical +workflow "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" +jq '.github_owned_allowed = false' "$policy" > "$work/no-owned.json" +expect 1 'GitHub-owned flag is respected' bash "$checker" "$work/no-owned.json" "$work/workflows" + +cat > "$work/workflows/test.yml" < "$work/workflows/test.yml" < "$work/workflows/test.yml" <<'YAML' +on: {push: {}} +jobs: + gate: + runs-on: ubuntu-latest + steps: + # uses: unapproved/comment@main + - run: | + echo 'uses: unapproved/example@main' +YAML +expect 0 'quoted command and comment examples are not executable uses' check +for ref in null 42 '""' '"actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1\n./hidden"'; do + printf '{"jobs":{"gate":{"uses":%s}}}\n' "$ref" > "$work/workflows/test.yml" + expect 2 "reject malformed uses value: $ref" check +done +printf 'jobs: [broken\n' > "$work/workflows/test.yml" +expect 2 'malformed YAML is unexamined' check +printf 'ordinary scalar\n' > "$work/workflows/test.yml" +expect 2 'scalar workflow is unexamined' check +expect 2 'empty input directory is not a pass' bash "$checker" "$policy" "$work/empty" +expect 2 'missing tree policy is not downloaded or assumed' bash "$checker" "$work/absent.json" "$work/workflows" +printf '%s\n%s\n' '{"github_owned_allowed":true,"patterns_allowed":[]}' '{"github_owned_allowed":true,"patterns_allowed":[]}' > "$work/stream-policy.json" +expect 2 'multiple JSON policies cannot be combined implicitly' bash "$checker" "$work/stream-policy.json" "$work/workflows" +printf '%s\n' '{"github_owned_allowed":false,"patterns_allowed":["fixture/action@*\nevil/*"]}' > "$work/newline-policy.json" +expect 2 'a newline cannot inject a second allowlist pattern' bash "$checker" "$work/newline-policy.json" "$work/workflows" + +# Run the actual consumer entrypoint against disposable Git checkouts. +# Hidden index flags must fail before any helper or native program executes. +source_fixture="$work/source-fixture" +authority_fixture="$work/standards-fixture" +mkdir -p "$source_fixture/.githooks" "$source_fixture/scripts/ci" \ + "$source_fixture/.github/workflows" "$source_fixture/.github/actions/local" +cp "$ROOT/.githooks/validate-actions-lock.sh" "$source_fixture/.githooks/" +cp "$transport" "$source_fixture/scripts/ci/" +printf 'mock-only lock sentinel\n' > "$source_fixture/.github/workflows/actions.lock" +printf 'jobs: {gate: {runs-on: ubuntu-latest, steps: [{run: echo fixture}]}}\n' \ + > "$source_fixture/.github/workflows/test.yml" +printf 'name: fixture\nruns: {using: composite, steps: []}\n' \ + > "$source_fixture/.github/actions/local/action.yaml" +printf '.github/actions/local/action.yml\n' > "$source_fixture/.gitignore" +git -C "$source_fixture" init --quiet +git -C "$source_fixture" add --all +git -C "$source_fixture" -c user.name=Fixture -c user.email=fixture@example.invalid \ + -c commit.gpgsign=false commit --quiet -m 'disposable source-qualification fixture' +git clone --quiet --no-hardlinks "$STANDARDS_ROOT" "$authority_fixture" +source_gate() { + STANDARDS_ROOT="$authority_fixture" GH_ACTIONS_LOCK_BIN="$official_native" \ + ACTIONS_LOCK_EVIDENCE_DIR="$work/source-evidence" \ + bash "$source_fixture/.githooks/validate-actions-lock.sh" +} +for flag in --assume-unchanged --skip-worktree; do + for fixture in "$authority_fixture" "$source_fixture"; do + if [[ "$fixture" = "$authority_fixture" ]]; then + path=scripts/update-actions-lock.sh + else + path=.github/workflows/test.yml + fi + git -C "$fixture" update-index "$flag" "$path" + printf 'tampered consumed source\n' > "$fixture/$path" + # This is the actual premise that fooled the previous source guard. + [[ -z "$(git -C "$fixture" status --porcelain --untracked-files=all)" ]] + expect 2 "hidden source flag $flag in $fixture is rejected" source_gate + grep -F 'unsafe index flags' "$work/stderr" >/dev/null + [[ ! -e "$work/source-evidence/native.exit" ]] || + { echo 'an unqualified source reached native verification' >&2; exit 1; } + git -C "$fixture" update-index --no-assume-unchanged "$path" + git -C "$fixture" update-index --no-skip-worktree "$path" + git -C "$fixture" checkout -- "$path" + done +done +# A clean status must not hide action.yml shadowing a tracked action.yaml. +printf 'name: ignored shadow\nruns: {using: composite, steps: []}\n' \ + > "$source_fixture/.github/actions/local/action.yml" +[[ -z "$(git -C "$source_fixture" status --porcelain --untracked-files=all)" ]] +expect 2 'ignored action input is not part of the qualified commit' source_gate +grep -F 'tracked, untracked or ignored changes' "$work/stderr" >/dev/null +[[ ! -e "$work/source-evidence/native.exit" ]] || + { echo 'ignored source reached native verification' >&2; exit 1; } + +cat > "$work/mock-native" <<'SH' +#!/usr/bin/env bash +set -eu +printf '%s\n' "$@" > "$MOCK_ARGS" +printf '%s|%s\n' "${GH_ACTIONS_LOCK_WORKFLOWS_DIR-unset}" "${GH_TOKEN-unset}" > "$MOCK_ENV" +cat "$MOCK_REPORT" +exit "$MOCK_STATUS" +SH +chmod +x "$work/mock-native" +export MOCK_ARGS="$work/mock-args" MOCK_ENV="$work/mock-env" MOCK_REPORT="$work/mock-report" +export GH_ACTIONS_LOCK_BIN="$work/mock-native" ACTIONS_LOCK_EVIDENCE_DIR="$work/evidence" +export GH_ACTIONS_LOCK_WORKFLOWS_DIR="$work/wrong-input" GH_TOKEN=fixture-only +printf '{"valid":true,"findings":[]}\n' > "$MOCK_REPORT" +export MOCK_STATUS=0 +expect 0 'transport preserves actual success JSON' "$transport" actions-lock --verify-local --json=valid,findings +cmp "$MOCK_REPORT" "$work/stdout" +[[ "$(cat "$MOCK_ENV")" = 'unset|unset' ]] || + { echo 'native child retained ambient input/auth overrides' >&2; exit 1; } + +cat > "$work/repo/.github/workflows/test.yml" <<'YAML' +on: {push: {}} +jobs: + gate: + runs-on: ubuntu-latest + steps: + - run: echo fixture +YAML +# Opaque sentinel: mocked process controls do not assert this is a native lock. +printf 'mock-only lock sentinel\n' > "$work/repo/.github/workflows/actions.lock" +before="$(sha256sum "$work/repo/.github/workflows/"*)" +helper() ( + cd "$work/repo" + GH_BIN="$transport" bash "$STANDARDS_ROOT/scripts/update-actions-lock.sh" --verify-local +) +expect 0 'unchanged published helper accepts a successful mock protocol result' helper +for status in 2 124 127; do + export MOCK_STATUS=$status + expect "$status" "valid-looking JSON cannot hide native exit $status" helper + [[ "$(cat "$ACTIONS_LOCK_EVIDENCE_DIR/native.exit")" = "$status" ]] + cmp "$MOCK_REPORT" "$ACTIONS_LOCK_EVIDENCE_DIR/native.stdout" + grep -F '"valid":true' "$work/stderr" >/dev/null +done +export MOCK_STATUS=0 +printf 'malformed report\n' > "$MOCK_REPORT" +expect 1 'unchanged canonical parser rejects malformed success output' helper +printf '' > "$MOCK_REPORT" +expect 1 'empty success output is not accepted' helper +printf '{"valid":false,"findings":[]}\n' > "$MOCK_REPORT" +expect 1 'false validity without findings is not accepted' helper +printf '{"valid":true,"findings":[{"category":"sha-as-ref","severity":"warning","workflow":".github/workflows/test.yml","dependency":"fixture/action@0123456789012345678901234567890123456789"}]}\n' > "$MOCK_REPORT" +export MOCK_STATUS=1 +expect 0 'existing canonical advisory reconciliation is preserved, not reimplemented' helper +[[ "$(sha256sum "$work/repo/.github/workflows/"*)" = "$before" ]] || + { echo 'mocked verification changed workflow or lock bytes' >&2; exit 1; } + +rm -f "$MOCK_ARGS" +expect 2 'generator argv is forbidden in the verification transport' "$transport" actions-lock +expect 2 'unknown native option is forbidden' "$transport" actions-lock --verify-local --no-fix +expect 2 'alternate command is forbidden' "$transport" api repos/fixture +[[ ! -e "$MOCK_ARGS" ]] || { echo 'unsupported argv reached the native child' >&2; exit 1; } + +export GH_ACTIONS_LOCK_BIN="$work/missing-native-binary" +expect 127 'actual missing native executable remains an infrastructure failure' helper +export GH_ACTIONS_LOCK_BIN="$official_native" +printf 'version: [malformed native lock\n' > "$work/repo/.github/workflows/actions.lock" +before="$(sha256sum "$work/repo/.github/workflows/"*)" +expect 2 'real official v0.2.0 rejects malformed native lock through the unchanged helper' helper +grep -F 'opening lockfile:' "$ACTIONS_LOCK_EVIDENCE_DIR/native.stderr" >/dev/null +[[ "$(sha256sum "$work/repo/.github/workflows/"*)" = "$before" ]] || + { echo 'native malformed-lock control changed input bytes' >&2; exit 1; } +printf 'CI integrity controls: %s passed; real consumer coverage is a separate gate\n' "$checks" diff --git a/scripts/ci/triage-startup-failure.sh b/scripts/ci/triage-startup-failure.sh index e5da0e1..c9c6823 100755 --- a/scripts/ci/triage-startup-failure.sh +++ b/scripts/ci/triage-startup-failure.sh @@ -9,34 +9,14 @@ # `referenced_workflows: []` even when the workflow's only job is `uses:` a # reusable workflow from another repository. Nothing in the API says why. # -# There are five causes, and they are distinguishable with the files plus a -# read-only API call: +# This command reports source/API observations: pin shape, repository +# canonical name and whether a reusable commit can be read. Zero jobs and +# referenced_workflows: [] do not identify a cause. API reachability is not +# GitHub admission, and sibling runs are comparative evidence, not a read of +# this repository's effective Actions Settings or startup diagnostic. # -# 1. a `uses:` that is not a 40-hex commit, under `sha_pinning_required`; -# 2. a `uses:` whose repository is NOT the one the workflow names — i.e. the -# repo was renamed (or transferred) and the allowlist patterns match the -# *canonical* owner, not the spelling in the file; -# 3. a reusable workflow pinned to a commit that no longer exists upstream; -# 4. a workflow file that does not parse as a workflow (GitHub's parser is -# stricter than YAML: duplicate job keys, `on:` shapes, unknown keys); -# 5. THIS REPOSITORY's allowed-actions policy refusing a non-verified -# cross-repository `uses:`. Under `allowed_actions: selected` with -# `verified_allowed`, GitHub-owned and marketplace-verified actions pass and -# a `uses: some-org/some-repo/...` does not — so checkout, cache and -# dtolnay/rust-toolchain work while every call into the estate's own -# reusable workflows dies at startup. The signature is exactly the one above -# (zero jobs, `referenced_workflows: []`), and the discriminator is that -# sibling repositories calling the SAME reusable at the SAME pin succeed. -# Only a repository admin can change it (GET /actions/permissions is 403 for -# an ordinary token), which is why this script reports rather than fixes. - -# Set TRIAGE_SIBLINGS to a space-separated list of repositories that use the same -# reusable workflows, to have the script state the discriminator explicitly: -# TRIAGE_SIBLINGS="hyperpolymath/hypatia hyperpolymath/verisimdb" -# -# This script checks 1–3 against the live GitHub API (read-only, no admin -# permission needed: public repository metadata is enough) and leaves 4 to -# `ci-integrity.yml`, which parses every workflow with PyYAML. +# TRIAGE_SIBLINGS optionally lists space-separated repositories whose recent +# run conclusions will be printed without inferring a policy failure. # # Usage: # scripts/ci/triage-startup-failure.sh # exit 1 on any finding @@ -144,43 +124,38 @@ for ref in "${refs[@]}"; do repo="$owner/$second" if ! printf '%s' "$at" | grep -qE '^[0-9a-f]{40}$'; then - emit error "$ref: not pinned to a 40-hex commit — refused at startup under sha_pinning_required" + emit error "$ref: not pinned to a 40-hex commit — source violates the immutable-ref policy" continue fi canon="$(canonical_name "$repo")" if [ -z "$canon" ]; then - emit error "$repo: repository not found (or API unreachable) for \`$ref\` — the run cannot start" + emit error "$repo: repository not found or API unreachable for \`$ref\` — source identity could not be verified" continue fi if [ "$canon" != "$repo" ]; then - # A rename is reported, not condemned: what matters is whether the - # reference is permitted, and a rename only bites when the allowlist - # pattern matches the old owner and not the new one. - emit warning "$repo now resolves to $canon — a \`uses:\` keeps working across a rename as long as an allowlist pattern matches the canonical name; check \`patterns_allowed\` before assuming this is the cause" + # A metadata redirect does not qualify executable coordinates or the + # identity records in the consumer's generated native lock. + emit warning "$repo now resolves to $canon — review the canonical executable coordinate and generated lock identity; this metadata redirect does not prove startup admission" repo="$canon" fi case "$subpath" in .github/workflows/*) if ! ref_exists "$repo" "$at"; then - emit error "$ref: reusable workflow pinned to $at, which no longer exists in $repo — the caller dies before its first job" + emit error "$ref: could not verify reusable commit $at in $repo — absence and API failure are not distinguished" continue fi - # The pin exists. If runs of the calling workflow still die at - # startup, the reference is being refused by this repository's - # allowed-actions policy — the one cause no file can show. echo " cross-repo reusable: $ref" - echo " if the calling workflow reports startup_failure with referenced_workflows: [], this" - echo " repository's allowed-actions policy is refusing it. Sibling repos calling the same" - echo " reusable at the same pin are the discriminator; the fix needs repository admin" - echo " (allowed_actions=all, or add \`$owner/*\` to patterns_allowed)." + echo " commit metadata is readable; this does not verify the reusable file," + echo " native lock closure, effective Actions Settings or startup admission." + echo " use the actual platform diagnostic before attributing a zero-job failure." ;; esac done if [ -n "${TRIAGE_SIBLINGS:-}" ]; then - echo "== discriminator: do siblings actually run this? ==" + echo "== comparative sibling run observations ==" for sib in $TRIAGE_SIBLINGS; do summary="$(api "repos/$sib/actions/runs?per_page=20")" printf '%s\n' "$summary" | python3 -c ' @@ -193,15 +168,15 @@ counts = collections.Counter((r.get("conclusion") or r.get("status")) for r in d print(" " + ", ".join(f"{k}={v}" for k, v in counts.most_common()) or " (no runs)") ' | sed "s|^| $sib: |" done - echo " A sibling whose reusable-workflow runs SUCCEED, at the same pin, means the" - echo " estate allowlist is fine and the refusal is scoped to this repository." + echo " Sibling conclusions are comparative observations only; they do not establish" + echo " this repository's effective Actions policy or the cause of its startup failure." fi echo "== summary ==" if [ "$findings" -eq 0 ]; then - echo "no startup-failure causes found in $WORKFLOWS_DIR" + echo "No source/API findings in $WORKFLOWS_DIR; live admission and startup cause remain unverified." else - echo "$findings finding(s) — see above. Each one produces a run with zero jobs and no log." + echo "$findings source/API finding(s) — see above; platform causation is not established." fi if [ "$REPORT_ONLY" -eq 1 ]; then diff --git a/src/assail/analyzer.rs b/src/assail/analyzer.rs index b632af7..fb0af3a 100644 --- a/src/assail/analyzer.rs +++ b/src/assail/analyzer.rs @@ -337,13 +337,23 @@ impl Analyzer { Self::build(target, true, true) } + /// Construct an analyser for a target, selecting its source language. + /// Registered input adapters take precedence over source language detection. + /// + /// # Errors + /// Returns an error if the target does not exist, or if directory language + /// discovery fails or rejects a symlink root. fn build(target: &Path, verbose: bool, browser_extension: bool) -> Result { if !target.exists() { anyhow::bail!("Target does not exist: {}", target.display()); } let language = if target.is_file() { - Language::detect_path(target) + if super::input_adapters::Adapter::detect(target).is_some() { + Language::Unknown + } else { + Language::detect_path(target) + } } else { Self::detect_directory_language(target)? }; @@ -373,15 +383,17 @@ impl Analyzer { self.analyze_inner(None) } - /// Analyse discovered source files and return findings, statistics and coverage. + /// Analyse discovered inputs and return findings, statistics and coverage. /// UTF-8 decoding falls back to Windows-1252. Files larger than 16 MiB, read - /// or decoding failures, and PHP parser failures are skipped and remain - /// coverage gaps in a successful report. + /// or decoding failures, and adapter parse or unsupported-input failures are + /// skipped and remain coverage gaps in a successful report. Extracted scripts + /// receive their interpreter and cross-language checks separately. /// If supplied, `accumulator` records candidate parent directories and files - /// that were read and decoded, including PHP files that subsequently fail parsing. + /// that were read and decoded, including inputs that subsequently fail parsing. /// /// # Errors /// Propagates root metadata errors and symlink-root rejection from discovery. + /// Also returns an error for an extracted script with an unimplemented language. fn analyze_inner( &self, mut accumulator: Option<&mut crate::attestation::EvidenceAccumulator>, @@ -497,8 +509,14 @@ impl Analyzer { let mut file_weak_points = Vec::new(); - // Dispatch to language-specific analyzer - let file_lang = Language::detect_path(file); + // Registered build/configuration names take precedence over a + // coincidental language suffix, exactly as they do in discovery. + let configuration = super::input_adapters::Adapter::detect(file); + let file_lang = if configuration.is_some() { + Language::Unknown + } else { + Language::detect_path(file) + }; // Record this file into the attestation accumulator (zero-cost when None) if let Some(ref mut acc) = accumulator { @@ -506,6 +524,71 @@ impl Analyzer { } match file_lang { + Language::Unknown => { + let Some(format) = configuration else { + coverage.mark( + file, + "unsupported", + Some("no selected input adapter".into()), + Some(&raw_bytes), + ); + continue; + }; + match format.analyze(&content, &rel_path) { + Ok(analysis) => { + file_weak_points.extend(analysis.findings); + for script in analysis.scripts { + let start = file_weak_points.len(); + match script.language { + Language::JavaScript => self.analyze_javascript( + &script.content, + &mut file_stats, + &mut file_weak_points, + &rel_path, + )?, + Language::Shell => self.analyze_shell( + &script.content, + &mut file_stats, + &mut file_weak_points, + &rel_path, + )?, + Language::Python => self.analyze_python( + &script.content, + &mut file_stats, + &mut file_weak_points, + &rel_path, + )?, + _ => anyhow::bail!("unimplemented embedded input adapter"), + } + self.analyze_cross_language( + &script.content, + &mut file_weak_points, + &rel_path, + )?; + for finding in &mut file_weak_points[start..] { + finding.description.push_str(&format!( + " (embedded {:?} input at {})", + script.language, script.origin + )); + } + } + } + Err(error) => { + let status = if error.is::() { + "unsupported" + } else { + "parse_error" + }; + coverage.mark( + file, + status, + Some(format!("{error:#}")), + Some(&raw_bytes), + ); + continue; + } + } + } Language::Php => match super::php::analyze(&content, &rel_path) { Ok(findings) => file_weak_points.extend(findings), Err(error) => { @@ -734,8 +817,12 @@ impl Analyzer { } } - // Cross-language security checks (run on all files) - self.analyze_cross_language(&content, &mut file_weak_points, &rel_path)?; + // Structured configuration uses parsed keys and values; treating + // comments or schema identifiers as executable source creates false + // findings. Source-language adapters retain their existing checks. + if configuration.is_none() { + self.analyze_cross_language(&content, &mut file_weak_points, &rel_path)?; + } coverage.mark(file, "analyzed", None, Some(&raw_bytes)); // Accumulate global stats @@ -841,7 +928,8 @@ impl Analyzer { } /// Choose the most common language among discovered source files, or `Unknown` - /// if none are found. Ties favour the lexicographically greatest debug name. + /// if none are found. Registered input adapters do not vote. Ties favour the + /// lexicographically greatest debug name. /// /// # Errors /// Propagates root metadata errors and symlink-root rejection from discovery; @@ -850,6 +938,11 @@ impl Analyzer { let (files, _) = super::coverage::discover(dir)?; let mut counts = std::collections::HashMap::new(); for path in files { + // Adapter inputs are covered, but do not vote for the source + // language or disable language-specific report metrics. + if super::input_adapters::Adapter::detect(&path).is_some() { + continue; + } let language = Language::detect_path(&path); *counts.entry(language).or_insert(0usize) += 1; } @@ -5108,7 +5201,9 @@ impl Analyzer { /// Operates on the project root (self.target or its parent) rather than on /// individual source files, because the relevant artefacts (Cargo.toml, /// Cargo.lock, Julia Manifest.toml, deno.json) are not source files in the - /// language-detection sense. + /// language-detection sense. Cargo git revisions are checked separately by + /// the per-file configuration adapter. Manifest reads are capped at 4 MiB; + /// failed reads are skipped. This check always returns `Ok`. fn analyze_supply_chain_manifests(&self, weak_points: &mut Vec) -> Result<()> { let project_root = if self.target.is_dir() { self.target.clone() @@ -5119,31 +5214,10 @@ impl Analyzer { .to_path_buf() }; - // ── Cargo.toml: git deps without explicit rev= ──────────────────── + // Cargo git revisions are checked once by the structured per-file + // adapter, including nested manifests. Keep the independent lock check. let cargo_toml_path = project_root.join("Cargo.toml"); if let Some(content) = read_bounded(&cargo_toml_path, MANIFEST_FILE_READ_LIMIT) { - let git_dep_count = - content.matches("git = \"").count() + content.matches("git=\"").count(); - let rev_count = content.matches("rev = \"").count() + content.matches("rev=\"").count(); - if git_dep_count > 0 && rev_count < git_dep_count { - let unpinned = git_dep_count - rev_count; - weak_points.push(WeakPoint { - file: None, - line: None, - category: WeakPointCategory::SupplyChain, - location: Some("Cargo.toml".to_string()), - severity: Severity::High, - description: format!( - "{} git dependency/ies in Cargo.toml without explicit `rev =` — \ - build is not reproducible", - unpinned - ), - recommended_attack: vec![], - suppressed: false, - test_context: None, - }); - } - // ── Cargo.lock absent when Cargo.toml declares [lib] or [[bin]] ─── let has_lib = content.contains("[lib]"); let has_bin = content.contains("[[bin]]"); diff --git a/src/assail/build_inputs.rs b/src/assail/build_inputs.rs new file mode 100644 index 0000000..1106b8e --- /dev/null +++ b/src/assail/build_inputs.rs @@ -0,0 +1,737 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Build-input checks and extraction. No recipe, import, build, interpolation or +//! command is executed. Unhandled dialects remain explicit coverage gaps. + +use super::input_adapters::{Analysis, EmbeddedScript, UnsupportedInput}; +use crate::types::{Language, Severity, WeakPoint, WeakPointCategory}; +use anyhow::{anyhow, ensure, Result}; +use std::collections::BTreeSet; + +fn unsupported(message: &str) -> anyhow::Error { + UnsupportedInput(message.into()).into() +} + +fn finding(file: &str, line: usize, category: WeakPointCategory, reason: &str) -> WeakPoint { + WeakPoint { + file: Some(file.into()), + line: Some(line as u32), + category, + location: Some(format!("{file}:{line}")), + severity: Severity::High, + description: reason.into(), + recommended_attack: Vec::new(), + suppressed: false, + test_context: None, + } +} + +fn interpreter(command: &str) -> Result { + let name = command.rsplit('/').next().unwrap_or(command); + match name { + "sh" | "bash" | "zsh" | "fish" | "dash" => Ok(Language::Shell), + "python" | "python3" => Ok(Language::Python), + "node" | "nodejs" => Ok(Language::JavaScript), + _ => Err(unsupported(&format!( + "embedded interpreter {name:?} has no selected adapter" + ))), + } +} + +/// Consume shell options without changing when the caller selects inline source. +fn shell_option( + arg: &str, + argv: &[String], + index: &mut usize, + shell_command: &mut bool, +) -> Result<()> { + match arg { + "--noprofile" | "--norc" | "--posix" | "--restricted" | "--verbose" | "--login" => {} + "--rcfile" | "--init-file" => { + *index += 1; + ensure!( + argv.get(*index).is_some(), + "shell option lacks its argument" + ); + } + _ if !arg.starts_with("--") => { + let flags = &arg[1..]; + for (offset, flag) in flags.char_indices() { + match flag { + 'c' if arg.starts_with('-') => *shell_command = true, + 'o' | 'O' => { + if offset + 1 != flags.len() { + return Err(unsupported( + "attached named shell option is not extracted", + )); + } + *index += 1; + ensure!( + argv.get(*index).is_some(), + "named shell option lacks its argument" + ); + } + 'a' | 'b' | 'e' | 'f' | 'h' | 'k' | 'm' | 'n' | 'p' | 't' | 'u' | 'v' | 'x' + | 'B' | 'C' | 'E' | 'H' | 'P' | 'T' | 'i' | 'l' | 'r' | 's' | 'D' => {} + _ => return Err(unsupported("unrecognised shell invocation option")), + } + } + } + _ => return Err(unsupported("unrecognised shell invocation option")), + } + Ok(()) +} + +/// A non-shell interpreter option either identifies its source, terminates +/// extraction, or remains unsupported; later argv words are never scanned. +fn interpreter_option( + language: Language, + arg: &str, + argv: &[String], + index: usize, +) -> Result> { + let option = if language == Language::Python { + "-c" + } else { + "-e" + }; + if arg == option + || language == Language::JavaScript && matches!(arg, "--eval" | "--print" | "-p") + { + return Ok(Some( + argv.get(index + 1) + .ok_or_else(|| anyhow!("inline interpreter option has no source"))? + .clone(), + )); + } + if let Some(source) = arg.strip_prefix(option).filter(|s| !s.is_empty()) { + return Ok(Some(source.into())); + } + if language == Language::JavaScript { + if let Some(source) = arg + .strip_prefix("--eval=") + .or_else(|| arg.strip_prefix("--print=")) + .or_else(|| arg.strip_prefix("-p").filter(|s| !s.is_empty())) + { + return Ok(Some(source.into())); + } + } + if matches!(arg, "-m" | "--version" | "--help") { + return Ok(None); + } + Err(unsupported( + "inline interpreter invocation options require an adapter", + )) +} + +/// Extract inline shell, Python or JavaScript source from an interpreter argv. +/// Returns `None` for invocations without inline source; later arguments are not +/// treated as source. +/// +/// # Errors +/// Returns an error for unsupported interpreters or options and for options +/// requiring a missing argument or source. +fn inline_source(name: &str, argv: &[String]) -> Result> { + let language = interpreter(name)?; + let mut index = 1; + let mut shell_command = false; + while let Some(arg) = argv.get(index) { + if arg == "--" { + return if shell_command { + Ok(Some( + argv.get(index + 1) + .ok_or_else(|| anyhow!("shell -c has no source"))? + .clone(), + )) + } else { + Ok(None) + }; + } + if !arg.starts_with(['-', '+']) || arg == "-" { + return Ok(shell_command.then(|| arg.clone())); + } + if language == Language::Shell { + shell_option(arg, argv, &mut index, &mut shell_command)?; + } else { + return interpreter_option(language, arg, argv, index); + } + index += 1; + } + ensure!(!shell_command, "shell -c has no source"); + Ok(None) +} + +/// Append executable source from a build command, using `shell` for shell form. +/// `line` is the one-based start of the instruction. Malformed JSON and JSON +/// that is not an array fall back to shell form; direct exec arguments are not +/// treated as source unless they invoke a recognised inline interpreter. +/// +/// # Errors +/// Returns an error for empty or non-string exec arrays and propagates inline +/// interpreter option errors. +fn command_input(text: &str, line: usize, shell: Language, result: &mut Analysis) -> Result<()> { + // BuildKit's parseMaybeJSON falls back to shell form for malformed JSON + // (including the POSIX `[` command), but rejects non-string array members. + let array = text + .starts_with('[') + .then(|| serde_json::from_str::(text).ok()) + .flatten(); + if let Some(serde_json::Value::Array(values)) = array { + let argv: Vec = values + .into_iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .ok_or_else(|| anyhow!("exec array contains a non-string argument")) + }) + .collect::>()?; + ensure!(!argv.is_empty(), "empty build command vector"); + let name = argv[0].rsplit('/').next().unwrap_or(&argv[0]); + // A JSON exec vector is not a shell string. Only an explicit inline + // interpreter invocation supplies executable source for these checks. + if matches!( + name, + "sh" | "bash" | "zsh" | "fish" | "dash" | "python" | "python3" | "node" | "nodejs" + ) { + if let Some(source) = inline_source(name, &argv)? { + result.scripts.push(EmbeddedScript { + content: source, + language: interpreter(name)?, + origin: format!("source block beginning at line {line}"), + }); + } + } + } else { + result.scripts.push(EmbeddedScript { + content: text.into(), + language: shell, + origin: format!("source block beginning at line {line}"), + }); + } + Ok(()) +} + +fn image_pinned(image: &str) -> bool { + image.rsplit_once("@sha256:").is_some_and(|(name, digest)| { + !name.is_empty() && digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit()) + }) +} + +/// Declarative instruction words, with quotes/backslashes removed but no +/// environment expansion or command execution. Keep a backslash marker on a +/// literal dollar sign so reference recognition cannot erase escaped values. +/// Comments were removed earlier. +fn field_words(text: &str) -> Result> { + let mut words = Vec::new(); + let mut word = String::new(); + let mut quote = None; + let mut escaped = false; + for ch in text.chars() { + if escaped { + if ch == '$' { + word.push('\\'); + } + word.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if let Some(delimiter) = quote { + if ch == delimiter { + quote = None; + } else { + if delimiter == '\'' && ch == '$' { + word.push('\\'); + } + word.push(ch); + } + } else if ch == '\'' || ch == '"' { + quote = Some(ch); + } else if ch.is_whitespace() { + if !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + } else { + word.push(ch); + } + } + ensure!( + quote.is_none() && !escaped, + "unfinished declarative instruction word" + ); + if !word.is_empty() { + words.push(word); + } + Ok(words) +} + +fn simple_environment_reference(value: &str) -> bool { + value.strip_prefix('$').is_some_and(|name| { + let mut bytes = name.bytes(); + bytes + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + }) +} + +fn literal_fields( + args: &str, + allow_unassigned: bool, + file: &str, + line: usize, + result: &mut Analysis, +) -> Result<()> { + let words = field_words(args)?; + if words.is_empty() { + return Ok(()); + } + let fields = if !allow_unassigned && !words[0].contains('=') { + ensure!(words.len() > 1, "declarative instruction has no value"); + vec![(words[0].clone(), words[1..].join(" "))] + } else { + let mut fields = Vec::new(); + for word in words { + if let Some((name, value)) = word.split_once('=') { + fields.push((name.into(), value.into())); + } else { + ensure!( + allow_unassigned, + "declarative assignment has no equals sign" + ); + } + } + fields + }; + for (name, value) in fields { + // This syntax is expanded by the build-input format, unlike a plain + // JSON string. Do not broaden the shared literal predicate for it. + if simple_environment_reference(&value) { + continue; + } + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("build field at line {line}"), + &name, + &value, + ) { + result.findings.push(finding); + } + } + Ok(()) +} + +/// Join physical lines in their original order before interpreting instructions. +/// Unsupported escapes and unfinished continuations remain errors. +fn container_instructions(content: &str) -> Result> { + let mut instructions = Vec::new(); + let mut current = String::new(); + let mut first_line = 0; + for (offset, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + if line.starts_with("# escape=") && line != "# escape=\\" { + return Err(unsupported("non-backslash Containerfile escape directive")); + } + continue; + } + if current.is_empty() { + first_line = offset + 1; + } + let continuation = line.ends_with('\\'); + current.push_str(line.strip_suffix('\\').unwrap_or(line)); + if continuation { + current.push(' '); + } else { + instructions.push((first_line, std::mem::take(&mut current))); + } + } + ensure!(current.is_empty(), "unfinished Containerfile continuation"); + Ok(instructions) +} + +/// Validate and record one FROM stage before the caller resets its shell. +fn container_from( + args: &str, + file: &str, + line: usize, + stages: &mut BTreeSet, + result: &mut Analysis, +) -> Result<()> { + let fields: Vec<_> = args + .split_whitespace() + .filter(|field| !field.starts_with("--platform=")) + .collect(); + ensure!( + fields.len() == 1 || fields.len() == 3 && fields[1].eq_ignore_ascii_case("as"), + "invalid FROM instruction at line {line}" + ); + let image = fields[0]; + let pinned = image_pinned(image); + if image != "scratch" && !stages.contains(&image.to_ascii_lowercase()) && !pinned { + result.findings.push(finding( + file, + line, + WeakPointCategory::SupplyChain, + "Container base image lacks a complete SHA256 digest; local stage aliases are accepted", + )); + } + if fields.len() == 3 { + stages.insert(fields[2].to_ascii_lowercase()); + } + Ok(()) +} + +/// Remove instruction options before checking heredocs and executable source. +fn container_command( + args: &str, + line: usize, + shell: Language, + result: &mut Analysis, +) -> Result<()> { + let mut command = args; + while command.starts_with("--") { + command = command + .split_once(char::is_whitespace) + .ok_or_else(|| anyhow!("build command has only options"))? + .1 + .trim_start(); + } + if command.contains("<<") { + return Err(unsupported( + "Containerfile heredoc extraction is not implemented", + )); + } + command_input(command, line, shell, result) +} + +/// Check remote ADD pinning without fetching or interpreting the resource. +fn container_add(args: &str, file: &str, line: usize, result: &mut Analysis) { + let checksum = args + .split_whitespace() + .find_map(|arg| arg.strip_prefix("--checksum=sha256:")); + let pinned = checksum.is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + if (args.contains("https://") || args.contains("http://")) && !pinned { + result.findings.push(finding( + file, + line, + WeakPointCategory::SupplyChain, + "Remote ADD has no SHA256 checksum", + )); + } +} + +/// Resolve only already-declared stages or literal external image coordinates. +/// Duplicate, missing and unresolved COPY sources keep their existing errors. +fn container_copy( + args: &str, + file: &str, + line: usize, + stages: &BTreeSet, + stage_count: usize, + result: &mut Analysis, +) -> Result<()> { + let words = field_words(args)?; + let mut source = None; + let mut fields = words.iter(); + while let Some(field) = fields.next().filter(|field| field.starts_with("--")) { + let value = if field == "--from" { + Some( + fields + .next() + .ok_or_else(|| anyhow!("COPY --from has no value"))? + .as_str(), + ) + } else { + field.strip_prefix("--from=") + }; + if let Some(value) = value { + ensure!( + source.is_none() && !value.is_empty(), + "duplicate or empty COPY --from" + ); + source = Some(value); + } + } + if let Some(source) = source { + if let Ok(index) = source.parse::() { + ensure!(index < stage_count, "COPY stage index does not exist"); + } else if !stages.contains(&source.to_ascii_lowercase()) && !image_pinned(source) { + if source.contains('$') || !source.contains([':', '/', '@']) { + return Err(unsupported( + "COPY source is an unresolved external image or named build context", + )); + } + result.findings.push(finding( + file, + line, + WeakPointCategory::SupplyChain, + "COPY external image lacks a complete SHA256 digest", + )); + } + } + Ok(()) +} + +/// Check supported Containerfile instructions and extract executable source. +/// Returns findings and scripts for later interpreter checks; no build is run. +/// +/// # Errors +/// Returns errors for malformed instructions or command vectors, and unsupported +/// instructions, interpreters or constructs such as heredocs and `ONBUILD`. +pub(super) fn container(content: &str, file: &str) -> Result { + let instructions = container_instructions(content)?; + let mut result = Analysis::default(); + let mut stages = BTreeSet::new(); + let mut shell = Language::Shell; + let mut seen_from = false; + let mut stage_count = 0usize; + for (line, instruction) in instructions { + let (name, args) = instruction + .split_once(char::is_whitespace) + .ok_or_else(|| anyhow!("Containerfile instruction without arguments at line {line}"))?; + let name = name.to_ascii_uppercase(); + let args = args.trim(); + ensure!( + !args.is_empty(), + "empty Containerfile instruction at line {line}" + ); + if !seen_from && name != "FROM" && name != "ARG" { + return Err(anyhow!( + "Containerfile instruction before FROM at line {line}" + )); + } + match name.as_str() { + "FROM" => { + container_from(args, file, line, &mut stages, &mut result)?; + seen_from = true; + stage_count += 1; + shell = Language::Shell; + } + "SHELL" => { + let argv: Vec = serde_json::from_str(args)?; + shell = interpreter( + argv.first() + .ok_or_else(|| anyhow!("empty SHELL instruction"))?, + )?; + } + "RUN" | "CMD" | "ENTRYPOINT" => { + container_command(args, line, shell, &mut result)?; + } + "HEALTHCHECK" => { + if !args.eq_ignore_ascii_case("NONE") { + let (_, command) = args + .split_once("CMD ") + .ok_or_else(|| anyhow!("HEALTHCHECK has no CMD"))?; + command_input(command.trim(), line, shell, &mut result)?; + } + } + "ADD" => container_add(args, file, line, &mut result), + "COPY" => { + container_copy(args, file, line, &stages, stage_count, &mut result)?; + } + "USER" => { + let user = args.split(':').next().unwrap_or(""); + if user == "root" || user.parse::().ok() == Some(0) { + result.findings.push(finding( + file, + line, + WeakPointCategory::ExcessivePermissions, + "Container explicitly selects the root user", + )); + } + } + "ONBUILD" => { + return Err(unsupported( + "deferred ONBUILD instruction analysis is not implemented", + )) + } + "ARG" | "ENV" | "LABEL" => { + literal_fields(args, name == "ARG", file, line, &mut result)? + } + "WORKDIR" | "EXPOSE" | "VOLUME" | "STOPSIGNAL" | "MAINTAINER" => {} + _ => { + return Err(unsupported(&format!( + "unknown Containerfile instruction {name}" + ))) + } + } + } + ensure!(seen_from, "Containerfile contains no FROM instruction"); + Ok(result) +} + +fn recipe_script( + lines: &[(usize, String)], + default: Language, + result: &mut Analysis, +) -> Result<()> { + if lines.is_empty() { + return Ok(()); + } + let mut language = default; + let script_mode = lines + .first() + .is_some_and(|(_, text)| text.starts_with("#!")); + if let Some(first) = lines.first().filter(|(_, text)| text.starts_with("#!")) { + let mut words = first.1[2..].split_whitespace(); + let first = words + .next() + .ok_or_else(|| anyhow!("empty recipe interpreter"))?; + let executable = if first.ends_with("/env") { + words + .next() + .ok_or_else(|| anyhow!("env recipe interpreter missing"))? + } else { + first + }; + language = interpreter(executable)?; + } + result.scripts.push(EmbeddedScript { + content: lines + .iter() + .map(|(_, text)| { + if script_mode { + text.as_str() + } else { + text.trim_start_matches('@') + } + }) + .collect::>() + .join("\n"), + language, + origin: format!("source block beginning at line {}", lines[0].0), + }); + Ok(()) +} + +/// Check one literal Just assignment without evaluating backticks or shell(). +fn just_assignment(text: &str, file: &str, line: usize, result: &mut Analysis) -> Result<()> { + if text.contains('`') || text.contains("shell(") { + return Err(unsupported( + "executable Just variable expression is not extracted", + )); + } + let (name, value) = text.split_once(":=").expect("assignment matched"); + let value = value.trim(); + if value.len() >= 2 + && (value.starts_with('"') && value.ends_with('"') + || value.starts_with('\'') && value.ends_with('\'')) + { + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("Just declaration at line {}", line), + name.trim().trim_start_matches("export ").trim(), + &value[1..value.len() - 1], + ) { + result.findings.push(finding); + } + } + Ok(()) +} + +/// Process a top-level declaration after the preceding recipe was flushed. +/// Only a recipe header returns true; unsupported declarations remain errors. +fn just_declaration( + text: &str, + file: &str, + line: usize, + default: &mut Language, + result: &mut Analysis, +) -> Result { + let setting = text + .strip_prefix("set") + .filter(|rest| rest.starts_with(char::is_whitespace)) + .map(str::trim_start); + let shell_setting = setting + .and_then(|rest| rest.strip_prefix("shell")) + .map(str::trim_start) + .and_then(|rest| rest.strip_prefix(":=")); + if let Some(value) = shell_setting { + let argv: Vec = serde_json::from_str(value.trim())?; + *default = interpreter(argv.first().ok_or_else(|| anyhow!("empty Just shell"))?)?; + } else if let Some(setting) = setting { + if setting.starts_with("shell") + || setting.contains("interpreter") + || setting.contains("windows-shell") + { + return Err(unsupported( + "additional Just interpreter setting requires an adapter", + )); + } + } else if text.starts_with("import ") + || text.starts_with("import? ") + || text.starts_with("mod ") + || text.starts_with("mod? ") + || text.starts_with("alias ") + { + // Inventory covers local *.just files independently. Imports and + // variable interpolation are never executed or presented as checked. + } else if text.starts_with('[') && text.ends_with(']') { + if text.contains("script") { + return Err(unsupported( + "Just script attributes require interpreter extraction", + )); + } + } else if text.contains(":=") { + just_assignment(text, file, line, result)?; + } else if text.contains(':') { + return Ok(true); + } else { + return Err(unsupported(&format!( + "unrecognised Just declaration at line {}", + line + ))); + } + Ok(false) +} + +/// Check recognised Just declarations and extract recipe bodies for later checks. +/// Imports and interpolation are not resolved and recipes are not run. +/// +/// # Errors +/// Returns errors for malformed recipe indentation or shell settings, and for +/// unsupported declarations or interpreters. +pub(super) fn just(content: &str, file: &str) -> Result { + let mut result = Analysis::default(); + let mut default = Language::Shell; + let mut in_recipe = false; + let mut body = Vec::new(); + let mut indentation = None; + for (offset, raw) in content.lines().enumerate() { + let text = raw.trim(); + if text.is_empty() { + continue; + } + if raw.starts_with(char::is_whitespace) { + ensure!( + in_recipe, + "indented Just input outside a recipe at line {}", + offset + 1 + ); + let leading = raw.len() - raw.trim_start().len(); + let prefix = *indentation.get_or_insert(leading); + ensure!( + leading >= prefix, + "inconsistent Just recipe indentation at line {}", + offset + 1 + ); + body.push((offset + 1, raw[prefix..].to_string())); + continue; + } + if text.starts_with('#') { + continue; + } + recipe_script(&body, default, &mut result)?; + body.clear(); + indentation = None; + in_recipe = just_declaration(text, file, offset + 1, &mut default, &mut result)?; + } + recipe_script(&body, default, &mut result)?; + Ok(result) +} diff --git a/src/assail/configuration.rs b/src/assail/configuration.rs new file mode 100644 index 0000000..72e68a1 --- /dev/null +++ b/src/assail/configuration.rs @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Structured configuration analysis. Parsing and checks must both finish before +//! an input can be acknowledged as analysed. These are local configuration +//! checks, not application-schema, deployment, or dataflow proofs. + +use super::input_adapters::Analysis; +use crate::types::{AttackAxis, Severity, WeakPoint, WeakPointCategory}; +use anyhow::{anyhow, Context, Result}; +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map, Number, Value}; +use std::fmt; +use std::path::Path; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Format { + Json, + Yaml, + Toml, +} + +impl Format { + /// The same registry is used by discovery and execution. Recognising a + /// candidate does not acknowledge its bytes: only successful analysis does. + pub(super) fn detect(path: &Path) -> Option { + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "json" => Some(Self::Json), + "yaml" | "yml" => Some(Self::Yaml), + "toml" => Some(Self::Toml), + _ => None, + } + } + + pub(super) fn description(self) -> &'static str { + match self { + Self::Json => "JSON parser and recursive configuration security checks; package.json scripts receive POSIX-style shell heuristics; duplicate keys rejected; no platform-shell, application-schema or dataflow proof", + Self::Yaml => "YAML budgeted parser, recursive configuration security checks and recognized Actions run-step interpreter heuristics; duplicate keys rejected; no expression expansion, application-schema or dataflow proof", + Self::Toml => "TOML parser and recursive configuration security checks; duplicate keys rejected; no application-schema or dataflow proof", + } + } + + /// Return configuration findings, discarding any extracted scripts. + /// + /// # Errors + /// Propagates parsing, conversion and script-extraction errors from + /// [`Self::analyze_input`]. + pub(super) fn analyze(self, content: &str, file: &str) -> Result> { + Ok(self.analyze_input(content, file)?.findings) + } + + /// Parse configuration, check each document and extract recognised scripts. + /// `file` identifies findings and selects package.json script extraction. + /// Duplicate mapping keys are rejected; YAML may contain multiple documents. + /// + /// # Errors + /// Returns parsing or value-conversion errors, including duplicate keys and + /// non-finite numbers, and propagates script-extraction errors. + pub(super) fn analyze_input(self, content: &str, file: &str) -> Result { + let documents: Vec = match self { + Self::Json => vec![ + serde_json::from_str::(content) + .context("JSON configuration parse failed")? + .0, + ], + Self::Yaml => { + // Keep the parser's default depth, expansion and alias budgets. + // The custom visitor also prevents duplicate keys being hidden + // when normalising a mapping to serde_json::Value. + let documents: Vec = serde_saphyr::from_str_multiple(content) + .context("YAML configuration parse failed")?; + documents.into_iter().map(|document| document.0).collect() + } + Self::Toml => { + let document: toml_edit::DocumentMut = + content.parse().context("TOML configuration parse failed")?; + vec![table_value(document.as_table())?] + } + }; + + let mut analysis = Analysis::default(); + for (index, document) in documents.iter().enumerate() { + let field = format!("document[{index}]"); + inspect_value(document, &field, file, &mut analysis.findings); + super::configuration_scripts::extract( + self, + document, + &field, + file, + &mut analysis.scripts, + )?; + } + Ok(analysis) + } +} + +/// `serde_json::Value` silently overwrites duplicate object keys. A contradictory +/// first value must not disappear before security checks run, including nested +/// objects and objects inside arrays. +struct UniqueValue(Value); + +impl<'de> Deserialize<'de> for UniqueValue { + fn deserialize>(deserializer: D) -> Result { + struct UniqueVisitor; + impl<'de> Visitor<'de> for UniqueVisitor { + type Value = UniqueValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("configuration with unique string mapping keys") + } + + fn visit_unit(self) -> Result { + Ok(UniqueValue(Value::Null)) + } + + fn visit_none(self) -> Result { + self.visit_unit() + } + + fn visit_bool(self, value: bool) -> Result { + Ok(UniqueValue(Value::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(UniqueValue(Value::Number(value.into()))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(UniqueValue(Value::Number(value.into()))) + } + + fn visit_f64(self, value: f64) -> Result { + Number::from_f64(value) + .map(Value::Number) + .map(UniqueValue) + .ok_or_else(|| E::custom("non-finite configuration number")) + } + + fn visit_str(self, value: &str) -> Result { + Ok(UniqueValue(Value::String(value.into()))) + } + + fn visit_string(self, value: String) -> Result { + Ok(UniqueValue(Value::String(value))) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut values = Vec::new(); + while let Some(value) = seq.next_element::()? { + values.push(value.0); + } + Ok(UniqueValue(Value::Array(values))) + } + + fn visit_map>(self, mut map: A) -> Result { + let mut values = Map::new(); + while let Some(key) = map.next_key::()? { + if values.contains_key(&key) { + return Err(de::Error::custom("duplicate configuration mapping key")); + } + values.insert(key, map.next_value::()?.0); + } + Ok(UniqueValue(Value::Object(values))) + } + } + deserializer.deserialize_any(UniqueVisitor) + } +} + +fn table_value(table: &toml_edit::Table) -> Result { + table + .iter() + .map(|(key, value)| Ok((key.to_owned(), item_value(value)?))) + .collect::>>() + .map(Value::Object) +} + +fn item_value(item: &toml_edit::Item) -> Result { + match item { + toml_edit::Item::None => Ok(Value::Null), + toml_edit::Item::Value(value) => toml_value(value), + toml_edit::Item::Table(table) => table_value(table), + toml_edit::Item::ArrayOfTables(tables) => tables + .iter() + .map(table_value) + .collect::>>() + .map(Value::Array), + } +} + +fn toml_value(value: &toml_edit::Value) -> Result { + match value { + toml_edit::Value::String(value) => Ok(Value::String(value.value().clone())), + toml_edit::Value::Integer(value) => Ok(Value::Number((*value.value()).into())), + toml_edit::Value::Float(value) => Number::from_f64(*value.value()) + .map(Value::Number) + .ok_or_else(|| anyhow!("non-finite configuration number")), + toml_edit::Value::Boolean(value) => Ok(Value::Bool(*value.value())), + toml_edit::Value::Datetime(value) => Ok(Value::String(value.value().to_string())), + toml_edit::Value::Array(values) => values + .iter() + .map(toml_value) + .collect::>>() + .map(Value::Array), + toml_edit::Value::InlineTable(table) => table + .iter() + .map(|(key, value)| Ok((key.to_owned(), toml_value(value)?))) + .collect::>>() + .map(Value::Object), + } +} + +fn finding( + file: &str, + field: &str, + category: WeakPointCategory, + severity: Severity, + reason: &str, +) -> WeakPoint { + WeakPoint { + file: Some(file.into()), + line: None, + category, + location: Some(file.into()), + severity, + description: format!("CONFIG: {reason} at {field} in {file}"), + recommended_attack: vec![AttackAxis::Network], + suppressed: false, + test_context: None, + } +} + +fn key_name(key: &str) -> String { + key.chars() + .filter(|c| *c != '_' && *c != '-') + .flat_map(char::to_lowercase) + .collect() +} + +fn is_reference(value: &str) -> bool { + let value = value.trim(); + (value.starts_with("${") && value.ends_with('}')) + || (value.starts_with("{{") && value.ends_with("}}")) +} + +/// Flag named credential fields with values of at least eight bytes, except +/// values enclosed in `${...}` or `{{...}}` after trimming. Key matching ignores +/// case, underscores and hyphens. The value is absent from the diagnostic. +/// Reused only for actual parsed/declarative literal fields, not comment text. +pub(super) fn literal_credential( + file: &str, + field: &str, + key: &str, + value: &str, +) -> Option { + let name = key_name(key); + if matches!( + name.as_str(), + "password" | "passwd" | "apikey" | "apisecret" | "secretkey" | "accesstoken" | "privatekey" + ) && value.len() >= 8 + && !is_reference(value) + { + Some(finding( + file, + field, + WeakPointCategory::HardcodedSecret, + Severity::Critical, + "literal credential in a named field", + )) + } else { + None + } +} + +fn inspect_value(value: &Value, field: &str, file: &str, findings: &mut Vec) { + match value { + Value::Object(map) => { + for (key, value) in map { + let child = format!("{field}/{}", key.replace('~', "~0").replace('/', "~1")); + let normal = key_name(key); + if let Some(finding) = value + .as_str() + .and_then(|secret| literal_credential(file, &child, key, secret)) + { + findings.push(finding); + } + if matches!( + normal.as_str(), + "verifyssl" + | "sslverify" + | "verifytls" + | "tlsverify" + | "verifycertificates" + | "rejectunauthorized" + ) && value == &Value::Bool(false) + || matches!( + normal.as_str(), + "insecureskipverify" | "trustallcertificates" + ) && value == &Value::Bool(true) + { + findings.push(finding( + file, + &child, + WeakPointCategory::InsecureProtocol, + Severity::High, + "certificate verification is disabled", + )); + } + if matches!(normal.as_str(), "privileged" | "allowprivilegeescalation") + && value == &Value::Bool(true) + { + findings.push(finding( + file, + &child, + WeakPointCategory::ExcessivePermissions, + Severity::High, + "privileged execution is enabled", + )); + } + // JSON Schema/JSON-LD identifiers are names, not configured + // network endpoints. All other nested scalars remain checked. + if !(value.is_string() + && matches!( + key.as_str(), + "$schema" + | "$id" + | "$ref" + | "@id" + | "@type" + | "@context" + | "@vocab" + | "xmlns" + )) + { + inspect_value(value, &child, file, findings); + } + } + let immutable_rev = map.get("rev").and_then(Value::as_str).is_some_and(|rev| { + rev.len() == 40 && rev.bytes().all(|byte| byte.is_ascii_hexdigit()) + }); + if file.ends_with("Cargo.toml") && map.contains_key("git") && !immutable_rev { + findings.push(finding( + file, + field, + WeakPointCategory::SupplyChain, + Severity::High, + "git dependency has no immutable revision", + )); + } + } + Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + inspect_value(value, &format!("{field}/{index}"), file, findings); + } + } + Value::String(text) + if text + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) => + { + let rest = &text[7..]; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + let authority = authority + .rsplit('@') + .next() + .unwrap_or("") + .to_ascii_lowercase(); + let loopback = matches!( + authority.split(':').next(), + Some("localhost" | "127.0.0.1" | "0.0.0.0") + ) || authority == "[::1]" + || authority.starts_with("[::1]:"); + if !loopback { + findings.push(finding( + file, + field, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "plaintext HTTP endpoint", + )); + } + } + _ => {} + } +} diff --git a/src/assail/configuration_scripts.rs b/src/assail/configuration_scripts.rs new file mode 100644 index 0000000..54b10a6 --- /dev/null +++ b/src/assail/configuration_scripts.rs @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Bounded extraction of executable configuration fields. This does not expand +//! Actions expressions, load referenced actions, resolve npm's runtime shell or +//! execute anything. Unrecognised Actions interpreters remain coverage gaps. + +use super::configuration::Format; +use super::input_adapters::{EmbeddedScript, UnsupportedInput}; +use crate::types::Language; +use anyhow::{anyhow, ensure, Result}; +use serde_json::Value; +use std::path::Path; + +fn unsupported(reason: &str) -> anyhow::Error { + UnsupportedInput(reason.into()).into() +} + +fn explicit_shell(value: &Value) -> Result { + let text = value + .as_str() + .ok_or_else(|| anyhow!("Actions shell is not a string"))?; + let executable = text + .split_whitespace() + .next() + .ok_or_else(|| anyhow!("Actions shell is empty"))?; + match executable.rsplit('/').next().unwrap_or(executable) { + "bash" | "sh" | "zsh" | "dash" => Ok(Language::Shell), + "python" | "python3" => Ok(Language::Python), + "node" | "nodejs" => Ok(Language::JavaScript), + _ => Err(unsupported( + "Actions run-step interpreter has no selected adapter", + )), + } +} + +fn default_shell(document: &Value) -> Option<&Value> { + document.get("defaults")?.get("run")?.get("shell") +} + +fn inferred_shell(job: &Value) -> Result { + // Actions' unspecified Linux/macOS runner shell is sh/bash; a container + // job defaults to sh. Dynamic, Windows and unqualified self-hosted runner + // declarations cannot be assigned this interpreter without evidence. + if job.get("container").is_some() { + return Ok(Language::Shell); + } + let Some(runner) = job.get("runs-on") else { + return Err(unsupported( + "Actions run step has no resolvable default interpreter", + )); + }; + let posix = |label: &str| { + label.starts_with("ubuntu-") + || label.starts_with("macos-") + || label == "linux" + || label == "macOS" + }; + let known = runner.as_str().is_some_and(posix) + || runner.as_array().is_some_and(|labels| { + labels.iter().any(|label| label.as_str().is_some_and(posix)) + && labels.iter().all(|label| { + label.as_str().is_some_and(|s| { + !s.contains("${{") && !s.to_ascii_lowercase().contains("windows") + }) + }) + }); + if known { + Ok(Language::Shell) + } else { + Err(unsupported( + "Actions runner does not establish a supported default shell", + )) + } +} + +fn steps( + values: &Value, + job: Option<&Value>, + workflow: &Value, + field: &str, + scripts: &mut Vec, +) -> Result<()> { + let values = values + .as_array() + .ok_or_else(|| anyhow!("Actions steps is not an array"))?; + for (index, step) in values.iter().enumerate() { + let map = step + .as_object() + .ok_or_else(|| anyhow!("Actions step is not a mapping"))?; + let Some(run) = map.get("run") else { continue }; + let source = run + .as_str() + .ok_or_else(|| anyhow!("Actions run field is not a string"))?; + ensure!( + !map.contains_key("uses"), + "Actions step has both run and uses" + ); + let shell = map + .get("shell") + .or_else(|| job.and_then(default_shell)) + .or_else(|| job.and_then(|_| default_shell(workflow))); + let language = match shell { + Some(value) => explicit_shell(value)?, + None => match job { + Some(job) => inferred_shell(job)?, + None => { + return Err(unsupported( + "composite action run step lacks its required shell", + )) + } + }, + }; + scripts.push(EmbeddedScript { + content: source.into(), + language, + origin: format!("configuration field {field}/{index}/run"), + }); + } + Ok(()) +} + +/// Append recognised Actions run steps or package.json scripts for later checks. +/// `field` identifies the document within the input; npm scripts use POSIX-style +/// heuristics without resolving the runtime shell. Actions shell selection uses +/// step, job and workflow defaults before considering the runner. +/// +/// # Errors +/// Returns errors for malformed recognised structures and unsupported or +/// unresolved interpreters. Scripts already appended are retained on error. +pub(super) fn extract( + format: Format, + document: &Value, + field: &str, + file: &str, + scripts: &mut Vec, +) -> Result<()> { + if format == Format::Yaml { + // Recognise the workflow structure, not arbitrary keys named `run`. + // This also works when a single workflow is scanned as the root file. + if document.get("on").is_some() && document.get("jobs").is_some() { + let jobs = document["jobs"] + .as_object() + .ok_or_else(|| anyhow!("Actions jobs is not a mapping"))?; + for (name, job) in jobs { + if let Some(values) = job.get("steps") { + steps( + values, + Some(job), + document, + &format!("{field}/jobs/{name}/steps"), + scripts, + )?; + } + } + } + if document.pointer("/runs/using").and_then(Value::as_str) == Some("composite") { + let values = document + .pointer("/runs/steps") + .ok_or_else(|| anyhow!("composite action has no steps"))?; + steps( + values, + None, + document, + &format!("{field}/runs/steps"), + scripts, + )?; + } + } + if format == Format::Json + && Path::new(file) + .file_name() + .and_then(std::ffi::OsStr::to_str) + == Some("package.json") + { + if let Some(values) = document.get("scripts") { + let values = values + .as_object() + .ok_or_else(|| anyhow!("package.json scripts is not a mapping"))?; + for (name, value) in values { + let source = value + .as_str() + .ok_or_else(|| anyhow!("package.json script is not a string"))?; + // This is intentionally POSIX-style lexical checking. npm can + // select cmd.exe or an externally configured script-shell; the + // coverage description expressly does not claim to resolve it. + scripts.push(EmbeddedScript { + content: source.into(), + language: Language::Shell, + origin: format!("package.json script {field}/scripts/{name} (POSIX-style heuristics; runtime shell unresolved)"), + }); + } + } + } + Ok(()) +} diff --git a/src/assail/coverage.rs b/src/assail/coverage.rs index 409e2b9..8224553 100644 --- a/src/assail/coverage.rs +++ b/src/assail/coverage.rs @@ -215,7 +215,8 @@ fn unsupported_candidate(path: &Path) -> bool { ) } -/// Inventory a file or directory tree and return sorted paths with known languages. +/// Inventory a file or directory tree and return sorted paths with registered +/// language or input adapters. /// Named build/vendor subdirectories are recorded without enumerating their /// contents; symlinks and other non-regular inputs are excluded. Selected unknown /// source/config formats are unsupported; other unknown formats are excluded. @@ -233,7 +234,7 @@ pub fn discover(root: &Path) -> anyhow::Result<(Vec, AnalysisCoverage)> ); let mut report = AnalysisCoverage { mode: "static_assail".into(), - scope: "Explicit regular-file inventory; named build/vendor directories and symlinks excluded without following them. Unknown source/config formats are unsupported; other extensions are outside declared scope. Non-PHP adapters are heuristic, not parse or dataflow proofs. No dynamic attacks executed.".into(), + scope: "Explicit regular-file inventory; named build/vendor directories and symlinks excluded without following them. Selected unknown source/config formats are unsupported; other extensions are outside declared scope. Complete means every candidate finished its stated adapter, whose parsing and heuristic depth is recorded per input; it is not complete semantic security coverage, a dataflow proof or an execution result. No dynamic attacks executed.".into(), ..Default::default() }; let mut excluded = Vec::new(); @@ -281,14 +282,19 @@ pub fn discover(root: &Path) -> anyhow::Result<(Vec, AnalysisCoverage)> continue; } let path = entry.path(); - let language = Language::detect_path(path); + let configuration = super::input_adapters::Adapter::detect(path); + let language = if configuration.is_some() { + Language::Unknown + } else { + Language::detect_path(path) + }; let (status, reason, kind) = if !entry.file_type().is_file() { ( "excluded", Some("non-regular input; symlinks are not followed".into()), "non_regular", ) - } else if language != Language::Unknown { + } else if language != Language::Unknown || configuration.is_some() { files.push(path.to_path_buf()); ("pending", None, "file") } else if unsupported_candidate(path) { @@ -311,14 +317,18 @@ pub fn discover(root: &Path) -> anyhow::Result<(Vec, AnalysisCoverage)> kind: kind.into(), status: status.into(), language, - analysis: (language != Language::Unknown).then(|| { - if language == Language::Php { - "tree-sitter-php 0.25.1; local T0 syntax checks; no cross-file taint" - } else { - "language and cross-language heuristics; no parser coverage claim" - } - .into() - }), + analysis: configuration + .map(|format| format.description().into()) + .or_else(|| { + (language != Language::Unknown).then(|| { + if language == Language::Php { + "tree-sitter-php 0.25.1; local T0 syntax checks; no cross-file taint" + } else { + "language and cross-language heuristics; no parser coverage claim" + } + .into() + }) + }), reason, content_blake3: None, }); diff --git a/src/assail/forth.rs b/src/assail/forth.rs new file mode 100644 index 0000000..7a99343 --- /dev/null +++ b/src/assail/forth.rs @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Forth lexical hazard probes. These do not validate stack effects or grammar. + +use super::input_adapters::{Analysis, UnsupportedInput}; +use crate::types::{Severity, WeakPoint, WeakPointCategory}; +use anyhow::{ensure, Result}; + +/// Advance through one parenthesised comment, retaining source line positions. +fn parenthesised_comment(bytes: &[u8], offset: &mut usize, line: &mut usize) -> Result<()> { + while *offset < bytes.len() && bytes[*offset] != b')' { + if bytes[*offset] == b'\n' { + *line += 1; + } + *offset += 1; + } + ensure!( + *offset < bytes.len(), + "unterminated Forth parenthesised comment" + ); + if *offset < bytes.len() { + *offset += 1; + } + Ok(()) +} + +/// Read one literal without interpreting its words; escaped newlines retain +/// the same line accounting as ordinary string bytes. +fn string_literal( + content: &str, + offset: &mut usize, + line: &mut usize, + escapes: bool, +) -> Result<(usize, String, bool)> { + let bytes = content.as_bytes(); + let string_start = *offset; + let first_line = *line; + let mut string_end = *offset; + let mut closed = false; + while *offset < bytes.len() { + if bytes[*offset] == b'\n' { + *line += 1; + } + if escapes && bytes[*offset] == b'\\' && *offset + 1 < bytes.len() { + if bytes[*offset + 1] == b'\n' { + *line += 1; + } + *offset += 2; + continue; + } + if bytes[*offset] == b'"' { + string_end = *offset; + *offset += 1; + closed = true; + break; + } + *offset += 1; + } + ensure!(closed, "unterminated Forth string"); + Ok(( + first_line, + content[string_start..string_end].trim().into(), + true, + )) +} + +/// The final flag distinguishes literal data from executable words. Keeping the +/// data lets named string constants receive credential checks without executing +/// or interpreting words embedded in those strings. +fn words(content: &str) -> Result> { + let mut result = Vec::new(); + let mut offset = 0; + let mut line = 1; + let bytes = content.as_bytes(); + while offset < bytes.len() { + if bytes[offset].is_ascii_whitespace() { + if bytes[offset] == b'\n' { + line += 1; + } + offset += 1; + continue; + } + let start = offset; + while offset < bytes.len() && !bytes[offset].is_ascii_whitespace() { + offset += 1; + } + let word = content[start..offset].to_ascii_lowercase(); + match word.as_str() { + "\\" => { + while offset < bytes.len() && bytes[offset] != b'\n' { + offset += 1; + } + } + "(" => parenthesised_comment(bytes, &mut offset, &mut line)?, + ".\"" | "s\"" | "c\"" | "s\\\"" | ".\\\"" | "abort\"" => { + let escapes = matches!(word.as_str(), "s\\\"" | ".\\\""); + result.push(string_literal(content, &mut offset, &mut line, escapes)?); + } + _ => result.push((line, word, false)), + } + } + Ok(result) +} + +/// A conservative witness for the ambiguous .fs suffix, not a Forth grammar. +/// Begin with a named colon definition and require matched definition +/// boundaries; independent F# annotation/list tokens are not a witness. +fn has_colon_definition(words: &[(usize, String, bool)]) -> bool { + if !matches!(words.first(), Some((_, word, false)) if word == ":") { + return false; + } + let mut open = false; + let mut needs_name = false; + let mut complete = false; + for (_, word, literal) in words { + if *literal { + if needs_name { + return false; + } + continue; + } + if needs_name { + if matches!(word.as_str(), ":" | ";") { + return false; + } + needs_name = false; + continue; + } + match word.as_str() { + ":" if !open => { + open = true; + needs_name = true; + } + ";" if open => { + open = false; + complete = true; + } + ":" | ";" => return false, + _ => {} + } + } + complete && !open && !needs_name +} + +/// Check Forth words for execution and raw-memory hazards and named string +/// credentials. Comments, string contents and definition names are not treated +/// as executable words. This is a lexical check, not grammar or stack validation. +/// +/// # Errors +/// Returns an error for unterminated comments or strings, or for an ambiguous +/// `.fs` input without a recognised colon definition. +pub(super) fn analyze(content: &str, file: &str) -> Result { + let words = words(content)?; + // .fs is shared with F#. Never infer Forth merely from that suffix. + if std::path::Path::new(file) + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("fs")) + && !has_colon_definition(&words) + { + return Err(UnsupportedInput( + "ambiguous .fs input has no recognised Forth colon definition; F# is unsupported" + .into(), + ) + .into()); + } + let mut result = Analysis::default(); + let mut definition_name = false; + for (index, (line, word, literal)) in words.iter().enumerate() { + if *literal { + if let (Some((_, binding, false)), Some((_, name, false))) = + (words.get(index + 1), words.get(index + 2)) + { + if matches!( + binding.as_str(), + "constant" | "2constant" | "value" | "2value" + ) { + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("Forth string binding at line {line}"), + name, + word, + ) { + result.findings.push(finding); + } + } + } + continue; + } + if definition_name { + definition_name = false; + continue; + } + if word == ":" { + definition_name = true; + continue; + } + let hazard = match word.as_str() { + "system" => Some(( + WeakPointCategory::CommandInjection, + "Forth SYSTEM executes an external command", + )), + "evaluate" => Some(( + WeakPointCategory::DynamicCodeExecution, + "Forth EVALUATE interprets source supplied at runtime", + )), + "!" | "c!" | "2!" | "move" | "fill" | "erase" => Some(( + WeakPointCategory::UnsafeCode, + "Forth raw memory write requires address and size validation", + )), + _ => None, + }; + if let Some((category, description)) = hazard { + result.findings.push(WeakPoint { + file: Some(file.into()), + line: Some(*line as u32), + category, + location: Some(format!("{file}:{line}")), + severity: Severity::High, + description: description.into(), + recommended_attack: Vec::new(), + suppressed: false, + test_context: None, + }); + } + } + Ok(result) +} diff --git a/src/assail/input_adapters.rs b/src/assail/input_adapters.rs new file mode 100644 index 0000000..ea6254b --- /dev/null +++ b/src/assail/input_adapters.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! One registry for non-source input discovery, execution and coverage depth. + +use super::configuration; +use crate::types::WeakPoint; +use anyhow::Result; +use std::path::Path; + +#[derive(Debug)] +pub(super) struct UnsupportedInput(pub String); + +impl std::fmt::Display for UnsupportedInput { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} +impl std::error::Error for UnsupportedInput {} + +pub(super) struct EmbeddedScript { + pub content: String, + pub language: crate::types::Language, + /// Exact structural field, or the beginning of the source block. Parsers + /// that do not retain source spans must not invent a physical line number. + pub origin: String, +} + +#[derive(Default)] +pub(super) struct Analysis { + pub findings: Vec, + pub scripts: Vec, +} + +#[derive(Clone, Copy)] +pub(super) enum Adapter { + Configuration(configuration::Format), + Html, + Css, + Container, + Just, + Forth, +} + +impl Adapter { + /// Select an adapter from the file name or extension, without reading the file. + /// Containerfile and Dockerfile names, including dotted variants, take + /// precedence over extensions; extension matching is case-insensitive. + pub(super) fn detect(path: &Path) -> Option { + let name = path.file_name()?; + let bytes = name.as_encoded_bytes(); + if bytes == b"Containerfile" + || bytes == b"Dockerfile" + || bytes.starts_with(b"Containerfile.") + || bytes.starts_with(b"Dockerfile.") + { + return Some(Self::Container); + } + if matches!(name.to_str(), Some("Justfile" | "justfile")) { + return Some(Self::Just); + } + if let Some(format) = configuration::Format::detect(path) { + return Some(Self::Configuration(format)); + } + match path.extension()?.to_str()?.to_ascii_lowercase().as_str() { + "html" | "htm" => Some(Self::Html), + "css" => Some(Self::Css), + "just" => Some(Self::Just), + "fs" | "fth" | "forth" | "4th" => Some(Self::Forth), + _ => None, + } + } + + pub(super) fn description(self) -> &'static str { + match self { + Self::Configuration(format) => format.description(), + Self::Html => "tree-sitter-html 0.23.2; markup security checks, embedded CSS parser and JavaScript heuristics; no browser, template-expansion or dataflow proof", + Self::Css => "tree-sitter-css 0.25.0; CSS function and URL security checks; no computed-style or browser execution proof", + Self::Container => "Containerfile instruction, JSON-exec and image-reference checks plus extracted shell/Python/JavaScript heuristics; no image resolution or build execution", + Self::Just => "Just recipe/declaration extraction and interpreter-specific shell/Python/JavaScript heuristics; no import, interpolation or task execution", + Self::Forth => "Forth word-level hazard checks with comment/string masking; ambiguous .fs rejected; no stack, grammar or execution proof", + } + } + + /// Run the selected adapter and return findings and scripts for later analysis. + /// `file` supplies the location used in findings. + /// + /// # Errors + /// Propagates malformed-input and unsupported-dialect errors from the adapter. + /// The scan caller records these as coverage gaps. + pub(super) fn analyze(self, content: &str, file: &str) -> Result { + match self { + Self::Configuration(format) => format.analyze_input(content, file), + Self::Html => super::templates::html(content, file), + Self::Css => super::templates::css(content, file), + Self::Container => super::build_inputs::container(content, file), + Self::Just => super::build_inputs::just(content, file), + Self::Forth => super::forth::analyze(content, file), + } + } +} diff --git a/src/assail/mod.rs b/src/assail/mod.rs index 2d9f4c1..3a62846 100644 --- a/src/assail/mod.rs +++ b/src/assail/mod.rs @@ -6,9 +6,15 @@ //! Pre-analyzes target programs to identify weak points and recommend attacks pub mod analyzer; +mod build_inputs; +mod configuration; +mod configuration_scripts; pub mod coverage; +mod forth; +mod input_adapters; pub mod patterns; pub mod php; +mod templates; use crate::kanren::core::{FactDB, LogicEngine}; use crate::kanren::crosslang::CrossLangAnalyzer; diff --git a/src/assail/templates.rs b/src/assail/templates.rs new file mode 100644 index 0000000..f9fa066 --- /dev/null +++ b/src/assail/templates.rs @@ -0,0 +1,444 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Parser-backed checks for markup and styles. A recovered error tree is a +//! coverage gap. Embedded scripts keep the existing JavaScript heuristic depth. + +use super::input_adapters::{Analysis, EmbeddedScript, UnsupportedInput}; +use crate::types::{Language, Severity, WeakPoint, WeakPointCategory}; +use anyhow::{anyhow, ensure, Result}; +use std::collections::BTreeMap; +use tree_sitter::{Node, Parser, Tree}; + +fn parsed(content: &str, language: tree_sitter::Language) -> Result { + let mut parser = Parser::new(); + parser.set_language(&language)?; + let tree = parser + .parse(content, None) + .ok_or_else(|| anyhow!("markup parser returned no tree"))?; + ensure!( + !tree.root_node().has_error(), + "markup parser recovered a syntax error; semantic checks are incomplete" + ); + Ok(tree) +} + +fn text<'a>(node: Node<'_>, source: &'a str) -> Result<&'a str> { + Ok(node.utf8_text(source.as_bytes())?) +} + +fn unquote(value: &str) -> &str { + for quote in ['\'', '"'] { + if let Some(inner) = value + .strip_prefix(quote) + .and_then(|v| v.strip_suffix(quote)) + { + return inner; + } + } + value +} + +fn finding( + file: &str, + node: Node<'_>, + category: WeakPointCategory, + severity: Severity, + description: &str, +) -> WeakPoint { + WeakPoint { + file: Some(file.into()), + // Assail bounds each input to 16 MiB, so its line count fits u32. + line: Some(node.start_position().row as u32 + 1), + category, + location: Some(format!("{}:{}", file, node.start_position().row + 1)), + severity, + description: description.into(), + recommended_attack: Vec::new(), + suppressed: false, + test_context: None, + } +} + +/// Read raw attribute spelling without decoding character references. +fn html_attribute(child: Node<'_>, content: &str) -> Result<(String, String)> { + let mut name = String::new(); + let mut value = String::new(); + for part in child.named_children(&mut child.walk()) { + match part.kind() { + "attribute_name" => name = text(part, content)?.to_ascii_lowercase(), + "attribute_value" | "quoted_attribute_value" => { + value = unquote(text(part, content)?).to_owned() + } + _ => {} + } + } + Ok((name, value)) +} + +/// Preserve attribute check order: embedded code, URLs, then literal credentials. +/// URL normalization also supplies the value used by the tag's SRI check. +fn check_html_attribute( + file: &str, + child: Node<'_>, + name: &str, + value: &mut String, + result: &mut Analysis, +) -> Result<()> { + if name.starts_with("on") && name.len() > 2 { + ensure_attribute_decoded(value)?; + result.scripts.push(EmbeddedScript { + content: value.clone(), + language: Language::JavaScript, + origin: format!( + "source block beginning at line {}", + child.start_position().row + 1 + ), + }); + } + if name == "style" && !value.is_empty() { + ensure_attribute_decoded(value)?; + let inline = css(&format!("element {{{value}}}"), file)?; + for mut finding in inline.findings { + finding.line = Some(child.start_position().row as u32 + 1); + finding.location = Some(format!("{file}:{}", child.start_position().row + 1)); + result.findings.push(finding); + } + } + if matches!(name, "src" | "href" | "action" | "formaction" | "poster") { + ensure_attribute_decoded(value)?; + // WHATWG URL parsing trims outer C0 controls/spaces + // and removes ASCII tab/newline characters before + // scheme parsing. Keep the same value for SRI below. + *value = value + .trim_matches(|ch: char| ch <= '\u{20}') + .chars() + .filter(|ch| !matches!(ch, '\t' | '\n' | '\r')) + .collect(); + let lower = value.to_ascii_lowercase(); + if lower.starts_with("javascript:") || lower.starts_with("vbscript:") { + result.findings.push(finding( + file, + child, + WeakPointCategory::DynamicCodeExecution, + Severity::High, + "HTML attribute contains an executable URL scheme", + )); + } + if lower.starts_with("http://") { + result.findings.push(finding( + file, + child, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "HTML resource or navigation uses plaintext HTTP", + )); + } + } + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("HTML attribute at line {}", child.start_position().row + 1), + name.strip_prefix("data-").unwrap_or(name), + value, + ) { + result.findings.push(finding); + } + Ok(()) +} + +/// Reject duplicates before examining each attribute and check remote-script +/// integrity only after the normalized attribute map is complete. +fn html_start_tag(node: Node<'_>, content: &str, file: &str, result: &mut Analysis) -> Result<()> { + let mut tag = String::new(); + let mut attributes = BTreeMap::new(); + for child in node.named_children(&mut node.walk()) { + if child.kind() == "tag_name" { + tag = text(child, content)?.to_ascii_lowercase(); + } + if child.kind() != "attribute" { + continue; + } + let (name, mut value) = html_attribute(child, content)?; + ensure!( + !attributes.contains_key(&name), + "duplicate HTML attribute {name}; browser interpretation is ambiguous" + ); + check_html_attribute(file, child, &name, &mut value, result)?; + attributes.insert(name, value); + } + if tag == "script" + && attributes.get("src").is_some_and(|src| { + src.to_ascii_lowercase().starts_with("https://") + || src.to_ascii_lowercase().starts_with("http://") + || src.starts_with("//") + }) + && !attributes + .get("integrity") + .is_some_and(|value| usable_integrity(value)) + { + result.findings.push(finding( + file, + node, + WeakPointCategory::SupplyChain, + Severity::Medium, + "Remote script has no syntactically usable supported integrity digest", + )); + } + Ok(()) +} + +/// Preserve the source type and relative line mapping of embedded HTML blocks. +fn html_raw_text(node: Node<'_>, content: &str, file: &str, result: &mut Analysis) -> Result<()> { + match node.parent().map(|parent| parent.kind()) { + Some("script_element") => { + let kind = element_type(node.parent().expect("parent matched"), content)?; + match kind.as_str() { + "" | "module" | "text/javascript" | "application/javascript" => { + result.scripts.push(EmbeddedScript { + content: text(node, content)?.into(), + language: Language::JavaScript, + origin: format!( + "source block beginning at line {}", + node.start_position().row + 1 + ), + }) + } + "application/json" | "application/ld+json" | "importmap" => result.findings.extend( + super::configuration::Format::Json.analyze(text(node, content)?, file)?, + ), + _ => { + return Err(UnsupportedInput(format!( + "embedded script type {kind:?} has no selected adapter" + )) + .into()) + } + } + } + Some("style_element") => { + let kind = element_type(node.parent().expect("parent matched"), content)?; + if !kind.is_empty() && kind != "text/css" { + return Err( + UnsupportedInput(format!("embedded style type {kind:?} is not CSS")).into(), + ); + } + let nested = css(text(node, content)?, file)?; + for mut finding in nested.findings { + let line = finding.line.unwrap_or(1) + node.start_position().row as u32; + finding.line = Some(line); + finding.location = Some(format!("{file}:{line}")); + result.findings.push(finding); + } + } + _ => {} + } + Ok(()) +} + +/// Check parsed HTML attributes and embedded CSS or JSON, and extract JavaScript +/// for later analysis. Remote-script integrity checks validate metadata syntax +/// only; they do not fetch the resource or verify its digest. +/// +/// # Errors +/// Propagates parser and embedded CSS/JSON errors, and rejects duplicate +/// attributes, unsupported embedded types and encoded sensitive attributes. +pub(super) fn html(content: &str, file: &str) -> Result { + let tree = parsed(content, tree_sitter_html::LANGUAGE.into())?; + let mut result = Analysis::default(); + let mut pending = vec![tree.root_node()]; + while let Some(node) = pending.pop() { + match node.kind() { + "start_tag" | "self_closing_tag" => { + html_start_tag(node, content, file, &mut result)?; + } + "raw_text" => html_raw_text(node, content, file, &mut result)?, + _ => {} + } + pending.extend(node.named_children(&mut node.walk())); + } + Ok(result) +} + +// SRI metadata needs a correctly sized supported base64 digest. This checks +// syntax only: it never fetches a resource or proves that its bytes match. +fn usable_integrity(metadata: &str) -> bool { + metadata.split_ascii_whitespace().any(|token| { + let expression = token.split('?').next().unwrap_or(token); + let Some((algorithm, value)) = expression.split_once('-') else { + return false; + }; + let bytes = match algorithm.to_ascii_lowercase().as_str() { + "sha256" => 32usize, + "sha384" => 48, + "sha512" => 64, + _ => return false, + }; + let digits = value.trim_end_matches('='); + let padding = value.len() - digits.len(); + if digits.len() != (bytes * 8).div_ceil(6) + || padding > 2 + || (padding > 0 && value.len() % 4 != 0) + { + return false; + } + let sextet = |byte: u8| match byte { + b'A'..=b'Z' => Some(byte - b'A'), + b'a'..=b'z' => Some(byte - b'a' + 26), + b'0'..=b'9' => Some(byte - b'0' + 52), + b'+' | b'-' => Some(62), + b'/' | b'_' => Some(63), + _ => None, + }; + if !digits.bytes().all(|b| sextet(b).is_some()) { + return false; + } + let Some(last) = digits.bytes().last().and_then(sextet) else { + return false; + }; + match bytes % 3 { + 1 => last & 15 == 0, + 2 => last & 3 == 0, + _ => padding == 0, + } + }) +} + +fn ensure_attribute_decoded(value: &str) -> Result<()> { + // The grammar does not decode character references. Refuse a sensitive + // attribute whose interpreted bytes differ instead of checking only its + // raw spelling and acknowledging an unexamined executable URL or script. + let named_entity = value.split('&').skip(1).any(|tail| { + tail.split_once(';').is_some_and(|(name, _)| { + !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric()) + }) + }); + if value.contains("&#") || named_entity { + return Err(UnsupportedInput( + "HTML character-reference expansion in executable/URL attributes is not implemented" + .into(), + ) + .into()); + } + Ok(()) +} + +fn element_type(element: Node<'_>, content: &str) -> Result { + for start in element + .named_children(&mut element.walk()) + .filter(|node| node.kind() == "start_tag") + { + for attribute in start + .named_children(&mut start.walk()) + .filter(|node| node.kind() == "attribute") + { + let mut name = String::new(); + let mut value = String::new(); + for part in attribute.named_children(&mut attribute.walk()) { + match part.kind() { + "attribute_name" => name = text(part, content)?.to_ascii_lowercase(), + "attribute_value" | "quoted_attribute_value" => { + value = unquote(text(part, content)?).to_ascii_lowercase() + } + _ => {} + } + } + if name == "type" { + return Ok(value); + } + } + } + Ok(String::new()) +} + +/// Check parsed CSS declarations, functions and imports for selected hazards. +/// Findings use locations relative to `content`; callers embedding CSS adjust them. +/// +/// # Errors +/// Propagates parser errors, including recovered syntax errors, and text decoding +/// errors from tree-sitter nodes. +pub(super) fn css(content: &str, file: &str) -> Result { + let tree = parsed(content, tree_sitter_css::LANGUAGE.into())?; + let mut result = Analysis::default(); + let mut pending = vec![tree.root_node()]; + while let Some(node) = pending.pop() { + if node.kind() == "declaration" { + let mut name = String::new(); + for child in node.named_children(&mut node.walk()) { + if child.kind() == "property_name" { + name = text(child, content)?.trim_start_matches('-').to_owned(); + } else if matches!(child.kind(), "plain_value" | "string_value") { + if let Some(finding) = super::configuration::literal_credential( + file, + &format!("CSS declaration at line {}", node.start_position().row + 1), + &name, + unquote(text(child, content)?), + ) { + result.findings.push(finding); + } + } + } + } + if node.kind() == "call_expression" { + let value = text(node, content)?.to_ascii_lowercase(); + if value.starts_with("expression(") { + result.findings.push(finding( + file, + node, + WeakPointCategory::DynamicCodeExecution, + Severity::High, + "CSS expression() embeds executable code in legacy browser engines", + )); + } + if value.starts_with("url(") + && (value.contains("http://") || value.contains("javascript:")) + { + result.findings.push(finding( + file, + node, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "CSS URL uses a plaintext or executable scheme", + )); + } + } + if node.kind() == "import_statement" + && text(node, content)? + .to_ascii_lowercase() + .contains("http://") + { + result.findings.push(finding( + file, + node, + WeakPointCategory::InsecureProtocol, + Severity::Medium, + "CSS import uses plaintext HTTP", + )); + } + pending.extend(node.named_children(&mut node.walk())); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::{html, unquote}; + + #[test] + fn unquote_removes_only_a_matching_outer_pair() { + for (input, expected) in [ + ("\"'value'\"", "'value'"), + ("'\"value\"'", "\"value\""), + ("\"\"", ""), + ("plain", "plain"), + ("\"mismatched'", "\"mismatched'"), + ("'", "'"), + ("", ""), + ] { + assert_eq!(unquote(input), expected); + } + } + + #[test] + fn html_event_handler_keeps_quotes_inside_attribute() { + let result = html(r#""#, "index.html").unwrap(); + assert_eq!(result.scripts[0].content, "'value'"); + } +} diff --git a/tests/build_input_coverage_tests.rs b/tests/build_input_coverage_tests.rs new file mode 100644 index 0000000..7c8d2e1 --- /dev/null +++ b/tests/build_input_coverage_tests.rs @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +use panic_attack::assail; +use panic_attack::types::{AssailReport, WeakPointCategory}; + +fn scan(name: &str, source: &str) -> AssailReport { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, source).unwrap(); + assail::analyze(&path).unwrap() +} + +#[test] +fn container_variants_check_image_pins_and_actual_shell_inputs() { + let report = scan("Containerfile.full", "FROM example.invalid/base:latest AS builder\nRUN chmod 777 /work\nFROM builder\nCMD [\"echo\",\"eval is a literal argument\"]\n"); + assert_eq!(report.coverage.status, "complete"); + assert_eq!( + report + .weak_points + .iter() + .filter(|finding| finding.category == WeakPointCategory::SupplyChain) + .count(), + 1 + ); + assert!(report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::ExcessivePermissions)); + assert!(!report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::CommandInjection)); + assert!(report.coverage.inputs[0].content_blake3.is_some()); +} + +#[test] +fn digest_pinned_images_are_not_floating_and_exec_interpreters_are_analyzed() { + let source = format!( + "FROM example.invalid/base@sha256:{}\nRUN [\"bash\",\"-c\",\"eval untrusted\"]\n", + "a".repeat(64) + ); + let report = scan("Dockerfile", &source); + assert_eq!(report.coverage.status, "complete"); + assert!(!report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::SupplyChain)); + assert!(report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::CommandInjection)); +} + +#[test] +fn invalid_or_unhandled_build_dialects_remain_gaps() { + for source in [ + "RUN echo no-base\n", + "FROM scratch\nRUN [\"bash\",7]\n", + "FROM scratch\nRUN echo incomplete \\\n", + "FROM scratch\nRUN < +use panic_attack::assail; +use panic_attack::types::{AssailReport, WeakPointCategory}; + +fn scan(name: &str, content: &str) -> AssailReport { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, content).unwrap(); + assail::analyze(&path).unwrap() +} + +#[test] +fn discovered_configurations_execute_their_declared_adapters() { + for (name, content, adapter) in [ + ( + "config.json", + r#"{"enabled":true,"endpoint":"https://example.org"}"#, + "JSON", + ), + ( + "config.YAML", + "enabled: true\nendpoint: https://example.org\n", + "YAML", + ), + ( + "config.toml", + "enabled = true\nendpoint = 'https://example.org'\n", + "TOML", + ), + ] { + let report = scan(name, content); + assert_eq!(report.coverage.status, "complete", "{name}"); + assert_eq!(report.coverage.candidate_files, 1); + assert_eq!(report.coverage.analyzed_files, 1); + let input = &report.coverage.inputs[0]; + assert!(input.analysis.as_deref().unwrap().starts_with(adapter)); + assert_eq!( + input.content_blake3.as_deref(), + Some(blake3::hash(content.as_bytes()).to_hex().as_str()) + ); + assert!( + report.weak_points.is_empty(), + "{name}: {:?}", + report.weak_points + ); + } +} + +#[test] +fn malformed_and_duplicate_configurations_remain_coverage_gaps() { + for (name, content) in [ + ("malformed.json", "{\"enabled\":"), + ( + "nested.json", + r#"{"items":[{"verify_ssl":false,"verify_ssl":true}]}"#, + ), + ("malformed.yml", "enabled: [\n"), + ( + "duplicate.yml", + "nested:\n verify_ssl: false\n verify_ssl: true\n", + ), + ("malformed.toml", "enabled = [\n"), + ( + "duplicate.toml", + "[nested]\nverify_ssl = false\n[nested]\nverify_ssl = true\n", + ), + ] { + let report = scan(name, content); + assert!(report.coverage.incomplete(), "{name}"); + assert_eq!(report.coverage.candidate_files, 1); + assert_eq!(report.coverage.analyzed_files, 0); + assert_eq!(report.coverage.inputs[0].status, "parse_error", "{name}"); + assert!(report.coverage.inputs[0].content_blake3.is_some()); + assert!(report.coverage.inputs[0].reason.is_some()); + } +} + +#[test] +fn checks_reach_nested_arrays_and_quoted_configuration_keys() { + for (name, content) in [ + ("config.json", r#"{"nested":[{"password":"fixture-literal-credential","verify_ssl":false,"privileged":true,"endpoint":"http://remote.invalid"}]}"#), + ("config.yml", "nested:\n - password: fixture-literal-credential\n verify_ssl: false\n privileged: true\n endpoint: http://remote.invalid\n"), + ("config.toml", "[[nested]]\npassword = 'fixture-literal-credential'\nverify_ssl = false\nprivileged = true\nendpoint = 'http://remote.invalid'\n"), + ] { + let report = scan(name, content); + assert_eq!(report.coverage.status, "complete", "{name}"); + for category in [WeakPointCategory::HardcodedSecret, WeakPointCategory::InsecureProtocol, WeakPointCategory::ExcessivePermissions] { + assert!(report.weak_points.iter().any(|finding| finding.category == category), "{name}: {category:?}"); + } + assert!(report.weak_points.iter().all(|finding| !finding.description.contains("fixture-literal-credential"))); + } +} + +#[test] +fn all_yaml_documents_are_checked() { + let report = scan("config.yaml", "enabled: true\n---\nverify_tls: false\n"); + assert_eq!(report.coverage.status, "complete"); + assert!(report.weak_points.iter().any(|finding| finding.category + == WeakPointCategory::InsecureProtocol + && finding.description.contains("document[1]"))); +} + +#[test] +fn comments_references_and_scalar_namespace_identifiers_are_not_credentials_or_endpoints() { + let report = scan("config.yml", "# password: fixture-comment-only\n# url: http://comment.invalid\npassword: '${{ secrets.PASSWORD }}'\n$schema: http://json-schema.org/draft-07/schema#\nendpoint: http://localhost:9099\n"); + assert_eq!(report.coverage.status, "complete"); + assert!(report.weak_points.is_empty(), "{:?}", report.weak_points); + let report = scan( + "nested.json", + r#"{"@context":{"verify_tls":false},"endpoint":"http://localhost.attacker.invalid"}"#, + ); + assert_eq!( + report.weak_points.len(), + 2, + "nested objects and lookalike hosts must remain checked" + ); +} + +#[test] +fn plaintext_endpoint_checks_use_the_host_after_userinfo() { + for (endpoint, insecure) in [ + ("http://localhost@remote.invalid/path", true), + ("http://localhost:123@remote.invalid?query", true), + ("http://first@localhost@remote.invalid#fragment", true), + ("http://user@LOCALHOST:9099/path", false), + ("http://first@second@127.0.0.1?query", false), + ("http://user@[::1]:8080#fragment", false), + ("http://localhost/path@remote.invalid", false), + ("http://localhost?query=@remote.invalid", false), + ("http://localhost#fragment@remote.invalid", false), + ("http://remote.invalid/path@localhost", true), + ("http://remote.invalid?query=@localhost", true), + ("http://remote.invalid#fragment@localhost", true), + ("http://user@localhost.attacker.invalid", true), + ] { + let report = scan("config.json", &format!(r#"{{"endpoint":"{endpoint}"}}"#)); + assert_eq!(report.coverage.status, "complete", "{endpoint}"); + assert_eq!( + report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::InsecureProtocol), + insecure, + "{endpoint}: {:?}", + report.weak_points + ); + } +} + +#[test] +fn cargo_revision_checks_cover_nested_dependencies_and_reject_fake_pins() { + for rev in ["", "rev = ''", "rev = 'main'", "rev = false"] { + let report = scan( + "Cargo.toml", + &format!("[workspace.dependencies.example]\ngit = 'https://example.org/repo'\n{rev}\n"), + ); + assert_eq!(report.coverage.status, "complete"); + assert!(report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::SupplyChain)); + } + let report = scan("Cargo.toml", "[workspace.dependencies.example]\ngit = 'https://example.org/repo'\nrev = '0123456789abcdef0123456789abcdef01234567'\n"); + assert!(!report + .weak_points + .iter() + .any(|finding| finding.category == WeakPointCategory::SupplyChain)); +} + +#[test] +fn supported_configuration_does_not_hide_other_unsupported_candidates() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("config.json"), "{\"enabled\":true}").unwrap(); + std::fs::write(dir.path().join("unknown.swift"), "let enabled = true").unwrap(); + let report = assail::analyze(dir.path()).unwrap(); + assert_eq!(report.coverage.status, "partial"); + assert_eq!(report.coverage.candidate_files, 2); + assert_eq!(report.coverage.analyzed_files, 1); + assert!(report + .coverage + .inputs + .iter() + .any(|input| input.status == "unsupported")); +} + +#[test] +fn executable_configuration_fields_use_existing_interpreter_checks() { + let report = scan("workflow.yml", "on: push\ndefaults:\n run:\n shell: python\njobs:\n check:\n runs-on: ubuntu-latest\n steps:\n - run: eval(user_input)\n - shell: bash\n run: eval \"$INPUT\"\n"); + assert_eq!(report.coverage.status, "complete"); + for (language, category) in [ + ("Python", WeakPointCategory::DynamicCodeExecution), + ("Shell", WeakPointCategory::CommandInjection), + ] { + assert!(report.weak_points.iter().any(|f| f.category == category + && f.description.contains(&format!("embedded {language}")) + && f.description + .contains("configuration field document[0]/jobs/check/steps/"))); + } + let report = scan("package.json", r#"{"scripts":{"build":"eval \"$INPUT\""}}"#); + assert_eq!(report.coverage.status, "complete"); + assert!(report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::CommandInjection + && f.description.contains("runtime shell unresolved"))); + let report = scan( + "action.yml", + "runs:\n using: composite\n steps:\n - shell: bash\n run: eval \"$INPUT\"\n", + ); + assert_eq!(report.coverage.status, "complete"); + assert!(report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::CommandInjection)); +} + +#[test] +fn actions_defaults_obey_scope_and_unknown_interpreters_remain_gaps() { + let report = scan("workflow.yml", "on: push\ndefaults:\n run:\n shell: pwsh\njobs:\n check:\n defaults:\n run:\n shell: bash\n runs-on: windows-latest\n steps:\n - run: echo safe\n"); + assert_eq!( + report.coverage.status, "complete", + "job shell overrides workflow and runner defaults" + ); + for settings in [ + "runs-on: windows-latest", + "runs-on: '${{ matrix.os }}'", + "runs-on: self-hosted", + "runs-on: ubuntu-latest\n defaults:\n run:\n shell: pwsh", + ] { + let report = scan( + "workflow.yml", + &format!( + "on: push\njobs:\n check:\n {settings}\n steps:\n - run: echo safe\n" + ), + ); + assert!(report.coverage.incomplete(), "{settings}"); + assert_eq!(report.coverage.inputs[0].status, "unsupported"); + } + let report = scan("data.yaml", "example:\n run: eval \"$INPUT\"\n"); + assert_eq!(report.coverage.status, "complete"); + assert!( + !report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::CommandInjection), + "arbitrary data is not a workflow command" + ); +} diff --git a/tests/coverage_php_tests.rs b/tests/coverage_php_tests.rs index fe41435..9e3e00f 100644 --- a/tests/coverage_php_tests.rs +++ b/tests/coverage_php_tests.rs @@ -29,7 +29,7 @@ fn malformed_php_and_unsupported_inputs_are_visible() { ) .unwrap(); std::fs::write(dir.path().join("bad.php"), " +//! Concrete review counterexamples for the declared input-adapter contract. +use panic_attack::{ + assail, + types::{AssailReport, WeakPointCategory}, +}; + +fn scan(name: &str, source: &str) -> AssailReport { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, source).unwrap(); + assail::analyze(&path).unwrap() +} + +fn category(report: &AssailReport, expected: WeakPointCategory) -> bool { + report + .weak_points + .iter() + .any(|f| f.category == expected && !f.suppressed) +} + +#[test] +fn clustered_shell_flags_reach_the_actual_inline_source() { + for argv in [ + r#"["bash","-lc","eval \"$INPUT\""]"#, + r#"["bash","-euo","pipefail","-lc","eval \"$INPUT\""]"#, + ] { + let report = scan("Containerfile", &format!("FROM scratch\nRUN {argv}\n")); + assert_eq!(report.coverage.status, "complete", "{argv}"); + assert!( + category(&report, WeakPointCategory::CommandInjection), + "{argv}" + ); + } + let report = scan( + "Containerfile", + "FROM scratch\nRUN [\"bash\",\"script.sh\",\"-c\",\"eval \\\"$INPUT\\\"\"]\n", + ); + assert!( + !category(&report, WeakPointCategory::CommandInjection), + "script arguments are not inline source" + ); +} + +#[test] +fn remote_script_integrity_requires_a_usable_supported_digest_token() { + for integrity in [ + "".to_owned(), + "bogus".into(), + "sha256-short".into(), + format!("sha256-{}=", "B".repeat(43)), + ] { + let report = scan( + "index.html", + &format!(""), + ); + assert_eq!(report.coverage.status, "complete"); + assert!( + category(&report, WeakPointCategory::SupplyChain), + "{integrity}" + ); + } + for integrity in [ + format!("sha256-{}=", "A".repeat(43)), + format!("sha384-{}", "A".repeat(64)), + format!("sha512-{}==", "A".repeat(86)), + ] { + let report = scan( + "index.html", + &format!(""), + ); + assert!( + !category(&report, WeakPointCategory::SupplyChain), + "valid syntax, not a fetched-resource digest proof" + ); + } +} + +#[test] +fn encoded_inline_css_remains_an_explicit_gap() { + let report = scan( + "index.html", + "

x

", + ); + assert_eq!(report.coverage.inputs[0].status, "unsupported"); + assert!(report.coverage.incomplete()); +} + +#[test] +fn plaintext_schemes_are_case_insensitive() { + for prefix in ["http", "HTTP", "hTtP"] { + let report = scan( + "config.json", + &format!(r#"{{"endpoint":"{prefix}://remote.invalid"}}"#), + ); + assert!(category(&report, WeakPointCategory::InsecureProtocol)); + let report = scan( + "config.json", + &format!(r#"{{"endpoint":"{prefix}://localhost:9099"}}"#), + ); + assert!(!category(&report, WeakPointCategory::InsecureProtocol)); + } +} + +#[test] +fn copy_sources_distinguish_stages_pins_and_unresolved_images() { + let report = scan( + "Containerfile", + "FROM scratch\nCOPY --from=nginx:latest /source /dest\n", + ); + assert!(category(&report, WeakPointCategory::SupplyChain)); + for source in [ + "builder".to_owned(), + "0".into(), + format!("nginx@sha256:{}", "a".repeat(64)), + ] { + let report = scan( + "Containerfile", + &format!("FROM scratch AS builder\nCOPY --from={source} /source /dest\n"), + ); + assert_eq!(report.coverage.status, "complete", "{source}"); + assert!( + !category(&report, WeakPointCategory::SupplyChain), + "{source}" + ); + } + let report = scan( + "Containerfile", + "FROM scratch\nCOPY --from=external-context /source /dest\n", + ); + assert_eq!( + report.coverage.inputs[0].status, "unsupported", + "external named context needs resolution" + ); +} + +#[cfg(unix)] +#[test] +fn non_utf8_filenames_keep_registered_ascii_extensions_in_scope() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + for (suffix, content) in [ + ("htm", "

text

"), + ("fth", ": go 1 ;"), + ("forth", ": go 1 ;"), + ("4th", ": go 1 ;"), + ("just", "check:\n echo safe\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let mut bytes = b"input-\xff.".to_vec(); + bytes.extend_from_slice(suffix.as_bytes()); + let path = dir.path().join(OsString::from_vec(bytes)); + std::fs::write(&path, content).unwrap(); + let report = assail::analyze(&path).unwrap(); + assert_eq!(report.coverage.status, "complete", "{suffix}"); + assert_eq!(report.coverage.candidate_files, 1); + assert_eq!(report.coverage.inputs[0].path, path); + } +} + +#[test] +fn container_variant_dispatch_matches_the_inventory_adapter() { + for name in ["Dockerfile.d", "Containerfile.sh", "Dockerfile.pl"] { + let report = scan(name, "FROM scratch\nRUN eval \"$INPUT\"\n"); + assert_eq!(report.coverage.status, "complete"); + assert_eq!(report.language, panic_attack::types::Language::Unknown); + assert!(report.coverage.inputs[0] + .analysis + .as_ref() + .unwrap() + .starts_with("Containerfile")); + assert!( + category(&report, WeakPointCategory::CommandInjection), + "{name}" + ); + } +} + +#[test] +fn non_configuration_literal_credential_fields_are_checked() { + for (name, content) in [ + ( + "Containerfile", + "FROM scratch\nENV API_KEY=fixture-literal-value\n", + ), + ( + "Dockerfile", + "FROM scratch\nARG ACCESS_TOKEN=fixture-literal-value\n", + ), + ( + "Containerfile.example", + "FROM scratch\nLABEL api-key=fixture-literal-value\n", + ), + ( + "index.html", + "

text

", + ), + ("style.css", ":root { --api-key: 'fixture-literal-value'; }"), + ( + "Justfile", + "api_key := 'fixture-literal-value'\ncheck:\n echo safe\n", + ), + ( + "example.forth", + "s\" fixture-literal-value\" 2constant api_key\n", + ), + ] { + let report = scan(name, content); + assert_eq!( + report.coverage.status, "complete", + "{name}: {:?}", + report.coverage.inputs + ); + assert!( + category(&report, WeakPointCategory::HardcodedSecret), + "{name}" + ); + assert!(report + .weak_points + .iter() + .all(|f| !f.description.contains("fixture-literal-value"))); + } + for (name,content) in [("Containerfile","FROM scratch\n# ENV API_KEY=fixture-literal-value\n"),("index.html","

text

"),("style.css","/* --api-key: 'fixture-literal-value' */ p { color: blue; }"),("Justfile","# api_key := 'fixture-literal-value'\ncheck:\n echo safe\n"),("example.forth","\\ s\" fixture-literal-value\" 2constant api_key\n")] { + let report=scan(name,content); + assert!(!category(&report,WeakPointCategory::HardcodedSecret),"{name}"); + assert!(!category(&report,WeakPointCategory::InsecureProtocol),"{name}"); + } +} + +#[test] +fn bracket_prefixed_shell_commands_are_not_misparsed_as_exec_json() { + let report = scan( + "Containerfile", + "FROM scratch\nRUN [ -f /etc/config ] && eval \"$INPUT\"\n", + ); + assert_eq!(report.coverage.status, "complete"); + assert!(category(&report, WeakPointCategory::CommandInjection)); +} + +#[test] +fn just_shell_settings_do_not_depend_on_canonical_spacing() { + for line in [ + r#"set shell:=["pwsh","-c"]"#, + r#"set shell := ["pwsh","-c"]"#, + "set\tshell := [\"pwsh\",\"-c\"]", + ] { + let report = scan("Justfile", &format!("{line}\ncheck:\n echo safe\n")); + assert_eq!(report.coverage.inputs[0].status, "unsupported", "{line}"); + } +} + +#[test] +fn cargo_git_revisions_have_one_structured_finding_per_dependency() { + let report = scan( + "Cargo.toml", + "[dependencies.example]\ngit = \"https://example.org/repo\"\n", + ); + let findings: Vec<_> = report + .weak_points + .iter() + .filter(|f| f.category == WeakPointCategory::SupplyChain) + .collect(); + assert_eq!(findings.len(), 1, "{findings:?}"); +} + +#[test] +fn plain_forth_strings_do_not_escape_their_closing_quote() { + let report = scan( + "example.forth", + r#": run s" C:\dir\" system s" trailing" ;"#, + ); + assert_eq!(report.coverage.status, "complete"); + assert!(category(&report, WeakPointCategory::CommandInjection)); + let report = scan( + "example.forth", + r#": run s\" escaped \" system still string" ;"#, + ); + assert_eq!(report.coverage.status, "complete"); + assert!(!category(&report, WeakPointCategory::CommandInjection)); +} + +#[test] +fn fs_type_annotations_do_not_supply_a_forth_definition() { + for content in [ + "let f (x : int) = [x ; 1]\n", + "; : unfinished\n", + ": ;\n", + ": outer : nested ; ;\n", + ] { + let report = scan("input.fs", content); + assert!(report.coverage.incomplete(), "{content}"); + } + let report = scan("input.fs", "\\ comment with : and ;\n: run system ;\n"); + assert_eq!(report.coverage.status, "complete"); + assert!(category(&report, WeakPointCategory::CommandInjection)); +} + +#[test] +fn adapter_inputs_do_not_vote_for_the_repository_source_language() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Main.res"), "let answer = 42\n").unwrap(); + for (name, source) in [ + ("config.json", "{}"), + ("config.yaml", "enabled: true"), + ("config.toml", "enabled = true"), + ("index.html", "

text

"), + ("style.css", "p { color: blue; }"), + ("Dockerfile.d", "FROM scratch\n"), + ] { + std::fs::write(dir.path().join(name), source).unwrap(); + } + let report = assail::analyze(dir.path()).unwrap(); + assert_eq!(report.language, panic_attack::types::Language::ReScript); + assert!(report.migration_metrics.is_some()); + assert_eq!(report.coverage.candidate_files, 7); + assert_eq!(report.coverage.analyzed_files, 7); +} + +#[test] +fn remote_script_urls_are_normalized_before_integrity_checks() { + for src in [ + " https://cdn.invalid/app.js", + "\tHTTPS://cdn.invalid/app.js\n", + "\r//cdn.invalid/app.js ", + "ht\ntps://cdn.invalid/app.js", + ] { + let report = scan("index.html", &format!("")); + assert!(category(&report, WeakPointCategory::SupplyChain), "{src:?}"); + } + let report = scan("index.html", ""); + assert!(!category(&report, WeakPointCategory::SupplyChain)); +} + +#[test] +fn container_environment_references_are_not_literal_credentials() { + for value in ["$SERVICE_TOKEN", "${SERVICE_TOKEN}", "\"$SERVICE_TOKEN\""] { + let report = scan( + "Containerfile", + &format!("FROM scratch\nENV API_KEY={value}\n"), + ); + assert_eq!(report.coverage.status, "complete"); + assert!( + !category(&report, WeakPointCategory::HardcodedSecret), + "{value}" + ); + } + for value in [ + "$9not-a-variable", + "$SERVICE_TOKEN-suffix", + "literal-$SERVICE_TOKEN", + "\\$SERVICE_TOKEN", + "'$SERVICE_TOKEN'", + "'${SERVICE_TOKEN}'", + ] { + let report = scan( + "Containerfile", + &format!("FROM scratch\nENV API_KEY={value}\n"), + ); + assert!( + category(&report, WeakPointCategory::HardcodedSecret), + "{value}" + ); + } + // JSON has no built-in environment expansion; a leading dollar sign alone + // must not waive a literal configuration credential. + let report = scan("config.json", r#"{"api_key":"$SERVICE_TOKEN"}"#); + assert!(category(&report, WeakPointCategory::HardcodedSecret)); +} + +#[test] +fn reserved_build_names_take_precedence_over_configuration_suffixes() { + for name in ["Dockerfile.json", "Containerfile.yaml", "Dockerfile.toml"] { + let report = scan(name, "FROM scratch\nRUN eval \"$INPUT\"\n"); + assert_eq!(report.coverage.status, "complete", "{name}"); + assert!( + category(&report, WeakPointCategory::CommandInjection), + "{name}" + ); + assert!(report.coverage.inputs[0] + .analysis + .as_ref() + .unwrap() + .starts_with("Containerfile")); + } +} + +#[test] +fn numeric_root_user_spellings_are_not_missed() { + for user in ["0", "00", "000:1000", "root:root"] { + let report = scan("Containerfile", &format!("FROM scratch\nUSER {user}\n")); + assert_eq!(report.coverage.status, "complete"); + assert!( + category(&report, WeakPointCategory::ExcessivePermissions), + "{user}" + ); + } + for user in ["1000", "001000", "00service"] { + let report = scan("Containerfile", &format!("FROM scratch\nUSER {user}\n")); + assert!( + !category(&report, WeakPointCategory::ExcessivePermissions), + "{user}" + ); + } +} diff --git a/tests/template_coverage_tests.rs b/tests/template_coverage_tests.rs new file mode 100644 index 0000000..68bdcd6 --- /dev/null +++ b/tests/template_coverage_tests.rs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +use panic_attack::assail; +use panic_attack::types::{AssailReport, WeakPointCategory}; + +fn scan(name: &str, source: &str) -> AssailReport { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + std::fs::write(&path, source).unwrap(); + assail::analyze(&path).unwrap() +} + +#[test] +fn clean_markup_and_css_have_hashed_parser_coverage_without_findings() { + for (name, source) in [ + ("index.html", "

Example

"), + ("style.css", "/* expression(alert(1)) url(http://comment.invalid) */ p { content: 'expression(alert(1))'; color: blue; }"), + ] { + let report = scan(name, source); + assert_eq!(report.coverage.status, "complete", "{name}"); + assert!(report.coverage.inputs[0].analysis.as_deref().unwrap().starts_with("tree-sitter-")); + assert_eq!(report.coverage.inputs[0].content_blake3.as_deref(), Some(blake3::hash(source.as_bytes()).to_hex().as_str())); + assert!(report.weak_points.is_empty(), "{name}: {:?}", report.weak_points); + } +} + +#[test] +fn markup_checks_distinguish_uri_attributes_from_text_and_comments() { + let report = scan("index.html", "

javascript:example()

run"); + assert_eq!(report.coverage.status, "complete"); + assert_eq!( + report + .weak_points + .iter() + .filter(|f| f.category == WeakPointCategory::DynamicCodeExecution) + .count(), + 1 + ); + assert!(report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::SupplyChain)); +} + +#[test] +fn embedded_javascript_and_css_are_actually_analyzed() { + let report = scan("index.html", ""); + assert_eq!( + report.coverage.status, "complete", + "{:?}", + report.coverage.inputs + ); + assert!(report + .weak_points + .iter() + .any(|f| f.description.contains("CSS expression"))); + assert!(report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::InsecureProtocol)); + assert!(report + .weak_points + .iter() + .any(|f| f.description.contains("embedded JavaScript"))); +} + +#[test] +fn malformed_or_ambiguous_markup_does_not_receive_complete_coverage() { + for (name, source) in [ + ("bad.css", "p { color: rgb(; }"), + ( + "bad.html", + "x", + ), + ("bad-style.html", ""), + ] { + let report = scan(name, source); + assert!(report.coverage.incomplete(), "{name}"); + assert_eq!(report.coverage.inputs[0].status, "parse_error"); + assert!(report.coverage.inputs[0].content_blake3.is_some()); + } +} + +#[test] +fn script_types_and_encoded_attributes_cannot_silently_bypass_an_adapter() { + let report = scan( + "data.html", + "", + ); + assert_eq!(report.coverage.status, "complete"); + assert!(!report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::DynamicCodeExecution)); + for content in [ + "", + "run", + "", + ] { + let report = scan("unknown.html", content); + assert!(report.coverage.incomplete(), "{content}"); + assert_eq!(report.coverage.inputs[0].status, "unsupported"); + } +} + +#[test] +fn nested_quotes_remain_part_of_attribute_and_css_values() { + for (name, source) in [ + ("secret.html", r#"
"#), + ("secret.css", r#"p { password: "'1234567'"; }"#), + ] { + let report = scan(name, source); + assert_eq!(report.coverage.status, "complete", "{name}"); + assert!( + report + .weak_points + .iter() + .any(|f| f.category == WeakPointCategory::HardcodedSecret), + "{name}: {:?}", + report.weak_points + ); + } + + let report = scan( + "type.html", + r#""#, + ); + assert_eq!(report.coverage.inputs[0].status, "unsupported"); + + let report = scan("type.html", ""); + assert_eq!(report.coverage.status, "complete"); +}