mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: add GitHub Action for one-step PMG setup in CI
Composite action at repo root that downloads PMG (with SHA-256 verification
against the upstream checksums.txt), runs `pmg setup install`, and wires
shims onto $GITHUB_PATH so subsequent `npm install` / `pip install` calls
are transparently analyzed.
Defaults are conservative: malware blocking + dependency cooldown + proxy
mode (matching PMG's own defaults). Sandbox is opt-in because enabling
Landlock/Bubblewrap on ubuntu-latest requires relaxing AppArmor
user-namespace restrictions.
Cloud sync uses the documented SAFEDEP_API_KEY / SAFEDEP_TENANT_ID env-var
fallback so we skip the keychain codepath that has no usable backend in
headless CI. When cloud is enabled and no endpoint-id is supplied, the
action sets PMG_CLOUD_ENDPOINT_ID=github-actions/${GITHUB_REPOSITORY} so
events aggregate per repository instead of per ephemeral runner hostname.
Closes #248.
https://claude.ai/code/session_01ARb8ZiBiJjvhWjchBXraAh
* fix(action): drop github.repository template from input description
Action manifest validation rejected the action.yml because the endpoint-id
input description contained ${{ github.repository }} — template expressions
aren't evaluated in input description text and trip the validator with
"Unrecognized named-value: 'github'". This caused every job using uses: ./
to fail before any step ran.
Also switch the config-file e2e job to verify the staged file directly
instead of calling `pmg config get`, which is not in the v0.13.0 release
that "latest" resolves to today.
https://claude.ai/code/session_01ARb8ZiBiJjvhWjchBXraAh
* fix(action): address review comments on PR #263
- Drop opinionated defaults on PMG_* toggle inputs. All defaults are now
empty strings, and the action only exports PMG_* env vars when the
caller explicitly sets the input. Without this, defaults like
PMG_PARANOID=false silently shadowed config-file overrides because env
vars beat config.yml in Viper precedence.
- Verify cached PMG against upstream checksums.txt on every cache hit.
The cached tarball is stored alongside the binary and re-hashed against
the freshly-fetched checksums.txt; on drift, the cache entry is evicted
and re-downloaded.
- Export PMG_* env vars BEFORE running `pmg setup install` so settings
like disable-telemetry actually apply during setup, not just to
subsequent package-manager calls.
- Add `|| true` to the grep that extracts the expected checksum so
set -e doesn't kill the script before the friendly error message fires
when no checksum entry is found.
- Pin third-party actions (actions/checkout, actions/setup-node) to
commit SHAs to match the repo's supply-chain hardening convention.
- Fix the malicious-package E2E test capturing tee's exit code instead
of npm's; redirect to a file and check the actual command exit code.
- Add an E2E job that asserts PMG_PARANOID is unset when only
config-file is provided — regression guard for the precedence fix.
https://claude.ai/code/session_01ARb8ZiBiJjvhWjchBXraAh
* ci(action-e2e): scope sandbox tests to action setup, not PMG runtime
The landlock job was running `npm install express` with no explicit
sandbox profile and the default profile blocks something npm needs
(PMG's own e2e uses `--sandbox-profile npm-restrictive` to make this
viable). Bubblewrap happened to pass, but verifying the default sandbox
profile is permissive enough for arbitrary package installs is PMG's
e2e responsibility — this workflow's job is to assert the action wires
sandbox config correctly.
Switch both drivers to a matrix and verify only what the action owns:
PMG_SANDBOX_* env vars propagated, pmg binary runs, bwrap is installed
when requested, AppArmor user-ns restriction relaxed.
https://claude.ai/code/session_01ARb8ZiBiJjvhWjchBXraAh
* ci(action-e2e): bump setup-node to 24
Node 20 reached end-of-life and setup-node now warns on it. Match the
version pinned by publish-npm.yml (the repo's newest workflow). Updated
the README and docs/github-action.md quick-start examples to match.
https://claude.ai/code/session_01ARb8ZiBiJjvhWjchBXraAh
---------
Co-authored-by: Claude <noreply@anthropic.com>
309 lines
12 KiB
YAML
309 lines
12 KiB
YAML
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/<owner>/<repo>" 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: Use proxy-based interception (PMG_PROXY_ENABLED). Empty = use PMG default (true). Setting "false" falls back to guard-based analysis.
|
|
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/<version>/<arch>. 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"
|
|
|
|
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 }}
|
|
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
|
|
}
|
|
|
|
# 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 [ -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 "github-actions/${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_PROXY_ENABLED "$IN_PROXY_MODE"
|
|
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
|
|
shell: bash
|
|
run: pmg setup install
|
|
|
|
- name: Add PMG shims to PATH
|
|
shell: bash
|
|
run: echo "$HOME/.pmg/bin" >> "$GITHUB_PATH"
|
|
|
|
- name: Verify PMG
|
|
shell: bash
|
|
run: |
|
|
pmg version
|
|
echo "npm resolves to: $(command -v npm || echo '<not installed>')"
|
|
|
|
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 }}
|