Part C: The enforcement map¶
The broader project standards are in Standards; this page maps the security subset to the gates that prove it. Each row is one invariant, the gate that proves it, and where the behaviour lives. Run them all with:
scripts/security_gates.py returns exit code 1 when any gate fails (and 0 when all pass), so an exit code of 1 means a claim on this page has stopped being true. That is the same non-zero exit the CI job keys on to fail the build.
| Gate | Invariant | Implementation |
|---|---|---|
no interpreter or shell execution |
A1 | source-wide AST scan, with explicit UTF-8 reads |
version arguments are shape-checked |
A1 | discovery._VERSION_ARG_RE |
network confined to the fetch modules |
A2, A3 | discovery.py, fetcher.py, full_aur/fetch.py, full_aur/metadata.py, release.py |
declared source URLs are never fetched |
A2 | no raw transport in src/trustsight/analysis/, and every fetch helper it imports is name-keyed |
every JSON report carries the fingerprint |
B1 | schema.fact_to_dict, reporting.report_body |
suppression is never hidden by a flag |
B5 | suppressed_rules outside any verbosity branch in cli/review.py |
alert grouping is reported |
B5, B11 | analysis/meta.alert_volume_finding; W010 becomes a SATURATED boundary |
alert counts reconcile |
B5 | shown + grouped == rendered; the JSON is never filtered |
campaign state is instrument state |
B1 | the config fingerprint carries no campaign or observation state |
campaign expiry is structural |
A11 | an expired campaign is never returned by active_campaigns |
campaign re-activation is bounded |
B1 | a class re-arming faster than the interval is reported as M006, never blocked |
campaign points are attributed |
B1 | every campaign bonus is a G007 entry carrying campaign_id |
correlation feeds are cycle-scoped and bounded |
A14 | G001/G002/G003/G004 read per-cycle tables cleared at the _run_baseline_build entry point; the G003 relatedness pass is capped at MAX_RELATEDNESS_MEMBERS |
elevation is attributed |
B1/B10 | G006 is weight-0 INFO and names the campaign whose scope it touched |
escalation is one notch |
B10 | licensing moves a band one step, never more |
escalation never reaches fatal |
B4/B10 | no escalation or bonus path reaches FATAL |
escalation chains are rejected |
B10 | a rule that is itself licensed cannot license a third |
expired overrides are reported |
B5 | a lapsed override stops suppressing and its id is named |
corroborated suppression is terminal-visible |
B5 | a visible-only suppression renders in the terminal |
overrides never hide indicator corroboration |
B5 | a clear-indicator override cannot hide a match the run made |
the default output is not headline-shaped |
Guarantees | the default inspect render volunteers no score |
one network host, declared |
A3 | endpoint constants: aur.archlinux.org everywhere, api.github.com and github.com only in release.py |
every request has a timeout |
A4 | urlopen call sites |
every stream read is bounded |
A4, A14 | source-wide AST scan for a read() with no size, plus read_text/read_bytes in the document cache; doc_cache.py reads through read_file_capped under MAX_DOC_CACHE_BYTES |
artifact reads are bounded before verification |
A4 | db.py, ioc_baseline.py, seed_build.py, full_aur/export.py |
rule matching is bounded on hostile input |
A5 | rules.MAX_RULE_LINE_BYTES |
H-series intake is frozen |
internal | categories.RULE_CATEGORIES |
H-series reduction residue |
internal | categories.RULE_CATEGORIES, target 55-65 |
retired ids resolve to live survivors |
internal | rule_id_history.RETIRED_RULE_IDS |
docs layer mapping matches the registry |
internal | scripts/build_layers_doc.py, trustsight.layers |
layer detection and coverage are independent |
internal | trustsight.layers._layer_coverage |
every rule names its shared dependencies |
internal | trustsight.detectors, SERIES_DEPENDENCIES |
layer overrides stay within the closed set |
internal | trustsight.layers.parse_overrides, LAYER_VALUES |
boundaries are the coverage view |
B2 | trustsight.boundaries, coverage.GAPS |
every report body key is documented |
B11, report schema | reporting.REPORT_KEYS, docs/reference/report-schema.md |
transcendence table names real series |
internal | docs/assurance-layers/diversity.md, layers.SERIES_LAYER |
every rule carries exactly one layer |
internal | trustsight.layers.layer_of, categories.RULE_CATEGORIES |
boundaries forbid clean exactly when coverage gaps exist |
B2 | trustsight.boundaries, coverage_gaps |
the indicator tier mirrors ioc_matches |
B1 | reporting.report_body, PackageFact.ioc_matches |
the layer profile is gap-precise and additive |
internal | trustsight.layers.layer_profile |
differ hostile input is bounded |
A4b | differ parser limits and hostile extraction gate |
generated diff is bounded before assembly |
A4b, B2 | sandbox/git_project.generate_diff_bounded, MAX_DIFF_PATCHES, MAX_PATCH_BYTES |
companion reads are bounded before data |
A4b | sandbox/git_project.companion_source_hunks, MAX_PKG_BUILD_BYTES, MAX_COMPANION_TREE_ENTRIES; collect_tree_files stops the general tree walk at GIT_TREE_MAX_ENTRIES during the walk |
differ output is deterministic |
Guarantees | sorted differ summaries and URL extraction |
API inputs are bounded before initialization |
A4c | trustsight.api input validators |
expansion is bounded and never indirect |
A6 | tokenizer.py and the shared expansion.py, including the parent's whole-PKGBUILD resolver; globs use the bounded table matcher (_MAX_GLOB_PATTERN_LEN, _MAX_GLOB_STEPS) |
tokenizer hostile-input smoke is deterministic |
A6, A14 | tokenizer.py and fixed hostile-input smoke cases |
tokenizer module is isolated |
A6 | no src/ module imports _tokenizer_engine except sandbox/expand_worker.py |
tokenizer worker startup is isolated |
A6 | the worker launches -I with no PYTHON* environment, so no sitecustomize runs at startup |
git worker startup is isolated |
A16 | the git worker launches -I with a minimal environment, so no sitecustomize runs at startup |
git worker limit failures are reported |
A16 | sandbox/git_worker._apply_limits returns every protection that could not be set; a lost RLIMIT_AS/RLIMIT_CPU/PR_SET_NO_NEW_PRIVS refuses the worker, and _harden_libgit2 plus secondary limits surface as degraded; this is a resource-bounded subprocess boundary, not an OS sandbox (no separate UID, network namespace or seccomp) |
every cited invariant is defined |
model parity | every A<n> cited by this map has a matching paragraph in docs/security/program-under-attack.md |
tokenizer worker limit failures are reported |
A6 | sandbox/expand_worker._apply_limits returns every limit that could not be set; secondary limits attach degraded to every response and the client logs them rather than inferring protection from silence |
sandbox core limit failure refuses the package |
A6, A16, B2 | sandbox/_spawn.core_sandbox_failures names the fatal set; both workers exit before serving when RLIMIT_AS, RLIMIT_CPU or PR_SET_NO_NEW_PRIVS failed, so the client refuses and the package is NOT vetted |
every child process gets an explicit environment |
A6 | every subprocess call in src/ passes env=minimal_env(), so no inherited startup-hook or loader-injection variable (PYTHONPATH, LD_PRELOAD, ...) reaches a child |
tokenizer pool waits are bounded |
A6, A14 | sandbox/client.py refuses a saturated pool at a finite deadline rather than blocking forever |
tokenizer request writes are bounded |
A4, A14 | sandbox/protocol.write_frame writes under a select deadline, so a child that stops reading stdin cannot hold the parent in a blocking write; both sandbox clients also refuse a frame past MAX_FRAME_BYTES before writing it |
sandbox responses are capped before framing |
A4, A14 | both workers serialise through protocol.encode_response, which stops at protocol.MAX_RESPONSE_BYTES during iterencode and frames only a body that fits; the parent's response-read cap remains a backstop |
a dead tokenizer child fails the package |
A6, B2 | sandbox/client.py raises TokenizerUnavailable; the batch runner reports the package as NOT vetted |
regex patterns pass adversarial audit |
A5, A14 | configured rules, source re.compile literals, inline re.match/re.search/... literals (anchored calls measured with the method they use), assembled patterns, and config-derived fragments; the shipped defaults are audited, not a stale local patterns.toml |
untrusted text is sanitised where it is rendered |
A1, B7 | every CLI render path: safe_text.clean rather than the weaker unicode.strip_ansi, and values wrapped rather than passed to Rich as bare strings |
every live regex is audited |
A5, A14 | every compiled pattern reachable from an imported module, patterns assembled from parts, inline re.* literal calls, and generated but not module-level patterns |
report rendering is data-driven |
A7 | verdict.py, findings.py |
no path-based archive extraction |
A8 | full_aur/fetch.py, db._extract_v2_archive |
release artifacts and tags use the verified commit |
release consistency | .github/workflows/publishing.yml builds from the checked-out target and refuses a tag that points elsewhere; scripts/verify_release.py rebuilds HEAD |
SQL is parameterised |
A9 | db.py |
terminal output is inert |
A10 | safe_text.py, cli/, the full_aur progress log sink, and the CLI _StderrHandler (message and traceback text) |
freshness uses local marker |
A11 | sandbox/git_project._is_current, sandbox/git_project.last_fetch_time |
a seed cannot rewrite the database |
A12 | db.import_seed |
hashed maintainers protect privacy |
P1 | db.maintainers_hashed, seed_meta.salt |
the seed hash is deterministic |
P1 | seed_build._hash_value |
an IOC match carries its source |
A13b | ioc_baseline.IocMatch.source, analysis/ioc_match.py |
IOC matches never contribute to the score |
B1 | PackageFact.ioc_matches separate from score_breakdown |
an expired IOC is never silent |
IOC expiration | ioc_baseline.active_iocs, cli/ioc.py [EXPIRED] label |
IOCs are not in the rule config layer |
config separation | no ioc table in rules.toml, patterns.toml, thresholds.toml |
a pattern IOC that risks ReDoS is refused |
A13b pattern safety | iocs.compile_ioc_pattern static nested-quantifier, quantified-alternation, and multi-atom repeating-group ((\w+\s?)+) checks |
reserved names are refused by every writer |
A12, A13 | db.upsert_package, db.save_package_profile, db.save_pkgbuild_snapshot |
a baseline supplies state, not rules |
A13 | full_aur/export.import_baseline |
incomplete coverage fails closed |
B2 | coverage.fail_closed |
a truncated diff cannot read as unflagged |
B2 | analysis/pipeline.py, full_aur/analyze.py |
an unpinned build dependency is a declared gap |
B2, A14 | analysis/buildfetch.py, coverage.UNPINNED_BUILD_DEPS |
a coverage gap is always shown with the band |
B2 | coverage.qualified_band, scoring.verdict_label |
every result declares its coverage |
B2 | every PackageFact(...) construction |
a result reports what changed |
B7 | changes on every PackageFact |
change entries carry no severity |
B7 | changes is a list of plain strings |
content findings carry a location |
B8 | findings.NON_CONTENT_RULES |
no template grants permission to skip |
B9 | denylist over verdict.py, findings.py |
positive evidence never changes the score |
B10 | every P finding is INFO, weight 0 |
every render reports the same information |
B11, B2, B5, B7 | all four renderers in cli/review.py, cli/inspect.py |
the API and CLI emit the same JSON body |
B11 | reporting.report_body, REPORT_KEYS |
the score is withheld from every default body |
B11, Guarantees | reporting.SCORE_KEYS, Report.to_dict |
the API and CLI share one analysis |
B11 | api.py imports the CLI's analysis entry points |
positive evidence cannot lower a FATAL |
B10, B4 | maximal declared evidence plus one FATAL |
declared findings fire under the shipped config |
B10 | every P finding reachable with the config that ships |
a critical finding never reads medium |
B4, bands | scoring.CRITICAL_BAND_FLOOR, calculate_score |
a fatal finding names itself in the label |
B4, bands | scoring.verdict_label, _fatal_label |
the flag threshold is derived, not copied |
B2 | scoring.FLAG_THRESHOLD |
the maturity numbers are derived, not copied |
B3 | scoring._MATURITY_THRESHOLD |
FATAL rules cannot be switched off |
B4 | config.enforce_fatal_rules |
FATAL findings survive every override |
B4, B5 | override.filter_triggered_rules |
doc cross-references resolve |
this page, and every page linking to it | every docs/** link and anchor |
the score is deterministic under a fixed fingerprint |
B1 | config.config_fingerprint, two-run comparison |
every input bound is a source constant |
A14 | bound constants are module-level literals |
every result render ends with a direction to review |
B9 | verdict.DIRECTIONS, structural |
no git filters or hooks are configured |
A3 | clone configuration |
libgit2 stays behind the git sandbox |
A16 | only sandbox/git_project.py and sandbox/git_worker.py import pygit2; the parent reaches every git operation through the isolated worker |
docs/security.md matches the gates |
this page | the table above |
CI installs from the lock |
the CI assumption | uv sync --locked in every workflow, uv.lock |
critical paths are synchronised |
CODEOWNERS, signature workflow and contributor policy |
canonical scripts/critical_paths.py |
an audit does not write history |
A15 | connection opened mode=ro when --record is absent; behavioural, asserted by running the path against a fixture DB and diffing it |
the history walk is bounded |
A14 | sandbox/git_project.walk_bounded is the only walker; differ.MAX_HISTORY_COMMITS, fetcher.MAX_HISTORY_DIFFS |
run diff assembly is bounded |
A14, B2 | fetcher.MAX_RUN_DIFF_BYTES charged across results |
a truncated history walk is a declared gap |
B2 | coverage.HISTORY_TRUNCATED set on every early stop |
every history diff is scored independently |
B1 | no aggregate score reachable from the --last path |
manifest parses are bounded and refusing |
A4b/A5 | hostile TOML/JSON/line fixtures return a refusal, never a hang |
manifest table is configuration |
A6 | the manifest filename table is written to patterns.toml |
U003 joins the host view |
B11 | a manifest-introduced git host appears in hosts_gained |
manifest ledger stays weight 0 |
B10 | U001/U002/U005/U006 are INFO and U009 is a feed-only LOW; all weigh 0, and only U003/U004 score |
archive listing adds no extraction |
A8 | the manifest layer reads committed files only |
yaml is only ever safe_load |
A5 | package-controlled YAML is parsed with yaml.safe_load only |
libgit2 is not the vulnerable version |
A14 | the pygit2 pin bundles libgit2 >= 1.9.5 (delta result-size allocation fix, CVE-2026-53585), read from the runtime version |
How each gate is scoped, and the recurring mistake that lets one pass while its invariant is broken, is set out in reviewing a security control. Read it before adding an invariant or a gate.
The last row is the one that keeps the rest honest: a gate with no entry here is an unstated guarantee, and an entry with no gate is an unsupported promise. Both fail the build.
Detection calibration is enforced separately by scripts/calibration_gates.py; see fire rates. The taxonomy and the adversarial thread of this model are developed at three depths: evidence tiers describes the signals; what TrustSight cannot see describes the limits; this page describes the whole.
The security and regex audits read repository text as UTF-8. Security-gate path allowlists use forward slashes on every platform. These checks do not establish that Linux sandbox controls are available on Windows.