name: Setup PMG description: Install Package Manager Guard (PMG) and wrap npm/pip/etc. in this Linux GitHub Actions runner. author: SafeDep branding: icon: shield color: green inputs: version: description: PMG release tag to install (e.g. v0.42.0) or "latest". required: false default: latest api-key: description: SafeDep Cloud API key. When set together with tenant-id, cloud sync is enabled and credentials are passed via env (no keychain needed in CI). required: false default: "" tenant-id: description: SafeDep Cloud tenant ID. Required when api-key is set. required: false default: "" endpoint-id: description: Identifier reported to SafeDep Cloud for this runner. When empty and cloud is enabled, defaults to "github-actions//" so events aggregate per repository instead of per ephemeral runner hostname. required: false default: "" paranoid: description: Treat suspicious packages as malicious (PMG_PARANOID). Empty = use PMG default (false). required: false default: "" cooldown-enabled: description: Block recently-published package versions (PMG_DEPENDENCY_COOLDOWN_ENABLED). Empty = use PMG default (true). required: false default: "" cooldown-days: description: Cooldown window in days (PMG_DEPENDENCY_COOLDOWN_DAYS). Empty = use PMG default (5). required: false default: "" proxy-mode: description: REMOVED. PMG always uses proxy-based interception. Setting "false" fails the action; any other value is ignored with a warning. required: false default: "" sandbox: description: Run package managers inside an OS sandbox (PMG_SANDBOX_ENABLED). Empty = use PMG default (false). Enabling will also relax AppArmor user-namespace restrictions on the runner so unprivileged user namespaces work. required: false default: "" sandbox-driver: description: Sandbox backend on Linux (PMG_SANDBOX_DRIVER). One of "landlock" or "bubblewrap". Defaults to "landlock" when sandbox is enabled and this is empty. required: false default: "" verbosity: description: PMG output verbosity (PMG_VERBOSITY). One of "silent", "normal", "verbose". Empty = use PMG default. required: false default: "" disable-telemetry: description: Disable anonymous PMG telemetry (PMG_DISABLE_TELEMETRY). Empty = use PMG default. required: false default: "" skip-event-logging: description: Skip writing audit events to the local event log (PMG_SKIP_EVENT_LOGGING). Empty = use PMG default. required: false default: "" config-file: description: Path (relative to the workspace) to a user-supplied PMG config.yml. Copied into the PMG config directory before "pmg setup install" runs, so PMG merges any missing template keys into it. required: false default: "" cache: description: Reuse a previously-installed PMG binary from $RUNNER_TOOL_CACHE/pmg//. Off by default; fresh download per run keeps the binary current and reduces the blast radius of any cached artifact tampering. Checksum verification still runs on every install path. required: false default: "false" server-mode: description: Run PMG as a persistent proxy server instead of shims. The proxy is started as a daemon and HTTP_PROXY/cert env vars are exported to the job. Requires a job-end "pmg proxy stop --fail-on-violation" step. required: false default: "false" runs: using: composite steps: - name: Verify runner OS shell: bash run: | if [ "${{ runner.os }}" != "Linux" ]; then echo "::error::safedep/pmg action currently supports Linux runners only. See https://github.com/safedep/pmg/issues/248 for cross-platform tracking." exit 1 fi - name: Install PMG id: install shell: bash env: INPUT_VERSION: ${{ inputs.version }} INPUT_CACHE: ${{ inputs.cache }} run: | set -euo pipefail repo="safedep/pmg" # Resolve version tag. if [ "$INPUT_VERSION" = "latest" ] || [ -z "$INPUT_VERSION" ]; then echo "Resolving latest PMG release..." tag=$(curl -fsSI -o /dev/null -w '%{redirect_url}' \ "https://github.com/${repo}/releases/latest" | sed 's|.*/||' | tr -d '\r\n') if [ -z "$tag" ]; then echo "::error::Could not resolve latest PMG release tag." >&2 exit 1 fi else tag="$INPUT_VERSION" fi echo "PMG version: $tag" # Map architecture. case "$(uname -m)" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="arm64" ;; *) echo "::error::Unsupported architecture: $(uname -m)" >&2 exit 1 ;; esac asset="pmg_Linux_${arch}.tar.gz" cache_dir="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}/pmg/${tag}/${arch}" install_dir="$cache_dir" if [ "$INPUT_CACHE" != "true" ]; then install_dir="${RUNNER_TEMP:-/tmp}/pmg-${tag}-${arch}" fi mkdir -p "$install_dir" bin_path="${install_dir}/pmg" tar_path="${install_dir}/${asset}" # Always fetch upstream checksums.txt — even on a cache hit — so a # tampered cache entry is caught before we run the binary. tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT curl -fsSL -o "${tmpdir}/checksums.txt" \ "https://github.com/${repo}/releases/download/${tag}/checksums.txt" expected=$(grep " ${asset}\$" "${tmpdir}/checksums.txt" | cut -d' ' -f1 || true) if [ -z "$expected" ]; then echo "::error::No checksum entry for ${asset} in ${tag}/checksums.txt" >&2 exit 1 fi need_download=true if [ "$INPUT_CACHE" = "true" ] && [ -f "$tar_path" ] && [ -x "$bin_path" ]; then cached=$(sha256sum "$tar_path" | cut -d' ' -f1) if [ "$cached" = "$expected" ]; then echo "Cached PMG ${tag} verified against upstream checksum" need_download=false else echo "Cached PMG ${tag} checksum drift (want ${expected}, got ${cached}); refreshing" rm -f "$tar_path" "$bin_path" fi fi if [ "$need_download" = "true" ]; then curl -fsSL -o "$tar_path" \ "https://github.com/${repo}/releases/download/${tag}/${asset}" actual=$(sha256sum "$tar_path" | cut -d' ' -f1) if [ "$actual" != "$expected" ]; then echo "::error::Checksum mismatch for ${asset}: want ${expected}, got ${actual}" >&2 exit 1 fi echo "Checksum verified for ${asset}" tar -xzf "$tar_path" -C "$install_dir" pmg chmod +x "$bin_path" fi echo "$install_dir" >> "$GITHUB_PATH" echo "pmg-bin-dir=$install_dir" >> "$GITHUB_OUTPUT" echo "pmg-version=$tag" >> "$GITHUB_OUTPUT" - name: Prepare sandbox (apt + AppArmor) if: inputs.sandbox == 'true' shell: bash env: INPUT_DRIVER: ${{ inputs.sandbox-driver }} run: | set -euo pipefail if [ "$INPUT_DRIVER" = "bubblewrap" ]; then if command -v sudo >/dev/null 2>&1; then sudo apt-get update sudo apt-get install -y bubblewrap else apt-get update apt-get install -y bubblewrap fi fi # Relax AppArmor restriction on unprivileged user namespaces so # Landlock helper and Bubblewrap can clone with CLONE_NEWUSER. # Tolerate failure on locked-down runners; the sandbox driver will # report a clear error at runtime if it cannot start. if command -v sudo >/dev/null 2>&1; then sudo systemctl stop apparmor 2>/dev/null || true sudo systemctl disable apparmor 2>/dev/null || true sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true else systemctl stop apparmor 2>/dev/null || true systemctl disable apparmor 2>/dev/null || true sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true fi - name: Stage user config file if: inputs.config-file != '' shell: bash env: INPUT_CONFIG_FILE: ${{ inputs.config-file }} run: | set -euo pipefail src="${GITHUB_WORKSPACE}/${INPUT_CONFIG_FILE}" if [ ! -f "$src" ]; then echo "::error::config-file not found: $src" >&2 exit 1 fi dest_dir="${XDG_CONFIG_HOME:-$HOME/.config}/safedep/pmg" mkdir -p "$dest_dir" cp "$src" "$dest_dir/config.yml" echo "Staged $src -> $dest_dir/config.yml" - name: Export PMG configuration shell: bash env: IN_API_KEY: ${{ inputs.api-key }} IN_TENANT_ID: ${{ inputs.tenant-id }} IN_ENDPOINT_ID: ${{ inputs.endpoint-id }} IN_PARANOID: ${{ inputs.paranoid }} IN_COOLDOWN_ENABLED: ${{ inputs.cooldown-enabled }} IN_COOLDOWN_DAYS: ${{ inputs.cooldown-days }} IN_PROXY_MODE: ${{ inputs.proxy-mode }} IN_SANDBOX: ${{ inputs.sandbox }} IN_SANDBOX_DRIVER: ${{ inputs.sandbox-driver }} IN_VERBOSITY: ${{ inputs.verbosity }} IN_DISABLE_TELEMETRY: ${{ inputs.disable-telemetry }} IN_SKIP_EVENT_LOGGING: ${{ inputs.skip-event-logging }} IN_SERVER_MODE: ${{ inputs.server-mode }} GH_REPOSITORY: ${{ github.repository }} run: | set -euo pipefail export_var() { local name="$1" value="$2" if [ -n "$value" ]; then printf '%s=%s\n' "$name" "$value" >> "$GITHUB_ENV" fi } # Guard mode is removed: fail loudly when a workflow still opts out of # proxy interception instead of silently switching it to proxy mode. case "$(echo "$IN_PROXY_MODE" | tr '[:upper:]' '[:lower:]')" in false|0|f) echo "::error::PMG guard mode has been removed and proxy interception can no longer be disabled. Remove the 'proxy-mode' input." >&2 exit 1 ;; "") ;; *) echo "::warning::The 'proxy-mode' input is removed and has no effect. Remove it from your workflow." >&2 ;; esac # Cloud credentials. PMG sync reads these directly when no keychain # credential is found, so we skip "pmg cloud login" entirely. if [ -n "$IN_API_KEY" ] || [ -n "$IN_TENANT_ID" ]; then if [ -z "$IN_API_KEY" ] || [ -z "$IN_TENANT_ID" ]; then echo "::error::api-key and tenant-id must be set together." >&2 exit 1 fi echo "::add-mask::$IN_API_KEY" export_var SAFEDEP_API_KEY "$IN_API_KEY" export_var SAFEDEP_TENANT_ID "$IN_TENANT_ID" export_var PMG_CLOUD_ENABLED "true" if [ "$IN_SERVER_MODE" = "true" ]; then # In server mode the proxy daemon owns delivery and uses auto_sync # to gate both periodic sync and the shutdown flush. export_var PMG_CLOUD_AUTO_SYNC_ENABLED "true" else # PMG's opportunistic background auto-sync is designed for # long-lived workstations. On an ephemeral runner the detached child # can be torn down before it drains the WAL, and the WAL goes with # it. Turn it off and rely on an explicit `pmg cloud sync` at # job-end if the user wants events delivered. export_var PMG_CLOUD_AUTO_SYNC_ENABLED "false" fi if [ -n "$IN_ENDPOINT_ID" ]; then export_var PMG_CLOUD_ENDPOINT_ID "$IN_ENDPOINT_ID" else # Stable per-repo identifier; the runner hostname is ephemeral # so PMG's hostname fallback would create a new "machine" entry # in SafeDep Cloud for every job. export_var PMG_CLOUD_ENDPOINT_ID "gha/${GH_REPOSITORY}" fi fi # Only emit PMG_* vars when the user explicitly set the input. This # preserves the "config-file overrides PMG defaults" guarantee — env # vars take precedence over config.yml in Viper, so empty pass-through # means we don't shadow file-based tuning. export_var PMG_PARANOID "$IN_PARANOID" export_var PMG_DEPENDENCY_COOLDOWN_ENABLED "$IN_COOLDOWN_ENABLED" export_var PMG_DEPENDENCY_COOLDOWN_DAYS "$IN_COOLDOWN_DAYS" export_var PMG_SANDBOX_ENABLED "$IN_SANDBOX" if [ "$IN_SANDBOX" = "true" ]; then driver="${IN_SANDBOX_DRIVER:-landlock}" export_var PMG_SANDBOX_DRIVER "$driver" fi export_var PMG_VERBOSITY "$IN_VERBOSITY" export_var PMG_DISABLE_TELEMETRY "$IN_DISABLE_TELEMETRY" export_var PMG_SKIP_EVENT_LOGGING "$IN_SKIP_EVENT_LOGGING" - name: Run pmg setup install (shim mode) if: inputs.server-mode != 'true' shell: bash run: pmg setup install - name: Add PMG shims to PATH (shim mode) if: inputs.server-mode != 'true' shell: bash run: echo "$HOME/.pmg/bin" >> "$GITHUB_PATH" - name: Start PMG proxy server (server mode) if: inputs.server-mode == 'true' shell: bash run: | set -euo pipefail # No cert install needed: `pmg proxy env` exports the CA path via # NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/PIP_CERT, so package managers trust # the proxy without touching the OS trust store. pmg proxy start --daemon pmg proxy env >> "$GITHUB_ENV" - name: Verify PMG shell: bash run: | pmg version echo "npm resolves to: $(command -v npm || echo '')" outputs: version: description: The resolved PMG version that was installed. value: ${{ steps.install.outputs.pmg-version }} bin-dir: description: Directory containing the pmg binary on this runner. value: ${{ steps.install.outputs.pmg-bin-dir }}