Files
sabonerune 2ffb2343b3 feat: support --locked flag (#79)
Co-authored-by: William Woodruff <william@yossarian.net>
2025-11-18 03:58:11 +00:00

176 lines
5.1 KiB
Python
Executable File

#!/usr/bin/env python3
# action.py: run pip-audit
#
# most state is passed in as environment variables; the only argument
# is a whitespace-separated list of inputs
import os
import string
import subprocess
import sys
from base64 import b64encode
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
_HERE = Path(__file__).parent.resolve()
_TEMPLATES = _HERE / "templates"
_GITHUB_STEP_SUMMARY = Path(os.getenv("GITHUB_STEP_SUMMARY")).open(
"a", encoding="utf-8"
)
_GITHUB_OUTPUT = Path(os.getenv("GITHUB_OUTPUT")).open("a", encoding="utf-8")
_RENDER_SUMMARY = os.getenv("GHA_PIP_AUDIT_SUMMARY", "true") == "true"
_DEBUG = os.getenv("RUNNER_DEBUG") is not None
def _template(name):
path = _TEMPLATES / f"{name}.md"
return string.Template(path.read_text())
def _summary(msg):
if _RENDER_SUMMARY:
print(msg, file=_GITHUB_STEP_SUMMARY)
def _debug(msg):
if _DEBUG:
print(f"::debug::{msg}")
def _log(msg):
print(msg, file=sys.stderr)
def _pip_audit(*args):
return ["python", "-m", "pip_audit", *args]
def _fatal_help(msg):
print(f"::error::❌ {msg}")
sys.exit(1)
inputs = [Path(p).resolve() for p in sys.argv[1].split()]
# The arguments we pass into `pip-audit` get built up in this list.
pip_audit_args = [
# The spinner is useless in the CI.
"--progress-spinner=off",
# We intend to emit a Markdown-formatted table.
"--format=markdown",
# `pip cache dir` doesn't work in this container for some reason, and I
# haven't debugged it yet.
"--cache-dir=/tmp/pip-audit-cache",
# Include full descriptions in the output.
"--desc",
# Write the output to this logfile, which we'll turn into the step summary (if configured).
"--output=/tmp/pip-audit-output.txt",
] + os.getenv("GHA_PIP_AUDIT_INTERNAL_BE_CAREFUL_EXTRA_FLAGS").split()
if _DEBUG:
pip_audit_args.append("--verbose")
if os.getenv("GHA_PIP_AUDIT_NO_DEPS", "false") != "false":
pip_audit_args.append("--no-deps")
if os.getenv("GHA_PIP_AUDIT_REQUIRE_HASHES", "false") != "false":
pip_audit_args.append("--require-hashes")
if os.getenv("GHA_PIP_AUDIT_LOCAL", "false") != "false":
pip_audit_args.append("--local")
if os.getenv("GHA_PIP_DISABLE_PIP", "false") != "false":
pip_audit_args.append("--disable-pip")
index_url = os.getenv("GHA_PIP_AUDIT_INDEX_URL")
if index_url != "":
pip_audit_args.extend(["--index-url", index_url])
extra_index_urls = os.getenv("GHA_PIP_AUDIT_EXTRA_INDEX_URLS", "").split()
for url in extra_index_urls:
pip_audit_args.extend(["--extra-index-url", url])
ignored_vuln_ids = os.getenv("GHA_PIP_AUDIT_IGNORE_VULNS", "").split()
for vuln_id in ignored_vuln_ids:
pip_audit_args.extend(["--ignore-vuln", vuln_id])
pip_audit_args.extend(
[
"--vulnerability-service",
os.getenv("GHA_PIP_AUDIT_VULNERABILITY_SERVICE", "pypi").lower(),
]
)
locked = os.getenv("GHA_PIP_AUDIT_LOCKED", "false") != "false"
if locked:
pip_audit_args.append("--locked")
# If inputs is empty, we let `pip-audit` run in "`pip list` source" mode by not
# adding any explicit input argument(s).
# Otherwise, we handle either exactly one project path (a directory)
# or one or more requirements-style inputs (all files).
for input_ in inputs:
# Forbid things that look like flags. This isn't a security boundary; just
# a way to prevent (less motivated) users from breaking the action on themselves.
if str(input_).startswith("-"):
_fatal_help(f"input {input_} looks like a flag")
if input_.is_dir():
if len(inputs) != 1:
_fatal_help("pip-audit only supports one project directory at a time")
pip_audit_args.append(input_)
else:
if not input_.is_file():
_fatal_help(f"input {input_} does not look like a file")
if locked:
_fatal_help("locked only applies to audits of project paths")
pip_audit_args.extend(["--requirement", input_])
_debug(f"running: pip-audit {[str(a) for a in pip_audit_args]}")
status = subprocess.run(
_pip_audit(*pip_audit_args),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env={**os.environ, "PIP_NO_CACHE_DIR": "1"},
)
_debug(status.stdout)
if status.returncode == 0:
_summary("🎉 pip-audit exited successfully")
else:
_summary("❌ pip-audit found one or more problems")
output = "⚠️ pip-audit did not return any output"
try:
with open("/tmp/pip-audit-output.txt", "r") as io:
output = io.read()
except OSError as ex:
_log(ex)
# This is really nasty: our output contains multiple lines,
# so we can't naively stuff it into an output.
print(f"output={b64encode(output.encode()).decode()}", file=_GITHUB_OUTPUT)
_log(output)
_summary(output)
_log(status.stdout)
_summary(_template("pip-audit").substitute(output=status.stdout))
# Normally, we exit with the same code as `pip-audit`, but the user can
# explicitly configure the CI to always pass.
# This is primarily useful for our own self-test workflows.
if os.getenv("GHA_PIP_AUDIT_INTERNAL_BE_CAREFUL_ALLOW_FAILURE", "false") != "false":
sys.exit(0)
else:
sys.exit(status.returncode)