pkglint
A security-focused linter for Arch Linux packages.
pkglint statically analyzes PKGBUILDs and their install scriptlets — without ever sourcing them — and reports findings about source integrity, build hermeticity, code execution, and persistence patterns, condensed into a letter grade per package. It also reproduces makepkg's own build-breaking metadata checks, so a PKGBUILD that would fail to build is caught (and, where the fix is mechanical, rewritten) before you run makepkg. It is built on a real bash AST (mvdan.cc/sh), so the quoting/line-continuation tricks that evade regex-based scanners don't work here.
Built packages (*.pkg.tar.zst and friends) are first-class inputs too: pkglint inspects
the archive the way namcap does — ELF hardening (PIE, RELRO, executable stacks, text
relocations, RPATH), stripping and placement, dependencies inferred from linked shared
libraries and script shebangs (via pacman's local database), packaged .INSTALL
scriptlets, and filesystem hygiene from FHS layout to stale python bytecode — while
never executing anything from the package (no ldd, no interpreter launches; ELF
files are parsed, not loaded).
$ pkglint ~/pkgbuilds/somepkg
somepkg: grade F, 3 finding(s)
PKGBUILD:16:3: critical [PB304] a network download is piped straight into bash and executed
PKGBUILD:11:1: error [PB101] remote source "http://..." has no checksum (SKIP): the download is never verified
PKGBUILD:24:3: error [PB402] sudo escalates privileges during a build; ...
1 package linted: 1 with findings
1 finding(s) fixable with --unsafe-fix
Packages with nothing to report stay out of the way — they are only counted in
the closing summary line (--verbose lists them individually). The line after
it tallies the findings a rule can rewrite, split by the flag that applies them
(--fix for behavior-preserving fixes, --unsafe-fix for the rest). A few
fixes also need state the linter does not have — the sources on disk for a
digest, an https host that answers — so the tally is what a fix run will
attempt, not a promise of that many rewrites.
Install
AUR
yay -S pkglint
PyPi
uv tool install pkglint
Go
go install github.com/jmelahman/pkglint@latest
Usage
pkglint [flags] [path ...] # paths are package dirs, PKGBUILD files,
# or built packages (*.pkg.tar.*) (default: .)
--format text|json|sarif # output format (sarif = SARIF 2.1.0, for code scanning)
--fail-on SEVERITY # exit 1 at or above: info, warn (default), error, critical, never
--ignore PB105,PB206 # disable rules
--color auto|always|never # colorize text output (auto = only on a terminal; honors NO_COLOR)
--verbose # list packages with no findings individually, not just in the summary
--rules # list every rule with its documentation
--fix # apply safe auto-fixes in place
--unsafe-fix # also apply behavior-changing fixes (implies --fix)
--diff # with --fix/--unsafe-fix/--add-ignores: show changes instead of writing
--offline # with --fix: skip fixes needing network (VCS ref resolution, https probing)
--add-ignores # insert ignore directives suppressing every current finding
--no-inline-ignores # disregard ignore directives (audit an untrusted package)
Suppress a reviewed, intentional finding inline:
# pkglint: ignore=PB204
go build -o "$pkgname" .
The directive covers its own line and the line below it, in the file it appears
in only: an ignore= in an .install scriptlet never affects the PKGBUILD,
or vice versa. --add-ignores writes these directives for you, one per finding
still reported, so a package can adopt pkglint without first fixing (or while
deliberately keeping) what it flags; --diff previews the insertions.
Directives are audited, not just obeyed. A directive that no longer matches a
finding on its line or the next — the issue was fixed, or the ID was never a
pkglint rule — is itself flagged (PB913, stale-ignore-directive), and --fix
deletes it, so a fixed issue cannot leave behind a comment that would silence
its return. And when reviewing a package you don't trust, --no-inline-ignores
disregards every directive and reports whatever the maintainer suppressed.
Auto-fixing
--fix rewrites what it can and prints every change; --diff previews without
writing. Fixes come in two tiers:
| Tier | Flag | Rules | What it does |
|---|---|---|---|
| Safe | --fix |
PB102, PB103, PB205, PB705, PB708, PB913, PB916 | Add sha256sums beside a weak md5sums/sha1sums (see below); pin a mutable VCS tag/branch to its current commit (via git ls-remote); delete Go verification-disabling env settings; strip a leading slash from backup entries; wrap a scalar list field (depends=foo) in an array (depends=(foo)); remove stale ignore directives; insert -modcacherw into go commands so the module cache stays removable |
| Unsafe | --unsafe-fix |
PB104, PB112, PB203, PB204, PB206–PB209, PB403, PB902, PB914–PB915, PB940, PB942 | Upgrade an insecure source transport (http:///ftp:// → https://, bare git:// → git+https://), signature sources included, and only after a headers-only request (or git ls-remote) confirms the https URL answers, so an unserved one keeps the finding — reachable still isn't identical, so rebuild after; append --locked to cargo (fails the build if no Cargo.lock ships); add a go mod download prepare() step; insert -buildmode=pie and -trimpath into go build; switch npm install→ci and yarn install→--immutable; append --frozen-lockfile to pnpm/bun install, --no-scripts to composer install, --frozen to bundle install and uv sync; drop --release from cargo test/cargo check so the suite runs with debug assertions and overflow checks on; insert --release into a build() cargo build so the package ships the optimized profile (declined when the PKGBUILD reads a debug/ artifact path, since --release moves the binary, or when the command's flags arrive through an expansion); drop setuid/setgid mode bits; prefix a custom variable with an underscore (pyname → _pyname), declaration and every reference at once |
Safe fixes preserve behavior or restore a security default; unsafe fixes are
mechanical but change what the build does, so review them. The PB902 rename is
all-or-nothing: it lands only when every occurrence of the name is accounted
for, and it stands down entirely when the variable is exported (a build tool
may read it from the environment), when the underscored spelling is taken, or
when the file reaches variables in ways no rewrite can follow — run-time
naming (eval, ${!x}, declare -n), source, a reference spelled in
literal text such as a trap string — because a half-applied rename would
leave the PKGBUILD reading a variable nothing sets. An inline
# pkglint: ignore= on a finding's line also suppresses its fix. Findings whose
remediation isn't a mechanical rewrite print a one-line suggestion instead:
updpkgsums for checksums, makepkg --printsrcinfo for a stale .SRCINFO.
Those suggestions are computed from what is left after fixing, so a checksum
--fix repaired is not then nagged about.
The PB102 fix is the one whose remedy is data rather than syntax, so it applies
only where it can prove what it writes. It hashes sources already downloaded
into the package directory or $SRCDEST — pkglint never fetches a source, since
that would mean issuing requests to URLs read out of an untrusted file — and it
emits a digest only after re-computing the existing md5/sha1 from the same
read and finding it matches. The sha256sums it adds therefore covers bytes the
weak digest already vouched for: replacing them would take an md5 preimage,
not the collision that makes md5 unfit for new use. Sources that aren't present,
a digest that doesn't match, or an array pkglint can't pair index-for-index all
leave the finding standing, and updpkgsums remains the way to close it. The
weak array is kept — makepkg checks every array present, so the edit is purely
additive.
Commit hook
This repo ships hooks for any runner that understands the
.pre-commit-config.yaml convention, so a packaging tree can lint its
PKGBUILDs on every commit:
repos:
- repo: https://github.com/jmelahman/pkglint
rev: v1.2.0
hooks:
- id: pkglint
Three hook ids are available: pkglint builds from source with the Go
toolchain, pkglint-system runs whatever pkglint is already on $PATH, and
pkglint-fix applies the safe auto-fixes in place (offline by default). Tune
any of them with e.g. args: [--ignore, PB105, --fail-on, critical].
Rules
| Group | Rules | What they catch |
|---|---|---|
| Integrity | PB101–PB114 | SKIP/weak/malformed checksums, unpinned VCS sources, unencrypted transports, source/url domain and forge-owner mismatches, DLAGENTS and other makepkg.conf overrides, checksum-count mismatches, missing install scripts, PGP signatures without pinned keys, insecure signature transport, unused validpgpkeys |
| Hermeticity | PB201–PB209 | network access outside prepare(), pip/uv pip without --require-hashes, unlocked cargo/npm/yarn/pnpm/bun/composer/bundler/uv/poetry installs, implicit Go module downloads and mutable @latest refs, disabled checksum databases |
| Execution | PB301–PB309 | top-level code, eval, decode-and-execute, download-and-execute (including eval "$(curl ...)" and source <(wget ...)" variants), /dev/tcp, unresolvable command names, embedded payloads, makepkg-internal function overrides, hidden bidi/zero-width characters |
| Filesystem | PB401–PB405 | writes outside $srcdir/$pkgdir, privilege escalation, setuid files and setcap capability grants, install steps that skip $pkgdir, writes to pacman/dynamic-linker/sudoers config |
| Scriptlets | PB501–PB504 | network access and persistence (crontabs, systemd units, shell profiles, login-capable users) in .install files running as root, unparseable scriptlets, commands pacman hooks already run |
| Consistency | PB601–PB603 | PKGBUILD / .SRCINFO drift, network access in pkgver(), provides/replaces/conflicts claims on core system packages |
| Correctness | PB701–PB711 | makepkg build-breakers: invalid pkgname/pkgver/pkgrel/epoch, backup leading slash, unknown options, provides comparison operators, scalar-vs-array field types, schema variables set inside package(), missing/duplicate/mixed arch, VCS sources without their client in makedepends |
| Built package | PB801–PB842 | everything namcap checks in a .pkg.tar.*: ELF in any packages and nonstandard paths, executable stacks, text relocations, missing RELRO, non-PIE executables, unstripped binaries, insecure RPATH/RUNPATH, missing/unused library and interpreter dependencies (resolved through pacman's database, statically — no ldd), stale soname declarations, pkg-config requirements, FHS layout, permissions and ownership, empty directories, invalid filenames, cross-directory hardlinks, dangling symlinks, .la/perllocal.pod/info dir/MIME-cache landmines, stale python bytecode, site-packages/tests, systemd/D-Bus units under /etc, missing license and backup files, doc-heavy packages, sphinx caches, jars outside /usr/share/java — plus the full scriptlet analysis over the packaged .INSTALL |
| Style | PB901–PB984 | namcap's PKGBUILD conventions: hardcoded architectures instead of $CARCH, custom variables without _ prefix, $startdir, redundant makedepends, pinned SourceForge mirrors, pkgname repeated in pkgdesc, makepkg-internal output helpers, missing Maintainer tag, uppercase package names, missing pkgdesc/url/license, version-only download names, depends duplicated in optdepends, stale ignore directives; plus the published Arch package guidelines: self-provides/self-conflicts, pre-SPDX license identifiers, installs into /usr/local or /usr/libexec; the Go guidelines: go build without -buildmode=pie or -trimpath, module caches written read-only (no -modcacherw), CFLAGS/LDFLAGS never forwarded to cgo; the Python guidelines: tox, lint/coverage plugins gating check(), pre-built wheels as sources, the python-build/python-installer flow and its makedepends; the Rust guidelines: cargo test --release, cargo install without --no-track, debug-profile builds, missing rust makedepends; the CMake/Meson guidelines: missing /usr prefix, CMAKE_BUILD_TYPE=Release clobbering Arch's flags, build tools missing from makedepends, bare ninja in meson builds; the VCS guidelines: tip-following sources without pkgver(), -git packages without provides/conflicts, $pkgver in checkout folder names, -git suffixes that don't match the sources; and the per-ecosystem naming families — fonts (arch=('any'), no depends, unstable download hosts), DKMS (dkms in depends, no pinned kernel headers), lib32 ((32-bit) pkgdesc, -m32), MinGW (!strip staticlibs !buildflags, (mingw-w64) pkgdesc), Node.js (npm makedepends, --cache in $srcdir), Java (java-runtime depends), CLR (arch=('any') + !strip), Haskell (never arch=('any')) and PHP (pure-PHP packages are arch=('any')) |
pkglint --rules prints the full documentation for each.
A full reference including examples of each rule is available in the documentation.
Relationship to namcap
pkglint covers namcap's rule set — both the PKGBUILD checks and the built-package
checks — with a few deliberate differences: nothing from the analyzed package is ever
executed (namcap runs ldd -r -u on packaged binaries; pkglint compares dynamic symbol
tables instead), findings the lint host cannot actually verify (a library owned by a
package that isn't installed here, a declared dependency that isn't installed) are
reported informationally instead of as hard errors, and everything is folded into the
same graded, suppressible, JSON/SARIF-capable reporting the PKGBUILD rules use.
Dependency inference reads pacman's local database directly (/var/lib/pacman/local)
and degrades gracefully on non-Arch hosts by skipping just those rules.
Grading: any critical → F, any error → D, 3+ warns → C, 1–2 warns → B, otherwise A.
A grade is a static hygiene score, not a malware verdict — it measures how reviewable and reproducible a PKGBUILD is. A low grade means "worth reviewing", never "malicious", and a high grade is not an endorsement. Static analysis cannot catch a malicious upstream release pinned with a perfectly valid checksum.
License
GPLv3 — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pkglint-1.3.3.tar.gz.
File metadata
- Download URL: pkglint-1.3.3.tar.gz
- Upload date:
- Size: 347.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
39d563e24f67b4fc2c52aa89ab2d6312c4cf750f9bfc1c9fb1ee7878fa1c9d66
|
|
| MD5 |
7d4555af261dc5dfbe2e0782af2a080a
|
|
| BLAKE2b-256 |
645777d003c416647d651d302ae4f7e4d03fc287fa472376e7bc1f1ec759679e
|
File details
Details for the file pkglint-1.3.3-py3-none-manylinux_2_17_x86_64.whl.
File metadata
- Download URL: pkglint-1.3.3-py3-none-manylinux_2_17_x86_64.whl
- Upload date:
- Size: 4.4 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d20beba8f66467dacfde6a0908ca6259b3623804ece94d4a257c789a15288ef4
|
|
| MD5 |
1e2b7eda5f9a0811eae69fbbcc279462
|
|
| BLAKE2b-256 |
a81e69b307651f1dc56254ca69c2529a8de3c89a80524038c6377bb5e173e5fa
|
File details
Details for the file pkglint-1.3.3-py3-none-manylinux_2_17_aarch64.whl.
File metadata
- Download URL: pkglint-1.3.3-py3-none-manylinux_2_17_aarch64.whl
- Upload date:
- Size: 3.9 MB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c972b92353567ddcf2c0811e4d4e6a55906f00b2eea9ff96afbae513d3ea0767
|
|
| MD5 |
205d5371468c4f22421d5a3fc7f6713b
|
|
| BLAKE2b-256 |
e71ef0445e06fb2019dc2d9e8d5cbc462139a3ad45ba2e6317b0d9dd991ea42a
|
File details
Details for the file pkglint-1.3.3-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: pkglint-1.3.3-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 4.1 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b7c91637a1d5cbc07783fed392bab1deb720bd001aa871d27e14491c7341f84d
|
|
| MD5 |
26bb26339a40a3d9cec6ce021b388f22
|
|
| BLAKE2b-256 |
a8160b754a923c280046256ffa378ef903f32621ae5a619e1579d56a156778f7
|
File details
Details for the file pkglint-1.3.3-py3-none-macosx_10_12_x86_64.whl.
File metadata
- Download URL: pkglint-1.3.3-py3-none-macosx_10_12_x86_64.whl
- Upload date:
- Size: 4.4 MB
- Tags: Python 3, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
uv/0.12.7 {"installer":{"name":"uv","version":"0.12.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cb31ea845c80751e61c9e1bf66e5ffff1ee6f5ec3e2a9e12e81928099171759b
|
|
| MD5 |
68a356f17514bf9b5c2e0ab64d64dc4d
|
|
| BLAKE2b-256 |
a8590e7c228e1f94a68a8a21fa20e9218d93550431435e932624e1d7e11dd444
|