From 8216072ba90653f51fc30d03df4ff7f3d120db1f Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 06:21:09 -0400 Subject: [PATCH 1/2] feat(s132): the control mapping, held to the tree and to NIST's catalogue Slice 132, stacked on slice 130 (PR #117), whose image it maps. docs/regulated/control-mapping.md maps Trinity's mechanisms to NIST SP 800-53 Rev 5 and SP 800-218. It opens by saying what it is not: an assessment, an ATO, an SSP, or a claim that any baseline is met. - SP 800-53: 65 rows across 45 controls; 25 Trinity's, 25 shared and 15 the deployment's. - SP 800-218: 13 SSDF task rows. - Every family and practice with no row is listed with its reason. test/control_mapping_test.exs holds the document to the tree: - every row's path, test, Mix task or function must exist; - every identifier must be active in NIST's OSCAL catalogues, pinned by commit and SHA-256 in usnistgov/oscal-content, CC0; - a row's ownership must match the responsibility matrix and the standards register; - the opening's disclaimers and the forbidden words are checked; - every regulated refusal, census test and gate step is cited or listed with a reason. Each rule has a planted violation; R1 to R7 are in the slice NOTES. The matrix was stale, and the first run found it: it still said the model allow-list was NOT IN TREE, which the regulated profile has enforced since regulated-boot. That row is corrected. Nine rows are added for the regulated profile, transport and access, and the container image, including two the pack had not stated: - connections to Trinity are plain HTTP; - the web pages, among them the permissions page where approvals are answered, carry no authentication, while the image binds every interface. Signed-off-by: Ayla Croft --- CHANGELOG.md | 19 + LICENSES/CC0-1.0.txt | 121 ++ README.md | 1 + REUSE.toml | 11 + docs/regulated/README.md | 6 +- docs/regulated/control-mapping.md | 227 +++ .../customer-responsibility-matrix.md | 32 +- docs/regulated/data-flow.md | 9 +- scripts/nist_catalogues.exs | 181 +++ test/control_mapping_test.exs | 384 ++++++ test/support/control_mapping.ex | 733 ++++++++++ test/support/fixtures/nist/sp800-218.tsv | 72 + test/support/fixtures/nist/sp800-53r5.tsv | 1223 +++++++++++++++++ 13 files changed, 3013 insertions(+), 6 deletions(-) create mode 100644 LICENSES/CC0-1.0.txt create mode 100644 docs/regulated/control-mapping.md create mode 100644 scripts/nist_catalogues.exs create mode 100644 test/control_mapping_test.exs create mode 100644 test/support/control_mapping.ex create mode 100644 test/support/fixtures/nist/sp800-218.tsv create mode 100644 test/support/fixtures/nist/sp800-53r5.tsv diff --git a/CHANGELOG.md b/CHANGELOG.md index 8215e47..34f4e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,25 @@ evidence for each increment is retained by the maintainers and summarised here. ## 2026-10-08 +### `slice/132`: a control mapping an assessor can check against the tree + +The regulated pack explained the boundary, the data flow, the cryptography and who owns what, and +named not one control. `docs/regulated/control-mapping.md` now states which NIST SP 800-53 Rev. 5 +controls and SP 800-218 practices each mechanism in this tree contributes to, where the mechanism +lives, what proves it, and whether the rest of the control is Trinity's, shared, or the +deployment's. It opens with what it is not: not an assessment, not an authorization to operate, not +a System Security Plan, and not a claim that any baseline is met. + +The mapping is checked rather than maintained. A test fails when a row names a file that has moved, +a test or function that no longer exists, or a control identifier that NIST's own catalogue, pinned +by commit and digest, does not have; when a row's ownership disagrees with the customer +responsibility matrix or its status with the standards register; and when a refusal of the regulated +profile, a census, a step of the gate, a row of the matrix or a control family is left unmapped. The +matrix was brought up to date first. It still said nothing refused an unapproved model, which the +regulated profile has done since it landed, and it now also covers the gateway allow-list, the +external authority, transport, the web pages' lack of their own authentication, and the container +image. + ### `slice/130`: the headless image, hardened, and checked as built The headless container is what a government program would actually run, and it was built to run diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/README.md b/README.md index f53a544..3f53789 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,7 @@ do over the next year, and why the order is what it is. | `docs/adr/` | Architecture decision records. One is added whenever a decision changes | | `docs/10-assurance-case.md` | The structured argument that the security claims hold, with the evidence for each and the assumptions and limits named | | `docs/09-standards-register.md` | One row per control a regulated deployment may ask about, with its evidence path and status | +| `docs/regulated/` | The pack for a regulated deployment: boundary, data flow, responsibility matrix, cryptographic inventory, the hardened image and its STIG statement, and `control-mapping.md`, which maps each mechanism to the NIST SP 800-53 controls and SP 800-218 practices it contributes to and says whose the rest of each control is. The mapping is not an assessment, and a test fails when a path, check or control identifier it names stops existing | | `lib/`, `test/`, `config/` | The application | | `src-tauri/` | The native desktop shell | | `scripts/`, `credo_checks/` | The release check, the benchmark scripts and this project's own Credo checks | diff --git a/REUSE.toml b/REUSE.toml index f47e0a1..968b18e 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -56,3 +56,14 @@ path = ["lib/trinity_web/components/layouts/root.html.heex"] precedence = "aggregate" SPDX-FileCopyrightText = "Sudo Apt Holdings LLC" SPDX-License-Identifier = "Apache-2.0" + +# Slice 132. **Not ours either.** The identifier lists the control mapping is checked against are +# derived from NIST's OSCAL catalogues (`usnistgov/oscal-content`), which that repository dedicates +# to the public domain under CC0 1.0; each file's header names the commit and the SHA-256 of the +# catalogue it came from, and `scripts/nist_catalogues.exs` re-derives them. The `**/*.tsv` line +# above would otherwise assign them to this project. +[[annotations]] +path = ["test/support/fixtures/nist/**"] +precedence = "override" +SPDX-FileCopyrightText = "National Institute of Standards and Technology" +SPDX-License-Identifier = "CC0-1.0" diff --git a/docs/regulated/README.md b/docs/regulated/README.md index ba45354..6a4dee9 100644 --- a/docs/regulated/README.md +++ b/docs/regulated/README.md @@ -65,12 +65,14 @@ model prompt and what may not. Read both before the others. | `crypto-inventory.md` | Every cryptographic capability, its module, and its certificate status | | `headless-image.md` | How the container image is built and hardened, and the checks run against it | | `stig-applicability.md` | The image's STIG applicability statement, generated from OpenSCAP's evaluation of it | +| `control-mapping.md` | Which NIST SP 800-53 controls and SP 800-218 practices each mechanism contributes to, and whose the rest of each control is | ## Status of this pack This directory is documentation only. It adds no code, changes no behaviour, and asserts no control that is not already in the tree. `stig-applicability.md` is generated rather than written, and says -how. Where the tree does not implement something this pack names, the -row says **NOT IN TREE** rather than describing an intention. +how. `control-mapping.md` is held to the tree by `test/control_mapping_test.exs`, which fails when a +path, a check or a control identifier it names stops existing. Where the tree does not implement +something this pack names, the row says **NOT IN TREE** rather than describing an intention. This pack documents the gap; it does not close it. diff --git a/docs/regulated/control-mapping.md b/docs/regulated/control-mapping.md new file mode 100644 index 0000000..7429399 --- /dev/null +++ b/docs/regulated/control-mapping.md @@ -0,0 +1,227 @@ + + +# Control mapping: NIST SP 800-53 Rev. 5 and SP 800-218 + +## What this mapping is not + +This mapping is **not an assessment**. Nobody has assessed Trinity against SP 800-53 or +SP 800-218, and no row records an assessor's finding. + +It is **not an authorization to operate (ATO)**, and it is not evidence that one exists or would be +granted. An ATO is an authorizing official's decision about a deployment, and this repository is +software, not a deployment. + +It is **not a System Security Plan**. A System Security Plan describes one system: its boundary, +its baseline, its impact level and how each selected control is implemented there. That is the +deployment's document about its own system, and this one can be read beside it, not instead of it. + +It is **not a claim that any baseline is met**, at any impact level, under any overlay. A row says +that a mechanism in this tree **contributes to** a control. Whether the control is satisfied is an +assessor's finding about a deployment, against a baseline that deployment chose, at an impact level +this tree cannot know. Nothing here claims compliance, certification or accreditation of any kind. + +What it is: for each mechanism this tree has, the SP 800-53 control or SP 800-218 practice it +contributes to, where it lives, what in the tree proves it, and whose the rest of the control is. + +## How to read a row + +| Column | What it holds | +|---|---| +| Control | An SP 800-53 Rev. 5 control or enhancement, or an SP 800-218 practice or task, exactly as NIST prints it | +| Mechanism | What this tree does, in one sentence a reader can check against the path | +| Path | Where the mechanism lives in this repository | +| Check | The test, Mix task, function or script that proves it, or `none` | +| Ownership | **Trinity's**, **shared** or **the deployment's** | +| The deployment's part | What the deployment supplies that this tree cannot, or `none` | +| Authority | The responsibility matrix row or standards register row the ownership rests on, or `none` | + +**Ownership** has three values and no others. + +- **Trinity's**: the mechanism is in this tree, a check in this tree names it, and the row needs + nothing from the deployment beyond running the software. +- **shared**: the mechanism is in this tree, and the control also needs something only the + deployment can supply, which the row names. A check is named where the tree has one. +- **the deployment's**: nothing in this tree implements it. The row names the path that says so. + +A control appearing in several rows is several mechanisms contributing to it; no single row is the +whole of a control. + +**The authority column.** `customer-responsibility-matrix.md` is the authority for ownership, and +this document does not hold a second opinion: where a row cites a matrix row, its ownership is the +one that matrix row implies, and every row that gives the deployment a part cites the matrix row, +or a register row recording a real-world dependency, that says so. Where a row cites +`docs/09-standards-register.md`, it repeats the register's status for that row and claims no more: +`:unknown` stays `:unknown`, and a row resting on a real-world dependency or on something the +register does not claim is never Trinity's. + +## SP 800-53 Rev. 5: the mapping + +| Control | Mechanism | Path | Check | Ownership | The deployment's part | Authority | +|---|---|---|---|---|---|---| +| `AC-2` | Trinity keeps no accounts for a deployment's users: under the production profile it is a resource server to the deployment's identity provider | `lib/trinity/mcp/auth.ex` | none | the deployment's | Account management, at the identity provider | CRM "Operating the IdP" | +| `AC-3` | An effect runs only through one boundary, which re-derives the approval's fingerprint from the arguments actually passed and refuses a mismatch; a census over the tree finds no other caller of a tool's `execute/2` | `lib/trinity/effects.ex`, `lib/trinity/permissions/fingerprint.ex` | `test/trinity/effects/membrane_test.exs` "AC4: arguments mutated after the decision are denied at execution with a receipt (M2 re-verify)"; `test/trinity/effects/census_test.exs` "the callers of execute/2 on a tool module are the two allowed and the one planted" | Trinity's | none | CRM "Refusing an effect that was not approved" | +| `AC-3` | A token's scope narrows which tools a caller may reach, and a scoped call still goes to the permission gate: identity answers who, never whether | `lib/trinity/mcp/auth/scopes.ex` | `test/trinity/mcp/auth/resource_server_test.exs` "AC4 and the receipts: read scope refused on an artifact tool, artifact scope reaches the gate, receipts name the principal, no token material" | shared | Which subject gets which scope, at the identity provider | CRM "Mapping a subject to an authorization decision"; Register "Identity is not authority": tree property | +| `AC-3` | The decision path never reads session or conversation state, so text in a conversation cannot argue with the gate; a census over the decision path holds it | `lib/trinity/permissions/`, `lib/trinity/authority/` | `test/trinity/permissions/session_state_census_test.exs` "only the planted reader reads session state from the decision path" | Trinity's | none | none | +| `AC-3` | Context only tightens a decision: no state, alone or combined, produces a result weaker than the decision without it | `lib/trinity/permissions/policy/` | `test/trinity/permissions/policy/state_test.exs` "no decision and no set of states produces a result weaker than the decision alone" | Trinity's | none | none | +| `AC-3` | Nothing the rule learner produces reaches a pending approval | `lib/trinity/permissions/learner.ex` | `test/trinity/permissions/learner_census_test.exs` "only the planted file lets the learner near a pending approval" | Trinity's | none | none | +| `AC-3` | An approval arriving on a chat channel may authorize at most a read, network or write effect; exec and destructive requests are refused by the channel's cap, whatever the profile | `lib/trinity/gateways/cap.ex` | `test/trinity/gateways/approvals_test.exs` "AC8: a destructive request is refused by the cap, receipted, and left for the desktop" | Trinity's | none | none | +| `AC-4` | Under `TRINITY_PROFILE=regulated` the node refuses to boot when a configured model's endpoint is not on `TRINITY_REGULATED_LLM_ENDPOINTS`, or states none. The check runs once, at boot, over the configured models | `lib/trinity/profile.ex`, `lib/trinity/application.ex` | `Trinity.Profile.check_llm_endpoints/3`; `test/trinity/regulated_boot_node_test.exs` "production mcp_auth and a non-Local authority, but no allow-list, does not start"; `test/trinity/profile_test.exs` "a model off the list is refused, naming the model and the endpoint" | shared | The allow-list itself, and the egress filtering that stops anything else leaving | CRM "Enforcing that only an approved model may be called" | +| `AC-4` | Under `TRINITY_PROFILE=regulated` every configured gateway adapter must be named in `TRINITY_REGULATED_GATEWAYS`, or the node refuses to boot | `lib/trinity/profile.ex` | `Trinity.Profile.check_gateways/3`; `test/trinity/regulated_boot_node_test.exs` "a configured adapter refuses without the allow-list and boots with it" | shared | Naming the channels, and authorizing each one | CRM "Which chat gateways may carry conversation text" | +| `AC-4` | Nothing in the tree decides what may leave the boundary; `data-flow.md` states what enters a prompt | `docs/regulated/data-flow.md` | none | the deployment's | The information flow policy, and the egress controls that enforce it | CRM "Deciding what may leave the boundary at all" | +| `AC-5` | Under `TRINITY_PROFILE=regulated` the node refuses to boot on the local authority, where one process would decide, act and record; an external adapter that changes the arguments it decided on is denied | `lib/trinity/profile.ex`, `lib/trinity/authority.ex`, `lib/trinity/effects.ex` | `Trinity.Profile.check_authority/2`; `test/trinity/regulated_boot_node_test.exs` "everything else correct, but TRINITY_AUTHORITY unset, does not start"; `test/trinity/effects/membrane_test.exs` "an adapter that rewrites the arguments in stage/2 is denied, with the fingerprint re-derived after stage" | shared | The external authority adapter. None ships in this tree, and it is assessed with the deployment | CRM "Who decides whether an effect may happen" | +| `AC-6` | The image runs as UID 10001, not root, which is checked on the built image; it writes only `/data` and `/tmp` | `ci/ironbank/Dockerfile`, `lib/mix/tasks/trinity.image.inspect.ex` | `mix trinity.image.inspect`; `test/mix/tasks/trinity_image_inspect_test.exs` "no user, root, UID 0 and a UID below 1000 are refused" | shared | A read-only root filesystem, dropped capabilities and no privilege escalation, set by the runtime that starts the container | CRM "Running the image read-only, with no capabilities and no privilege escalation" | +| `AC-16` | Content from outside the machine is marked untrusted, with its digest, and reaches the model only inside an untrusted block | `lib/trinity/tools/untrusted.ex` | `test/trinity/tools/provenance_test.exs` "the assistant's summary of a fetched page carries taint untrusted; the user's message stays trusted" | Trinity's | none | none | +| `AU-2` | The events recorded are a closed vocabulary of receipt kinds: decision, effect, query, boot and cap | `lib/trinity/receipts/receipt.ex`, `docs/receipt-scheme-mapping.md` | `test/trinity/receipts/scheme_mapping_test.exs` "AC3: the kinds the document names are exactly Receipt.kinds/0" | Trinity's | none | none | +| `AU-3` | Every receipt signs one field set: its kind, its subject, the decision, the fingerprint, the time, the key, and its place in the chain | `lib/trinity/receipts/chain_writer.ex`, `docs/receipt-scheme-mapping.md` | `test/trinity/receipts/scheme_mapping_test.exs` "AC3: every signed field has a row in the mapping table, and every row names a signed field" | Trinity's | none | none | +| `AU-4(1)` | Trinity emits telemetry and keeps its receipts locally; it forwards logs nowhere | `docs/telemetry.md` | none | the deployment's | Shipping logs and metrics to storage off the host | CRM "Shipping logs or metrics anywhere" | +| `AU-5` | When the signer is unavailable an alarm is raised and every effect is denied until a signer returns; under `TRINITY_PROFILE=regulated` the node does not boot at all | `lib/trinity/receipts/alarm.ex`, `lib/trinity/profile.ex` | `test/trinity/effects/membrane_test.exs` "AC5: the signing key removed mid-run: the next effect is denied, the alarm sounds, no unsigned receipt row exists; a read is refused too, because its decision cannot be receipted"; `Trinity.Profile.check_receipts/2`; `test/trinity/regulated_boot_node_test.exs` "case 4's configuration with case 1's broken key store does not start" | shared | Watching for the alarm, and alerting someone | CRM "Raising an alarm when the signer is unavailable" | +| `AU-8` | Each receipt carries a signed hybrid logical clock that never goes backwards within a chain; its reading is the host's own clock | `lib/trinity/receipts/clock.ex` | `test/trinity/receipts/clock_test.exs` "a wall clock that went BACKWARDS still produces a greater clock" | shared | A trusted time source, where the time itself must be authoritative | Register "Trusted time for receipts": real-world dependency | +| `AU-9(3)` | Receipts are hash-linked and signed before the row is written, and a verifier that does not need the application finds a gap, a wrong link, an altered byte or a forged signature | `lib/trinity/receipts/verifier.ex`, `bin/verify_receipt.exs` | `test/trinity/receipts/verifier_test.exs` "AC3: a chain with a gap, a wrong prev_hash, or a forged signature is 1"; `test/trinity/receipts/standalone_verifier_test.exs` "AC7: a stranger's run from an empty directory: verified is 0; the outcomes carry their codes" | Trinity's | none | CRM "Receipt signing key generation and use" | +| `AU-10` | Effect, decision, boot and cap receipts are signed one by one, and query receipts are covered by signed checkpoints. Standalone, one process decides, acts and signs, so a signature binds a record to a key, not to an independent party | `lib/trinity/receipts/chain_writer.ex`, `lib/trinity/receipts/key_custody.ex` | `test/trinity/receipts/chain_writer_test.exs` "rows chain: gapless seq, each prev_hash the previous receipt_hash, signed kinds verify, query rows unsigned"; `test/trinity/receipts/chain_writer_test.exs` "after N query receipts a checkpoint names the tail and its coverage; its signature verifies" | shared | An external authority, so that the party that decides is not the party that records, and custody of the signing key | CRM "Who decides whether an effect may happen"; Register "NIST SP 800-53 AU family: non-repudiation of every governed act (AU-10)": :unknown | +| `AU-11` | The chain writer is the only code that creates or removes a receipt row | `lib/trinity/receipts/chain_writer.ex` | `test/trinity/receipts/chain_writer_test.exs` "the census: ChainWriter is the only inserter into receipts; the planted bypass is named" | shared | How long the receipts store is kept | CRM "Receipt chain retention" | +| `AU-11` | There is no records schedule and no legal hold in the tree | `docs/regulated/customer-responsibility-matrix.md` | none | the deployment's | A records schedule, and legal hold | CRM "A records schedule, or legal hold" | +| `AU-12` | Every gate decision and every effect yields a receipt: a read gives a decision and a query receipt, an effect a decision, an admit and a done | `lib/trinity/effects.ex`, `lib/trinity/receipts/chain_writer.ex` | `test/trinity/effects/membrane_test.exs` "AC3: every gate decision and every effect yields a receipt: a read gives decision and query; an effect gives decision, admit and done" | Trinity's | none | none | +| `CM-5` | A skill change applies through one path only, behind an approval: a census finds no second caller of the swap, and the swap refuses without an approval | `lib/trinity/skills/promotion.ex`, `lib/trinity/skills/manager.ex` | `test/trinity/skills/census_test.exs` "the callers of Promotion.swap/3 are the manager and the plant"; `test/trinity/skills/staging_test.exs` "AC8: swap/3 refuses without an approval, with a pending, denied, other-tool or other-change approval, and when the staged files changed" | Trinity's | none | none | +| `CM-6` | Three MCP authorization profiles exist, and under `TRINITY_PROFILE=regulated` only the production profile boots; an unrecognised `TRINITY_PROFILE` raises rather than falling back to the default | `config/runtime.exs`, `lib/trinity/profile.ex` | `Trinity.Profile.check_mcp_auth/2`; `test/trinity/regulated_boot_node_test.exs` "personal and local are both refused, where case 4's configuration is otherwise met"; `test/trinity/profile_test.exs` "an unrecognised value raises rather than falling back to default" | shared | Setting both profiles, and the issuer, audience and resource | CRM "Choosing the profile" | +| `CM-6` | The image's STIG applicability statement is generated from OpenSCAP's evaluation of the built image, and every rule the DISA STIG profile selects has a disposition | `docs/regulated/stig-applicability.md`, `ci/headless/stig_dispositions.yaml` | `mix trinity.image.stig`; `test/mix/tasks/trinity_image_stig_test.exs` "a rule with no disposition fails the check (planted red), and passes once it has one" | shared | The rules the statement dispositions as the deployment's, and the STIG of the host that runs the image | CRM "STIG rules for the image" | +| `CM-7` | The effects the agent can cause are a closed catalogue: only a module attribute admits a catalogue tool, and the published catalogue must match the tree or the release check fails | `lib/trinity/tools/catalog.ex`, `docs/effects-catalog.md` | `test/trinity/tools/catalog_census_test.exs` "every :catalog tool in the tree is in the attribute, and the attribute names only :catalog tools"; `mix trinity.effects.catalog --check` | Trinity's | none | none | +| `CM-7` | The image carries no package manager, compiler, shell history, documentation, SUID file or key material beyond what is allowed by name; distribution is off and no cookie is baked in. Checked on the built image | `lib/mix/tasks/trinity.image.inspect.ex`, `ci/headless/image_allowances.yaml` | `mix trinity.image.inspect`; `test/mix/tasks/trinity_image_inspect_test.exs` "distribution not turned off is refused" | Trinity's | none | CRM "What the image contains" | +| `CP-9` | `mix trinity.export` writes a portable archive of the whole state, with a manifest of digests | `lib/mix/tasks/trinity.export.ex`, `docs/backup.md` | `test/trinity/archive/round_trip_test.exs` "AC1 and AC2: export, import into an empty directory, the rows and the chain come back, the tree's digests match the manifest" | shared | Running it | CRM "Producing a portable archive" | +| `CP-9` | Nothing schedules a backup, copies one offsite or keeps one for a period | `docs/backup.md` | none | the deployment's | Scheduling, offsite copies and retention | CRM "Scheduling, offsite copies, retention of backups" | +| `CP-9(8)` | An export's blobs are sealed with AES-256-GCM, a fresh data key each, when exports are sealed | `lib/trinity/vault.ex`, `docs/encryption-at-rest.md` | `test/trinity/vault_test.exs` "an export manifest is ciphertext on disk when exports are sealed" | shared | Where the archive is stored, and its protection there | CRM "Encrypting the archive at rest" | +| `CP-10` | `mix trinity.import` restores onto a fresh install, and refuses a tampered archive before anything is written | `lib/mix/tasks/trinity.import.ex` | `test/trinity/archive/round_trip_test.exs` "AC4: a tampered archive fails digest verification before anything is written" | shared | Running a restore, and testing that it works | CRM "Restoring onto a fresh install" | +| `IA-2` | Trinity is never the identity provider for a deployment's users; it validates the tokens the deployment's provider issues | `lib/trinity/mcp/auth.ex` | none | the deployment's | Operating the identity provider, and authenticating users there | CRM "Operating the IdP" | +| `IA-2` | An inbound MCP request is refused unless it carries a token the configured issuer signed for this resource: a wrong audience, an expired token and the static bearer are refused, each with a receipt | `lib/trinity/mcp/auth.ex`, `lib/trinity/mcp/auth/token.ex` | `test/trinity/mcp/auth/resource_server_test.exs` "AC1: 401 with resource_metadata; the PRM names the AS; wrong audience, expired and the static bearer are 401; each refusal receipted"; `test/trinity/mcp/auth/token_test.exs` "each claim check is its own refusal" | shared | The issuer, audience and resource Trinity is configured to accept | CRM "Validating a bearer token on an inbound MCP request" | +| `IA-2` | Under the production profile Trinity mints no token and holds no issuer key; the personal issuer refuses to start under an external authority, and under `TRINITY_PROFILE=regulated` | `lib/trinity/mcp/auth/embedded.ex`, `lib/trinity/profile.ex` | `Trinity.Profile.check_embedded_as/2`; `test/trinity/mcp/auth/embedded_test.exs` "the production profile holds no key material of its own: no keys, no JWKS, no AS metadata"; `test/trinity/mcp/auth/embedded_test.exs` "the personal profile refuses to start under an external authority adapter" | shared | The identity provider that does issue the tokens | CRM "Choosing the profile"; Register "Trinity issues no production authority": tree property | +| `IA-2` | The web pages, among them the permissions page where approvals are answered, carry no authentication of their own; the bearer is required on `/mcp` only, and the image binds every interface | `lib/trinity_web/router.ex`, `docs/mcp-server.md`, `ci/ironbank/Dockerfile` | none | the deployment's | An authenticating reverse proxy in front of the port, or a network that only the owner reaches | CRM "Authenticating access to the web pages" | +| `IA-5(7)` | The gate fails on a credential-shaped string anywhere in the tracked tree | `lib/mix/tasks/trinity.secrets.scan.ex` | `mix trinity.secrets.scan`; `test/secrets_scan_test.exs` "RED: a planted fake key of each shape is found" | Trinity's | none | none | +| `IA-11` | An expired token is refused, and with introspection enabled the issuer is asked about each token; a client then goes back to the identity provider | `lib/trinity/mcp/auth/token.ex`, `config/runtime.exs` | `test/trinity/mcp/auth/token_test.exs` "introspection: an opaque token is asked about at the issuer (RFC 7662), the answer's claims checked as a JWT's are" | shared | Token lifetime, revocation and re-authentication policy, at the identity provider | CRM "Token revocation and session lifetime" | +| `IR-6` | Trinity reports nothing to anyone; `incident.md` sets out the clocks that may apply and what the record gives an investigator | `docs/regulated/incident.md` | none | the deployment's | Reporting, within whatever clock applies | CRM "Reporting to a government customer within a clock" | +| `RA-5` | The gate audits every locked dependency on every push; the built image is scanned with grype, and a High or Critical finding without a justification fails. **The image's scan fails today**: OTP findings fixed in a later OTP release have no justification until the toolchain moves | `lib/mix/tasks/trinity.image.findings.ex`, `ci/headless/justifications.yaml` | `mix deps.audit`; `mix trinity.image.findings`; `test/mix/tasks/trinity_image_findings_test.exs` "AC3: a planted High finding with no row fails, and passes once justified" | shared | Scanning what is actually deployed, on the deployment's schedule | CRM "Scanning for known vulnerabilities" | +| `RA-5(11)` | A published process for reporting a vulnerability in this software. It is a document, and no check in the tree holds it | `SECURITY.md` | none | shared | Reporting to it | CRM "A published vulnerability disclosure process for this software" | +| `SA-4` | Nothing in the tree is a contract with a model provider | `config/llm.exs` | none | the deployment's | Whether the provider may train on or keep the data, settled in the contract | CRM "Whether the model provider trains on your data" | +| `SA-9` | The model in force is configuration: Trinity calls the endpoint it is pointed at, and under the regulated profile only one on the allow-list (the `AC-4` row above) | `config/llm.exs` | none | shared | Choosing the provider and the endpoint | CRM "Which model is called" | +| `SA-11` | The test suite cannot reach the network: outside the opt-in live run, a connect to a listener that is definitely there is refused | `test/support/network_guard.ex` | `test/network_guard_test.exs` "RED: the default test run refuses a connect to a listener that is definitely there" | Trinity's | none | CRM "Suppressing network access in the test suite" | +| `SC-7` | Nothing in the tree restricts which hosts the process may reach | `docs/regulated/authorization-boundary.md` | none | the deployment's | Security groups, an egress proxy or a firewall | CRM "Restricting which hosts the process may reach" | +| `SC-7` | The model endpoint is outside the boundary unless the deployment puts it inside | `docs/regulated/authorization-boundary.md` | none | the deployment's | Self-hosting the model, where that is required | CRM "Keeping the model inside your boundary" | +| `SC-8` | Connections Trinity makes offer TLS 1.2 and 1.3 and nothing older | `README.md` | `test/supply_chain_test.exs` "the runtime's default TLS versions are 1.2 and 1.3, and nothing older" | Trinity's | none | CRM "TLS on connections Trinity makes"; Register "TLS floor 1.2 in FIPS mode": :unknown | +| `SC-8` | The headless release serves plain HTTP on `TRINITY_BIND`: the loopback by default, every interface in the image | `config/runtime.exs`, `ci/ironbank/Dockerfile` | none | the deployment's | TLS in front of the process | CRM "TLS on connections made to Trinity" | +| `SC-12` | The receipt signing key is generated once, mode 0600, registered, and read at every signature by the custody module and by nothing else | `lib/trinity/receipts/key_custody.ex`, `lib/trinity/keys.ex` | `test/trinity/receipts/signer_test.exs` "boot generates the key once (0600), appends its registry row, and a second boot reuses it"; `test/trinity/keys/custody_census_test.exs` "no module outside the custody seam reads key material from disk" | Trinity's | none | CRM "Receipt signing key generation and use" | +| `SC-12` | Key material lives in the data directory's keys directory by default, sealed when a key source is configured | `lib/trinity/paths.ex`, `lib/trinity/keys/local.ex` | `test/trinity/keys/signer_seam_test.exs` "the signing key is ciphertext on disk and the registry says so" | shared | Where the keys live, and who can read them | CRM "Where key material lives"; Register "Exclusive deployment control of encryption keys": :unknown | +| `SC-12` | Keys pass through one behaviour, so a KMS or HSM adapter is a module and a configuration line; none ships in this tree | `lib/trinity/keys.ex` | `test/trinity/keys/signer_seam_test.exs` "a sealed key still signs, which is the point of sealing it" | shared | A KMS or HSM adapter, where one is required | CRM "A KMS or HSM adapter" | +| `SC-12` | Custody of the signing key is not addressed: a key in a file shows the records were not altered by anything without read access to it, and nothing about who held it | `docs/10-assurance-case.md` | none | the deployment's | Custody of the signing key | CRM "Custody of the signing key" | +| `SC-13` | Signing picks its algorithm by the runtime's mode: Ed25519 by default, ECDSA P-384 with SHA-384 in FIPS mode, where Ed25519 reports itself unavailable. The gate and the suite run in FIPS mode on every push | `lib/trinity/receipts/signer.ex`, `docs/fips-leg.md`, `docs/regulated/crypto-inventory.md` | `test/trinity/receipts/signer_test.exs` "outside FIPS mode the default is Ed25519; in FIPS mode (the fips leg) it is P-384"; `test/fips/receipts_test.exs` "the mode is on, Ed25519 reports unavailable, P-384 is selected and the boot receipt names it" | shared | A validated cryptographic module on the host, established from its certificate and the running binary | Register "FIPS 140-3 validated cryptography in FIPS mode": :unknown; Register "FIPS 140-2 certificates on the Historical list from 2026-09-22": real-world dependency | +| `SC-23` | The state an MCP client carries between rounds is sealed with AES-256-GCM: a replay, one tampered byte, an expired envelope and a state bound to other arguments are refused | `lib/trinity/mcp/server/envelope.ex` | `test/trinity/mcp/server_mrtr_test.exs` "a replay inside the partition, one tampered byte, an expired envelope and a state bound to other arguments are refused; a missing state is a first call" | Trinity's | none | Register "Envelope MAC in FIPS mode is an approved algorithm": :unknown | +| `SC-24` | Failure denies: an unavailable signer denies every effect, a tool absent from the catalogue or a decision other than allow is denied before anything runs, and an authority adapter that cannot be loaded refuses the boot | `lib/trinity/effects.ex`, `lib/trinity/authority/selection.ex` | `test/trinity/effects/membrane_test.exs` "a :catalog tool absent from the catalog, or a decision other than allow, is denied before anything runs"; `test/trinity/authority/selection_test.exs` "an absent module is refused as not loaded, by name" | Trinity's | none | Register "Nothing fails open": :unknown | +| `SC-28` | Anything SQLite indexes is left unencrypted by design, for the volume beneath it | `docs/encryption-at-rest.md` | none | the deployment's | Volume or page-level encryption below SQLite | CRM "Encryption of everything SQLite indexes" | +| `SC-28(1)` | Blobs nothing indexes (skill files, staged changes, archives) are sealed with AES-256-GCM under a fresh data key each | `lib/trinity/vault.ex` | `test/trinity/vault_test.exs` "a sealed blob is ciphertext: the plaintext does not appear in it"; `test/trinity/vault_test.exs` "every blob gets its own data key, so one compromise does not open the next" | Trinity's | none | CRM "Envelope encryption of blobs"; Register "Encryption of data at rest with deployment-controlled keys": :unknown | +| `SI-4` | Documented telemetry events an operator can subscribe to; Trinity emits them and alerts no one | `lib/trinity/telemetry.ex`, `docs/telemetry.md` | `test/trinity/telemetry/telemetry_test.exs` "every event the catalogue lists appears in docs/telemetry.md" | shared | Collecting the events, and alerting on them | CRM "Telemetry events an operator can subscribe to" | +| `SI-7` | A changed MCP tool definition is held, not called, until the owner accepts it; the first sighting is the baseline | `lib/trinity/tools/surface.ex`, `lib/trinity/tools/definition_digest.ex` | `test/trinity/mcp/surface_drift_test.exs` "a description change alone holds the tool, with the schema untouched" | Trinity's | none | none | +| `SI-7` | Every input to the image build that is not a UBI package is declared with its digest and copied only if declared, and the published bases are pinned by digest | `ci/ironbank/hardening_manifest.yaml`, `lib/mix/tasks/trinity.ironbank.lint.ex` | `mix trinity.ironbank.lint`; `test/ironbank_submission_test.exs` "copying a file no resource declares is refused"; `test/ironbank_submission_test.exs` "a published base pinned by tag and not by digest is refused (planted red)" | Trinity's | none | none | +| `SI-10` | Input from an MCP server is decoded and validated by one decoder and one validator, the client library's, and a census finds no second | `lib/trinity/mcp/client.ex`, `lib/trinity/mcp/client/` | `test/trinity/mcp/thin_driver_census_test.exs` "decoding and validation are the core's public functions; no decoder or validator of Trinity's own" | Trinity's | none | none | +| `SI-11` | Credential-shaped strings are masked on their way into a log, and boot refuses to continue without the filter. A last line, not the control | `lib/trinity/telemetry/redaction.ex`, `lib/trinity/application.ex` | `test/trinity/telemetry/redaction_chardata_test.exs` "verify_log_redaction!/0 raises when the filter is absent, so boot cannot continue unfiltered"; `test/trinity/telemetry/redaction_test.exs` "a provider key, by its shape rather than by its name" | Trinity's | none | CRM "Redacting credential-shaped strings from logs" | +| `SI-12` | Memory entries go stale at 30 days and are archived at 90, each step receipted, and nothing is deleted | `lib/trinity/memory/curator.ex` | `test/trinity/scheduler/curator_test.exs` "stale at 30 days with a query receipt, archived at 90 with an effect receipt, fresh untouched, nothing deleted; the archived entry leaves recall" | shared | The thresholds, against the deployment's retention policy | CRM "Ageing memory entries" | +| `SI-12(3)` | Nothing in the tree deletes on a schedule | `lib/trinity/memory/curator.ex` | none | the deployment's | Disposal, where policy requires deletion | CRM "Deleting anything on a schedule" | +| `SR-4` | The gate generates a CycloneDX bill of materials covering every locked dependency | `lib/mix/tasks/trinity.sbom.ex` | `mix trinity.sbom`; `test/supply_chain_test.exs` "covers the dependencies the lock file holds" | Trinity's | none | Register "Software bill of materials on every release": :unknown | + +## SP 800-218 (SSDF 1.1): the mapping + +For SP 800-218 the producer is this project, so **Trinity's** means the project's own practice, +held by a check in this tree. + +| Control | Mechanism | Path | Check | Ownership | The deployment's part | Authority | +|---|---|---|---|---|---|---| +| `PO.3.1` | The toolchain is one command: `mix gate` runs every enforcer as its own step | `mix.exs` | `test/gate_alias_test.exs` "the gate runs every enforcer as its own step" | Trinity's | none | none | +| `PO.3.2` | Every CI workflow pins each third-party action to a full commit hash and grants no write permission at the top level | `.github/workflows/` | `test/workflows_test.exs` "every third-party action is pinned to a full commit hash, not a movable tag"; `test/workflows_test.exs` "every workflow declares a top-level permissions block, and it grants no write" | Trinity's | none | none | +| `PS.3.2` | Every gate run generates the CycloneDX bill of materials, and the third-party licence list is derived from it | `lib/mix/tasks/trinity.sbom.ex`, `THIRD_PARTY_LICENSES.md` | `mix trinity.sbom`; `mix trinity.third_party_licenses --check`; `test/supply_chain_test.exs` "covers the dependencies the lock file holds" | Trinity's | none | Register "Software bill of materials on every release": :unknown | +| `PW.4.1` | Dependencies are taken at pinned versions: every direct dependency has a row in `VERSIONS.md`, and the lock must agree with every pin | `VERSIONS.md`, `mix.lock` | `mix versions.verify`; `mix versions.gen --check`; `test/mix/tasks/versions_verify_test.exs` "the real project has no undocumented direct dependency" | Trinity's | none | none | +| `PW.4.4` | Every locked dependency is checked against published advisories and retired releases on every push | `mix.lock` | `mix deps.audit`; `mix hex.audit` | Trinity's | none | none | +| `PW.5.1` | No credential in the tree, and no evaluation of model output as code: both are gate checks over the tracked tree | `lib/mix/tasks/trinity.secrets.scan.ex`, `test/no_eval_on_model_output_test.exs` | `mix trinity.secrets.scan`; `test/no_eval_on_model_output_test.exs` "the family is exactly the six the moduledoc names" | Trinity's | none | none | +| `PW.6.2` | The compiler runs with warnings as errors, which makes the boundary rules and the type checker blocking; the release compiles under `MIX_ENV=prod`, assembles, and evaluates its runtime configuration | `mix.exs`, `scripts/prod_check.sh` | `mix compile --warnings-as-errors --force`; `scripts/prod_check.sh`; `test/gate_alias_test.exs` "the gate has a compile step and it carries --warnings-as-errors" | Trinity's | none | none | +| `PW.7.2` | Static analysis on every push: Credo in strict mode, and Sobelow, which blocks, with every skipped finding carrying a reason | `.credo.exs`, `.sobelow-skips`, `.sobelow-skips.reasons` | `mix credo --strict`; `mix sobelow --exit --skip`; `test/sobelow_skips_test.exs` "every skipped sobelow finding carries a reason"; `test/gate_alias_test.exs` "sobelow blocks rather than advises" | Trinity's | none | none | +| `PW.8.2` | The whole suite runs in the gate, and coverage may not fall more than three points below the previous slice's | `coverage.tsv`, `lib/mix/tasks/trinity.coverage.ex` | `mix test`; `mix trinity.coverage`; `test/coverage_gate_test.exs` "a drop of more than three points fails" | Trinity's | none | none | +| `PW.9.1` | Each tool's default comes from its risk tier: a read is allowed, a write asks the owner, and a tool the tier map does not name asks | `lib/trinity/permissions/policy.ex` | `test/trinity/permissions/gate_test.exs` "the default by tier: read allows, write asks, an unmapped name asks" | Trinity's | none | none | +| `PW.9.2` | The regulated profile is a group of settings implemented in code and documented in this pack; a node that does not meet it does not start, and a mistyped profile name raises | `lib/trinity/profile.ex`, `docs/regulated/authorization-boundary.md` | `test/trinity/regulated_boot_node_test.exs` "production mcp_auth, a non-Local authority, an allowed endpoint and a signer"; `test/trinity/profile_test.exs` "an unrecognised value raises rather than falling back to default" | Trinity's | none | none | +| `RV.1.1` | Advisories against locked dependencies are gathered on every push by the gate, and Dependabot watches the dependencies | `.github/dependabot.yml` | `mix deps.audit`; `test/workflows_test.exs` "every workflow and the Dependabot configuration parse as YAML" | Trinity's | none | none | +| `RV.2.2` | A High or Critical finding in the image is fixed, or carries a justification keyed to one version of one package; a justification for a finding no longer reported fails as stale | `ci/headless/justifications.yaml`, `lib/mix/tasks/trinity.image.findings.ex` | `mix trinity.image.findings`; `test/mix/tasks/trinity_image_findings_test.exs` "AC4: a planted row whose finding the scan no longer reports fails as stale" | Trinity's | none | none | + +## Gate steps that serve no practice + +Every step of `mix gate` is either cited by a row above or listed here. These four are real +checks; they hold project rules that are not security practices, and mapping them to one would +overstate them. + +| Step | Why it maps to no practice | +|---|---| +| `mix format --check-formatted` | A formatting rule. It keeps review readable; it finds no weakness | +| `mix trinity.version_form` | A writing rule: the protocol is versioned by date | +| `mix trinity.names` | A naming rule for this repository's text | +| `mix trinity.reuse` | Per-file copyright and licence headers: a licensing obligation, not a security check | + +## Families with no row + +Every SP 800-53 family either has a row above or is listed here with the reason. + +| Family | Why there is no row | +|---|---| +| `AT` | Awareness and training is of people, and the people are the deployment's | +| `CA` | Assessment, authorization and monitoring are acts performed on a deployment. This document is an input to an assessment, not an instance of one | +| `MA` | Maintenance of the system is performed by whoever operates it | +| `MP` | Media protection concerns the physical and digital media the deployment handles | +| `PE` | Physical and environmental protection belongs to the facility | +| `PL` | Planning, including the System Security Plan, is the deployment's | +| `PM` | Program management is organizational | +| `PS` | Personnel security concerns the deployment's staff | +| `PT` | Nothing in the tree identifies personally identifiable information or tracks the purpose it is processed for. `data-flow.md` states what enters a prompt; the processing policy is the deployment's | + +## Practices with no row + +Every SP 800-218 practice either has a row above, for itself or one of its tasks, or is listed here +with the reason. A practice the project follows but no check in the tree holds is listed here, +because a row needs a check. + +| Practice | Why there is no row | +|---|---| +| `PO.1` | Security requirements for development are written in the slice records, which are not in this tree | +| `PO.2` | Roles and training are organizational; `GOVERNANCE.md` names roles, and no check holds it | +| `PO.4` | The gate's steps are the criteria for a security check (the `PO.3.1` row), and nothing in the tree tracks them through the lifecycle beyond the coverage floor | +| `PO.5` | The development environment and the CI runners are configured outside this tree | +| `PS.1` | Who may change the code is the forge's configuration, not this tree's. `test/dco_test.exs` establishes who signed off each change, which is provenance rather than protection | +| `PS.2` | The standards register records signed releases as not claimed, and no release has been published | +| `PW.1` | The threat model is `docs/10-assurance-case.md`; no check in the tree holds it | +| `PW.2` | A design review by someone not involved in the design is not yet possible: the register records `two_person_review` as outstanding | +| `RV.3` | Root-cause analysis is recorded in the slice records, which are not in this tree | + +## The catalogues the identifiers are checked against + +Both from NIST's OSCAL content, `usnistgov/oscal-content`, pinned to one commit, under the +repository's CC0 dedication. + +| Catalogue | File | Commit | SHA-256 of the file | +|---|---|---|---| +| SP 800-53 Rev. 5 (version 5.2.0) | `nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json` | `78650f02ad9321bb7b817846f8fbd4f2bcd620de` | `01f37cf90ea99d92242c936cbfbdebcc338eef1f71454e2acac36cc56e9bc062` | +| SP 800-218, SSDF 1.1 | `nist.gov/SP800-218/ver1/json/NIST_SP800-218_ver1_catalog.json` | `78650f02ad9321bb7b817846f8fbd4f2bcd620de` | `b01634a5fdb382e7a12660c379a4d0bc3a2b8e29abccf2861834880005137117` | + +The catalogues themselves are not in the tree. `scripts/nist_catalogues.exs` derives the identifier +lists in `test/support/fixtures/nist/` from them, refuses a file whose SHA-256 is not the one above, +and with `--check` confirms the committed lists are what the pinned files derive. + +## How this document is checked + +`test/control_mapping_test.exs`, in the gate. It fails when: + +- a row's identifier is not in NIST's catalogue, or is withdrawn there; +- a path a row names, or a test file or script in its Check cell, is not in the tree; +- a `Trinity's` row names no check, or any row names a test, Mix task or function that does not + exist. A test is found by its literal name in its file. **Stated limit:** a test whose body is + emptied while its name is kept still passes here; what a test asserts is that test's own red; +- a row's ownership differs from the responsibility matrix row it cites, or a row giving the + deployment a part cites nothing that says so; +- a row repeats a register status the register does not record, or claims Trinity's where the + register records a real-world dependency or something not claimed; +- this document stops opening with what it is not, or uses, outside that opening, the words of a claim a + mapping cannot make; +- a member of a population derived from the tree is cited by no row: a row of the responsibility + matrix, a register row naming an SP 800-53 control, a refusal of the regulated profile + (`Trinity.Profile`'s `check_*` functions), a census test, or a step of `mix gate`; or a family or + practice in NIST's catalogue has neither a row nor a reason here. diff --git a/docs/regulated/customer-responsibility-matrix.md b/docs/regulated/customer-responsibility-matrix.md index 74ff00a..eddb628 100644 --- a/docs/regulated/customer-responsibility-matrix.md +++ b/docs/regulated/customer-responsibility-matrix.md @@ -24,10 +24,21 @@ should not plan as though it does. | Control | Trinity software | Customer | Host or AWS | |---|---|---|---| | Which model is called | Configuration only, `config/llm.exs:14` | **Yes.** You choose the endpoint and the provider | Network path | -| Enforcing that only an approved model may be called | **NOT IN TREE.** No allow-list mechanism exists in the tree; the model list is configuration and nothing refuses an unapproved one at runtime | **Yes.** Enforce by configuration control and egress policy | Egress filtering | +| Enforcing that only an approved model may be called | **Yes, under `TRINITY_PROFILE=regulated`, at boot.** `lib/trinity/profile.ex` refuses to start when a configured model's endpoint is not on `TRINITY_REGULATED_LLM_ENDPOINTS`, or states none. Under the default profile nothing refuses an unapproved model | **Yes.** The allow-list is yours, and so is the egress policy that holds it at run time | Egress filtering | | Whether the model provider trains on your data | No | **Yes.** A contract question with your provider | No | | Keeping the model inside your boundary | No | **Yes**, if required. Self-hosting is a customer deployment choice | **Yes** | +## Regulated profile + +`TRINITY_PROFILE=regulated` adds refusals at boot (`lib/trinity/profile.ex`). Each is a refusal, not +a capability: it stops a node starting in a configuration that cannot support a regulated +deployment, and it does not supply what the deployment has to. + +| Control | Trinity software | Customer | Host or AWS | +|---|---|---|---| +| Which chat gateways may carry conversation text | **Yes, under the regulated profile, at boot.** Every configured adapter must be named in `TRINITY_REGULATED_GATEWAYS` (`lib/trinity/profile.ex`) | **Yes.** Naming the channels, and authorizing each one | No | +| Who decides whether an effect may happen | **Yes, as a refusal.** Under the regulated profile the node will not boot on the local authority (`lib/trinity/profile.ex`); no other adapter ships in this tree (`authorization-boundary.md`) | **Yes.** The external authority adapter, and its assessment | No | + ## Keys | Control | Trinity software | Customer | Host or AWS | @@ -49,6 +60,14 @@ should not plan as though it does. | Redacting credential-shaped strings from logs | **Yes**, as a last line. `lib/trinity/telemetry/redaction.ex` | No | No | | Deciding what may leave the boundary at all | No | **Yes.** See `data-flow.md` | **Yes** | +## Transport and access + +| Control | Trinity software | Customer | Host or AWS | +|---|---|---|---| +| TLS on connections Trinity makes | **Yes.** The runtime offers TLS 1.2 and 1.3 and nothing older, `test/supply_chain_test.exs` | No | No | +| TLS on connections made to Trinity | **No.** The headless release serves plain HTTP on `TRINITY_BIND`: the loopback by default (`config/runtime.exs`), every interface in the image (`ci/ironbank/Dockerfile`) | **Yes.** Terminate TLS in front of it | **Yes** | +| Authenticating access to the web pages | **No.** The bearer is required on `/mcp`; the web pages, among them the permissions page where approvals are answered, carry no authentication of their own (`lib/trinity_web/router.ex`, `docs/mcp-server.md`). The image binds every interface | **Yes.** An authenticating reverse proxy in front of the port, or a network that only the owner reaches | **Yes** | + ## Backups | Control | Trinity software | Customer | Host or AWS | @@ -76,3 +95,14 @@ should not plan as though it does. | Shipping logs or metrics anywhere | **NOT IN TREE.** Trinity emits; it does not forward | **Yes** | **Yes** | | Reporting to a government customer within a clock | No | **Yes.** See `incident.md` | No | | A published vulnerability disclosure process for this software | **Yes.** `SECURITY.md` | Report to it | No | + +## Container image + +The headless image, `docs/regulated/headless-image.md`. + +| Control | Trinity software | Customer | Host or AWS | +|---|---|---|---| +| What the image contains | **Yes.** No package manager, compiler, shell history or key material beyond what is allowed by name; distribution off and no baked cookie. Checked on the built image by `mix trinity.image.inspect` | No | No | +| Running the image read-only, with no capabilities and no privilege escalation | The image runs as UID 10001 and writes only `/data` and `/tmp`, so it needs nothing more (`ci/ironbank/Dockerfile`) | **Yes.** Set them in the runtime that starts it | **Yes** | +| STIG rules for the image | **Yes.** `stig-applicability.md`, generated by `mix trinity.image.stig`, gives every rule of the profile a disposition | **Yes.** The rules it dispositions as the deployment's, and the host's own STIG | **Yes** | +| Scanning for known vulnerabilities | **Yes.** `mix deps.audit` in the gate; `mix trinity.image.findings` over a grype scan of the built image, which fails today on OTP findings that have no justification (`headless-image.md`) | **Yes.** Scanning what you run, on your schedule | **Yes** | diff --git a/docs/regulated/data-flow.md b/docs/regulated/data-flow.md index cba7c41..73e982f 100644 --- a/docs/regulated/data-flow.md +++ b/docs/regulated/data-flow.md @@ -56,9 +56,12 @@ prompt. The requirement: CUI or PHI must not leave the boundary except to a model the lab has approved for that category of data, under a contract that permits it. -The enforcement: **NOT IN TREE.** There is no allow-list of approved models in this repository, and -nothing refuses a call to an unapproved endpoint at runtime. The model in force is configuration -(`config/llm.exs:14`, and `TRINITY_MODEL` at runtime). Trinity will call whatever it is pointed at. +The enforcement, under `TRINITY_PROFILE=regulated` only: the node refuses to boot when a configured +model's endpoint is not on `TRINITY_REGULATED_LLM_ENDPOINTS`, or when a model states no endpoint +(`lib/trinity/profile.ex`, `check_llm_endpoints/3`). The check runs once, at boot, over the models the +configuration lists; it does not watch traffic and it is not an egress control. Under the default +profile there is no such check: the model in force is configuration (`config/llm.exs:14`, and +`TRINITY_MODEL` at runtime), and Trinity will call whatever it is pointed at. What this means for an accreditation: diff --git a/scripts/nist_catalogues.exs b/scripts/nist_catalogues.exs new file mode 100644 index 0000000..dc06908 --- /dev/null +++ b/scripts/nist_catalogues.exs @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Derives the two identifier lists `docs/regulated/control-mapping.md` is checked against, from +# NIST's own OSCAL catalogues, and refuses any catalogue that is not the pinned one. +# +# elixir scripts/nist_catalogues.exs --sp800-53 PATH --sp800-218 PATH # write +# elixir scripts/nist_catalogues.exs --sp800-53 PATH --sp800-218 PATH --check # compare +# +# The two catalogues come from `usnistgov/oscal-content` at the commit below: +# +# curl -fsSLO https://raw.githubusercontent.com/usnistgov/oscal-content//nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json +# curl -fsSLO https://raw.githubusercontent.com/usnistgov/oscal-content//nist.gov/SP800-218/ver1/json/NIST_SP800-218_ver1_catalog.json +# +# They are 10.4 MB and 0.25 MB and stay outside the tree. What enters it is the identifiers, each +# with its status and its family, in `test/support/fixtures/nist/`, with the URL, the commit and +# the SHA-256 of the file it came from in the header. Titles are left out on purpose: a title is +# not needed to refuse an identifier the catalogue does not have. +# +# This is not in `mix gate`. It needs the catalogues, which the gate does not download, and what it +# checks changes when NIST publishes, not when this tree does. `--check` is how anyone re-derives +# the committed lists from NIST's files and confirms they are the same bytes. + +defmodule NistCatalogues do + @commit "78650f02ad9321bb7b817846f8fbd4f2bcd620de" + @base "https://raw.githubusercontent.com/usnistgov/oscal-content/#{@commit}/nist.gov" + + @sources %{ + sp800_53: %{ + url: "#{@base}/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json", + sha256: "01f37cf90ea99d92242c936cbfbdebcc338eef1f71454e2acac36cc56e9bc062", + out: "test/support/fixtures/nist/sp800-53r5.tsv", + heading: "NIST SP 800-53 Rev. 5 controls and control enhancements" + }, + sp800_218: %{ + url: "#{@base}/SP800-218/ver1/json/NIST_SP800-218_ver1_catalog.json", + sha256: "b01634a5fdb382e7a12660c379a4d0bc3a2b8e29abccf2861834880005137117", + out: "test/support/fixtures/nist/sp800-218.tsv", + heading: "NIST SP 800-218 (SSDF 1.1) practices and tasks" + } + } + + def main(argv) do + {opts, _, invalid} = + OptionParser.parse(argv, strict: [sp800_53: :string, sp800_218: :string, check: :boolean]) + + paths = %{sp800_53: opts[:sp800_53], sp800_218: opts[:sp800_218]} + + if invalid != [] or Enum.any?(paths, fn {_, p} -> is_nil(p) end) do + IO.puts( + :stderr, + "usage: elixir scripts/nist_catalogues.exs --sp800-53 PATH --sp800-218 PATH [--check]" + ) + + System.halt(2) + end + + results = + for {key, path} <- paths do + source = Map.fetch!(@sources, key) + bytes = File.read!(path) + verify_digest!(path, bytes, source.sha256) + text = render(key, source, JSON.decode!(bytes)["catalog"]) + {source.out, text} + end + + if opts[:check], do: check(results), else: write(results) + end + + defp verify_digest!(path, bytes, expected) do + got = :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) + + if got != expected do + IO.puts( + :stderr, + "REFUSED #{path}: SHA-256 #{got}, pinned #{expected}. Not the catalogue at #{@commit}." + ) + + System.halt(1) + end + end + + defp write(results) do + for {out, text} <- results do + File.mkdir_p!(Path.dirname(out)) + File.write!(out, text) + IO.puts("nist_catalogues: wrote #{out} (#{count(text)} identifiers)") + end + end + + defp check(results) do + stale = + for {out, text} <- results, File.read(out) != {:ok, text} do + IO.puts(:stderr, "STALE #{out}: not what the pinned catalogue derives") + out + end + + if stale == [] do + IO.puts("nist_catalogues: OK. Both lists are what the pinned catalogues derive") + else + System.halt(1) + end + end + + defp count(text), + do: text |> String.split("\n") |> Enum.count(&(&1 =~ ~r/^(control|practice|task)\t/)) + + defp render(_key, source, catalog) do + meta = catalog["metadata"] + + header = [ + "# #{source.heading}. Derived; regenerate, never edit.", + "# source: #{source.url}", + "# commit: #{@commit}", + "# sha256: #{source.sha256}", + "# catalogue: version #{meta["version"]}, last-modified #{meta["last-modified"]}, OSCAL #{meta["oscal-version"]}", + "# licence: CC0-1.0, the LICENSE.md of usnistgov/oscal-content at that commit", + "# derived by: elixir scripts/nist_catalogues.exs --sp800-53 PATH --sp800-218 PATH" + ] + + rows = + Enum.flat_map(catalog["groups"], fn group -> + family = label(group) |> family_code(group["id"]) + ["family\t#{family}\t#{group["title"]}" | walk(group["controls"] || [], family, 0)] + end) + + Enum.join(header ++ rows, "\n") <> "\n" + end + + # SP 800-53 nests enhancements under controls; SP 800-218 nests tasks under practices. The kind + # is the depth, and the label is the one NIST prints, never a reconstruction from the id. + defp walk(controls, family, depth) do + Enum.flat_map(controls, fn control -> + line = + "#{kind(control["id"], depth)}\t#{identifier(control)}\t#{status(control)}\t#{family}" + + [line | walk(control["controls"] || [], family, depth + 1)] + end) + end + + defp kind(id, depth) do + cond do + not ssdf?(id) -> "control" + depth == 0 -> "practice" + true -> "task" + end + end + + defp ssdf?(id), do: String.match?(id, ~r/^[A-Z]{2}\.\d/) + + # SSDF's ids are its identifiers (`PO.1.1`); its labels are the practice's name. SP 800-53's ids + # are lower case (`ac-2.1`) and its plain label is the identifier as NIST prints it (`AC-2(1)`), + # not the zero-padded one. + defp identifier(%{"id" => id} = control) do + if ssdf?(id), do: id, else: label(control) + end + + defp label(%{"props" => props} = item) do + Enum.find_value(props, fn p -> (p["name"] == "label" and is_nil(p["class"])) && p["value"] end) || + item["id"] + end + + defp label(item), do: item["id"] + + # SSDF groups are labelled "Prepare the Organization (PO)"; SP 800-53 groups carry no label and + # their id is the family in lower case. + defp family_code(label, id) do + case Regex.run(~r/\(([A-Z]{2})\)$/, label) do + [_, code] -> code + nil -> String.upcase(id) + end + end + + defp status(%{"props" => props}) do + Enum.find_value(props, "active", fn p -> p["name"] == "status" && p["value"] end) + end + + defp status(_), do: "active" +end + +NistCatalogues.main(System.argv()) diff --git a/test/control_mapping_test.exs b/test/control_mapping_test.exs new file mode 100644 index 0000000..c00bc75 --- /dev/null +++ b/test/control_mapping_test.exs @@ -0,0 +1,384 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.ControlMappingTest do + @moduledoc """ + Slice 132: `docs/regulated/control-mapping.md` cannot drift from the tree, from NIST's + catalogues, from the responsibility matrix or from the standards register. + + A mapping of mechanisms to controls is worth exactly as much as its agreement with the things it + names, and that agreement decays silently: a file moves, a test is renamed, a register row is + downgraded, and the document goes on saying what it said. `Trinity.Receipts.SchemeMappingTest` + holds `docs/receipt-scheme-mapping.md` to the payload it describes; this holds the control mapping + to the tree in the same way, with the document's own rows as the population. + + Every rule here is tested twice: against the real document, where it must find nothing, and + against a copy of the real document with one violation planted, where it must find exactly that. + The second half is what makes the first worth reading. + """ + use ExUnit.Case, async: true + + alias Trinity.Test.ControlMapping, as: CM + + @doc_path "docs/regulated/control-mapping.md" + @crm_path "docs/regulated/customer-responsibility-matrix.md" + @register_path "docs/09-standards-register.md" + @sp800_53_path "test/support/fixtures/nist/sp800-53r5.tsv" + @sp800_218_path "test/support/fixtures/nist/sp800-218.tsv" + + for path <- [@doc_path, @crm_path, @register_path, @sp800_53_path, @sp800_218_path] do + @external_resource path + end + + defp doc, do: File.read!(@doc_path) + defp rows(text \\ doc()), do: CM.rows(text) + defp sp800_53, do: @sp800_53_path |> File.read!() |> CM.catalogue() + defp sp800_218, do: @sp800_218_path |> File.read!() |> CM.catalogue() + defp crm, do: @crm_path |> File.read!() |> CM.crm() + defp register, do: @register_path |> File.read!() |> CM.register() + + defp tracked do + {out, 0} = System.cmd("git", ["ls-files"]) + out |> String.split("\n", trim: true) |> MapSet.new() + end + + defp gate_steps, do: Mix.Project.config()[:aliases][:gate] |> Enum.map(&CM.gate_step/1) + + # How a check named in a row is found. A test by its literal name in its file; a Mix task by + # Mix's own lookup, or as a step of the gate (Hex's tasks are an archive the suite does not load); + # a function by loading its module. + defp resolve do + %{ + test_names: fn file -> + case File.read(file) do + {:ok, src} -> + Regex.scan(~r/^\s*(?:test|property)\s+"((?:[^"\\]|\\.)*)"/m, src) + |> Enum.map(fn [_, name] -> name end) + + {:error, _} -> + :missing + end + end, + mix_task?: fn task -> Mix.Task.get(task) != nil or task in gate_steps() end, + function?: fn mod, fun, arity -> + module = Module.concat([mod]) + + Code.ensure_loaded?(module) and + function_exported?(module, String.to_atom(fun), arity) + end + } + end + + # Replace the first occurrence of `from` inside the row for `control` and return the document, + # asserting the plant took, so a plant that silently changed nothing cannot pass for a red. + defp plant(control, from, to) do + text = doc() + + row = + Regex.scan(~r/^\| `#{Regex.escape(control)}` \|.*$/m, text) + |> Enum.map(&hd/1) + |> Enum.find(&String.contains?(&1, from)) + + assert row, "no #{control} row contains #{inspect(from)}" + + planted = + String.replace(text, row, String.replace(row, from, to, global: false), global: false) + + assert planted != text, "the plant in #{control} changed nothing" + planted + end + + describe "the document is what this test reads" do + test "both tables parse into rows, so no check below is vacuous" do + rows = rows() + assert Enum.count(rows, &(&1.table == :sp800_53)) > 40 + assert Enum.count(rows, &(&1.table == :sp800_218)) > 8 + end + + test "the catalogue fixtures are NIST's, by the source, commit and digest they record" do + for {catalogue, url_part, count_kinds, count} <- [ + {sp800_53(), "SP800-53/rev5", ["control"], 1196}, + {sp800_218(), "SP800-218/ver1", ["practice", "task"], 61} + ] do + assert catalogue.header["source"] =~ + "https://raw.githubusercontent.com/usnistgov/oscal-content/" + + assert catalogue.header["source"] =~ url_part + assert catalogue.header["commit"] =~ ~r/^[0-9a-f]{40}$/ + assert catalogue.header["sha256"] =~ ~r/^[0-9a-f]{64}$/ + assert catalogue.header["source"] =~ catalogue.header["commit"] + + assert Enum.count(catalogue.ids, fn {_, {kind, _, _}} -> kind in count_kinds end) == count + end + + assert map_size(sp800_53().families) == 20 + assert map_size(sp800_218().families) == 4 + end + end + + describe "AC1: every row names the identifier, the mechanism, the path and the ownership" do + test "the real document" do + assert CM.shape_violations(rows()) == [] + end + + test "a planted ownership outside the three is refused" do + planted = plant("AU-12", "| Trinity's |", "| compliant |") + assert ["line " <> _ = v] = CM.shape_violations(rows(planted)) + assert v =~ ~s(ownership "compliant" is not one of) + end + end + + describe "the identifiers are NIST's" do + test "every identifier in both tables is in its catalogue and not withdrawn" do + assert CM.id_violations(rows(), sp800_53(), sp800_218()) == [] + end + + test "a planted control that does not exist is refused" do + planted = plant("AU-12", "`AU-12`", "`AU-99`") + assert [v] = CM.id_violations(rows(planted), sp800_53(), sp800_218()) + assert v =~ "AU-99 is not an identifier in NIST's SP 800-53 Rev. 5 catalogue" + end + + test "a planted withdrawn control is refused, because a withdrawn control maps nothing" do + planted = plant("AU-12", "`AU-12`", "`AC-2(10)`") + assert [v] = CM.id_violations(rows(planted), sp800_53(), sp800_218()) + assert v =~ "AC-2(10) is withdrawn" + end + + test "a planted practice SSDF 1.1 does not have is refused" do + planted = plant("PW.8.2", "`PW.8.2`", "`PW.3.1`") + assert [v] = CM.id_violations(rows(planted), sp800_53(), sp800_218()) + assert v =~ "PW.3.1 is not an identifier in NIST's SP 800-218 catalogue" + end + end + + describe "AC2: every path in every row exists" do + test "the real document" do + assert CM.path_violations(rows(), tracked()) == [] + end + + test "a planted moved path fails, naming it" do + planted = plant("AU-12", "`lib/trinity/effects.ex`", "`lib/trinity/effects/membrane.ex`") + assert [v] = CM.path_violations(rows(planted), tracked()) + assert v =~ "lib/trinity/effects/membrane.ex does not exist in this tree" + end + end + + describe "AC3: no row claims a control Trinity does not implement" do + test "every Trinity's row names a check, and every check named exists" do + assert CM.check_violations(rows(), resolve()) == [] + end + + test "a planted row naming a test that does not exist fails" do + planted = + plant( + "AU-12", + ~s("AC3: every gate decision and every effect yields a receipt), + ~s("every effect yields a receipt, which nobody wrote) + ) + + assert [v] = CM.check_violations(rows(planted), resolve()) + assert v =~ "test/trinity/effects/membrane_test.exs has no test named" + end + + test "a planted row naming a function that does not exist fails" do + planted = + plant( + "AC-5", + "`Trinity.Profile.check_authority/2`", + "`Trinity.Profile.check_separation/2`" + ) + + assert [v] = CM.check_violations(rows(planted), resolve()) + assert v =~ "Trinity.Profile.check_separation/2 does not exist" + end + + test "a planted Trinity's row with no check at all fails" do + [row] = Regex.run(~r/^\| `AU-12` \|.*$/m, doc()) + + [c, m, p, _checks, o, part, a] = + row |> String.trim("|") |> String.split("|") |> Enum.map(&String.trim/1) + + planted = + String.replace( + doc(), + row, + "| " <> Enum.join([c, m, p, "none", o, part, a], " | ") <> " |" + ) + + assert [v] = CM.check_violations(rows(planted), resolve()) + assert v =~ "a Trinity's row names no test or enforcer" + end + end + + describe "ownership agrees with the responsibility matrix, which is its authority" do + test "the real document" do + rows = rows() + assert CM.ownership_violations(rows) == [] + assert CM.crm_violations(rows, crm()) == [] + end + + test "the matrix's rows are what this test reads, and their keys are unique" do + keys = Enum.map(crm(), & &1.key) + assert length(keys) > 30 + + assert keys == Enum.uniq(keys), + "two rows of the matrix share a first cell, so a citation is ambiguous" + end + + test "a planted row claiming more than the matrix gives it fails" do + planted = plant("SC-28", "| the deployment's |", "| shared |") + assert [v] = CM.crm_violations(rows(planted), crm()) + assert v =~ "says shared, and the responsibility matrix's row" + end + + test "a planted shared row that cites nothing for the deployment's part fails" do + planted = + plant("AU-8", ~s(Register "Trusted time for receipts": real-world dependency), "none") + + assert [v] = CM.ownership_violations(rows(planted)) + assert v =~ "cites neither the responsibility matrix nor a register row" + end + end + + describe "AC4: the document opens with what the mapping is not" do + test "the real document" do + assert CM.opening_violations(doc()) == [] + end + + test "nothing outside that opening claims compliance, satisfaction, certification or novelty, and there is no em dash" do + assert CM.claim_violations(doc()) == [] + end + + test "a planted opening that drops the System Security Plan limit fails" do + planted = + String.replace(doc(), "not a System Security Plan", "a System Security Plan", + global: false + ) + + assert planted != doc() + assert [v] = CM.opening_violations(planted) + assert v =~ "not a System Security Plan" + end + + test "a planted claim of compliance in a row fails" do + planted = plant("AU-12", "| Trinity's |", "| Trinity's, compliant |") + assert [v | _] = CM.claim_violations(planted) + assert v =~ ~s("compliant" outside the statement of limits) + end + end + + describe "AC5: nothing in the mapping contradicts the standards register" do + test "the real document" do + assert CM.register_violations(rows(), register()) == [] + end + + test "every register row naming an SP 800-53 control is cited by a row for that control" do + cited = for row <- rows(), {key, _} <- row.register, do: {key, row.control} + register_controls = CM.register_controls(register()) + + assert register_controls != [], + "no register row names an SP 800-53 control; this check has gone vacuous" + + assert register_controls -- cited == [] + end + + test "a planted row claiming Trinity's where the register records a real-world dependency fails" do + planted = plant("AU-8", "| shared |", "| Trinity's |") + violations = CM.register_violations(rows(planted), register()) + assert Enum.any?(violations, &(&1 =~ "records real-world dependency")) + end + + test "a planted row upgrading the register's :unknown fails" do + planted = plant("AU-10", ~s|(AU-10)": :unknown|, ~s|(AU-10)": tree property|) + assert [v] = CM.register_violations(rows(planted), register()) + assert v =~ ~s(says the register records "tree property") + assert v =~ ~s(states [":unknown"]) + end + end + + describe "populations derive from the tree, and every member is cited" do + test "every row of the responsibility matrix" do + cited = Enum.flat_map(rows(), & &1.crm) + assert CM.uncited(Enum.map(crm(), & &1.key), cited, "responsibility matrix row") == [] + end + + test "every refusal the regulated profile makes" do + refusals = + for {fun, arity} <- Trinity.Profile.__info__(:functions), + String.starts_with?(Atom.to_string(fun), "check_"), + do: "Trinity.Profile.#{fun}/#{arity}" + + assert length(refusals) >= 6 + assert CM.uncited(refusals, CM.cited_checks(rows()).funs, "regulated refusal") == [] + end + + test "every census in the tree" do + {out, 0} = System.cmd("git", ["ls-files", "test/*census*_test.exs"]) + censuses = String.split(out, "\n", trim: true) + assert length(censuses) >= 7 + assert CM.uncited(censuses, CM.cited_checks(rows()).tests, "census") == [] + end + + test "every step of the gate, against SP 800-218 or the steps that serve no practice" do + ssdf = rows() |> Enum.filter(&(&1.table == :sp800_218)) |> CM.cited_checks() + + none = + CM.listed(doc(), "## Gate steps that serve no practice") + |> Enum.map(fn {_, s, _} -> CM.gate_step(s) end) + + steps = gate_steps() + assert length(steps) >= 15 + + assert CM.uncited( + steps, + MapSet.union(MapSet.union(ssdf.mix, ssdf.scripts), MapSet.new(none)), + "gate step" + ) == + [] + + assert none -- steps == [], "a step listed as serving no practice is not a step of the gate" + end + + test "a planted census the document does not cite is named" do + assert ["census test/trinity/planted_census_test.exs is cited by no row"] = + CM.uncited( + ["test/trinity/planted_census_test.exs"], + CM.cited_checks(rows()).tests, + "census" + ) + end + + test "every SP 800-53 family has a row, or is listed with a reason" do + covered = + rows() + |> Enum.filter(&(&1.table == :sp800_53)) + |> Enum.map(&(&1.control |> String.slice(0, 2))) + + listed = CM.listed(doc(), "## Families with no row") + + assert CM.coverage_violations(Map.keys(sp800_53().families), covered, listed, "family") == + [] + end + + test "every SP 800-218 practice has a row for it or a task of it, or is listed with a reason" do + practices = for {id, {"practice", _, _}} <- sp800_218().ids, do: id + + covered = + for row <- rows(), + row.table == :sp800_218, + practice <- practices, + row.control == practice or String.starts_with?(row.control, practice <> "."), + do: practice + + listed = CM.listed(doc(), "## Practices with no row") + assert CM.coverage_violations(practices, covered, listed, "practice") == [] + end + + test "a planted family listed as having no row while it has one fails" do + listed = [{1, "AU", "nothing here"}] + + assert ["line 1: family AU is listed as having no row, and has one"] = + CM.coverage_violations(["AU"], ["AU"], listed, "family") + end + end +end diff --git a/test/support/control_mapping.ex b/test/support/control_mapping.ex new file mode 100644 index 0000000..7228f77 --- /dev/null +++ b/test/support/control_mapping.ex @@ -0,0 +1,733 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Test.ControlMapping do + @moduledoc """ + Slice 132: reads `docs/regulated/control-mapping.md`, and the documents it defers to, and says + what is wrong with it. + + Every function here is pure over text and over what the caller hands in (the tracked files, the + catalogues, a resolver for checks). That is what lets `test/control_mapping_test.exs` run each + planted violation against a modified copy of the real document as a permanent test, instead of + editing the file and putting it back: a red that lives in the suite is a red that cannot quietly + stop being one. + + Each `*_violations` function answers a list of sentences, empty when there is nothing wrong. + A sentence names the row by its line and its identifier, so a failure says where to look. + """ + + @ownerships ["Trinity's", "shared", "the deployment's"] + @statuses [":unknown", "not claimed", "real-world dependency", "tree property"] + @opening "## What this mapping is not" + + @typedoc "One row of a mapping table." + @type row :: %{ + line: pos_integer(), + table: :sp800_53 | :sp800_218, + cells: [String.t()], + control: String.t() | nil, + mechanism: String.t(), + paths: [String.t()], + checks: [check()], + ownership: String.t(), + part: String.t(), + crm: [String.t()], + register: [{String.t(), String.t()}], + unparsed: [String.t()] + } + + @typedoc "What a row's Check cell can name." + @type check :: + {:test, String.t(), String.t()} + | {:mix, String.t()} + | {:fun, String.t(), String.t(), non_neg_integer()} + | {:script, String.t()} + + @doc "The ownership values the SLICE allows, strongest first." + @spec ownerships() :: [String.t()] + def ownerships, do: @ownerships + + @doc "The register's four statuses." + @spec statuses() :: [String.t()] + def statuses, do: @statuses + + # --------------------------------------------------------------------------------------------- + # The mapping document + + @doc """ + The rows of both mapping tables. A row is a line beginning `` | ` `` under a heading that begins + `## SP 800-53` or `## SP 800-218`; anything else in the document is prose or another table. + """ + @spec rows(String.t()) :: [row()] + def rows(doc) do + doc + |> numbered() + |> Enum.reduce({nil, []}, fn {line, n}, {section, acc} -> + cond do + String.starts_with?(line, "## ") -> + {table_of(line), acc} + + section && String.starts_with?(line, "| `") -> + {section, [parse_row(line, n, section) | acc]} + + true -> + {section, acc} + end + end) + |> elem(1) + |> Enum.reverse() + end + + defp table_of("## SP 800-53" <> _), do: :sp800_53 + defp table_of("## SP 800-218" <> _), do: :sp800_218 + defp table_of(_), do: nil + + defp parse_row(line, n, table) do + cells = cells(line) + base = %{line: n, table: table, cells: cells, unparsed: []} + + case cells do + [control, mechanism, paths, checks, ownership, part, authority] -> + {checks, bad_checks} = parse_checks(checks) + {crm, register, bad_auth} = parse_authority(authority) + + Map.merge(base, %{ + control: backticked(control), + mechanism: mechanism, + paths: Regex.scan(~r/`([^`]+)`/, paths) |> Enum.map(fn [_, p] -> p end), + checks: checks, + ownership: ownership, + part: part, + crm: crm, + register: register, + unparsed: bad_checks ++ bad_auth + }) + + _ -> + Map.merge(base, %{ + control: nil, + mechanism: "", + paths: [], + checks: [], + ownership: "", + part: "", + crm: [], + register: [] + }) + end + end + + defp backticked(cell) do + case Regex.run(~r/^`([^`]+)`$/, cell) do + [_, id] -> id + nil -> nil + end + end + + defp parse_checks("none"), do: {[], []} + + # Items are found by their shape, not by splitting on "; ", because a test's name may itself + # contain "; " (slice 024's AC3 does). What is left once every item is removed must be nothing + # but the separators, or the cell holds something this check does not read. + defp parse_checks(cell) do + items = Regex.scan(~r/`[^`]+`(?: "[^"]*")?/, cell) |> Enum.map(&hd/1) + + residue = + items + |> Enum.reduce(cell, &String.replace(&2, &1, "", global: false)) + |> String.replace(";", "") + |> String.trim() + + leftover = if residue == "", do: [], else: ["check text #{inspect(residue)}"] + + Enum.reduce(items, {[], leftover}, fn item, {ok, bad} -> + case parse_check(item) do + nil -> {ok, bad ++ ["check #{inspect(item)}"]} + check -> {ok ++ [check], bad} + end + end) + end + + @doc "One item of a Check cell, or nil when it is none of the four forms." + @spec parse_check(String.t()) :: check() | nil + def parse_check(item) do + cond do + m = Regex.run(~r/^`(test\/[^`]+_test\.exs)` "([^"]+)"$/, item) -> + [_, file, name] = m + {:test, file, name} + + m = Regex.run(~r/^`mix ([a-z][a-z0-9_.]*)(?: [^`]*)?`$/, item) -> + {:mix, Enum.at(m, 1)} + + m = + Regex.run( + ~r/^`((?:[A-Z][A-Za-z0-9_]*\.)*[A-Z][A-Za-z0-9_]*)\.([a-z_][A-Za-z0-9_]*[?!]?)\/(\d+)`$/, + item + ) -> + [_, mod, fun, arity] = m + {:fun, mod, fun, String.to_integer(arity)} + + m = Regex.run(~r/^`(scripts\/[^`]+)`$/, item) -> + {:script, Enum.at(m, 1)} + + true -> + nil + end + end + + defp parse_authority("none"), do: {[], [], []} + + defp parse_authority(cell) do + cell + |> String.split("; ") + |> Enum.map(&String.trim/1) + |> Enum.reduce({[], [], []}, fn item, {crm, reg, bad} -> + cond do + m = Regex.run(~r/^CRM "([^"]+)"$/, item) -> + {crm ++ [Enum.at(m, 1)], reg, bad} + + m = Regex.run(~r/^Register "([^"]+)": (.+)$/, item) -> + {crm, reg ++ [{Enum.at(m, 1), Enum.at(m, 2)}], bad} + + true -> + {crm, reg, bad ++ ["authority #{inspect(item)}"]} + end + end) + end + + @doc "The first cell of every row of a two-column table under the given heading." + @spec listed(String.t(), String.t()) :: [{pos_integer(), String.t(), String.t()}] + def listed(doc, heading) do + doc + |> section(heading) + |> Enum.filter(fn {line, _} -> String.starts_with?(line, "| `") end) + |> Enum.map(fn {line, n} -> + case cells(line) do + [first, reason] -> {n, first |> String.trim("`"), reason} + _ -> {n, line, ""} + end + end) + end + + defp section(doc, heading) do + doc + |> numbered() + |> Enum.drop_while(fn {line, _} -> String.trim(line) != heading end) + |> Enum.drop(1) + |> Enum.take_while(fn {line, _} -> not String.starts_with?(line, "## ") end) + end + + # --------------------------------------------------------------------------------------------- + # AC1: the row's shape + + @doc "AC1: every row has an identifier, a mechanism, a path and one of the three ownerships." + @spec shape_violations([row()]) :: [String.t()] + def shape_violations(rows), do: Enum.flat_map(rows, &shape/1) + + defp shape(%{cells: cells, line: n}) when length(cells) != 7, + do: ["line #{n}: #{length(cells)} cells, expected 7"] + + defp shape(row) do + at = "line #{row.line} #{row.control}" + + List.flatten([ + when_true( + is_nil(row.control), + "line #{row.line}: the identifier is not one backticked value" + ), + when_true(String.trim(row.mechanism) == "", "#{at}: no mechanism"), + when_true(row.paths == [], "#{at}: no path in this tree"), + when_true( + row.ownership not in @ownerships, + "#{at}: ownership #{inspect(row.ownership)} is not one of #{inspect(@ownerships)}" + ), + Enum.map(row.unparsed, &"#{at}: #{&1} is not a form this check reads") + ]) + end + + defp when_true(true, sentence), do: [sentence] + defp when_true(false, _sentence), do: [] + + # --------------------------------------------------------------------------------------------- + # The catalogues + + @doc """ + A fixture under `test/support/fixtures/nist/`: the header (lines beginning `# `) as a map, the + families, and every identifier with its kind, status and family. + """ + @spec catalogue(String.t()) :: %{header: map(), families: map(), ids: map()} + def catalogue(text) do + lines = String.split(text, "\n", trim: true) + + header = + for "# " <> rest <- lines, + [k, v] <- [String.split(rest, ": ", parts: 2)], + into: %{}, + do: {k, v} + + entries = + Enum.reject(lines, &String.starts_with?(&1, "#")) |> Enum.map(&String.split(&1, "\t")) + + %{ + header: header, + families: for(["family", code, title] <- entries, into: %{}, do: {code, title}), + ids: for([kind, id, status, family] <- entries, into: %{}, do: {id, {kind, status, family}}) + } + end + + @doc "Every identifier is in its catalogue and not withdrawn." + @spec id_violations([row()], map(), map()) :: [String.t()] + def id_violations(rows, sp800_53, sp800_218) do + Enum.flat_map(rows, fn + %{control: nil} -> + [] + + %{table: table, control: id, line: n} -> + catalogue = if table == :sp800_53, do: sp800_53, else: sp800_218 + name = if table == :sp800_53, do: "SP 800-53 Rev. 5", else: "SP 800-218" + + case Map.get(catalogue.ids, id) do + nil -> ["line #{n}: #{id} is not an identifier in NIST's #{name} catalogue"] + {_kind, "withdrawn", _} -> ["line #{n}: #{id} is withdrawn in NIST's #{name} catalogue"] + {_kind, _status, _} -> [] + end + end) + end + + # --------------------------------------------------------------------------------------------- + # AC2: paths + + @doc """ + AC2: every path a row names, in its Path cell or as a test file or script in its Check cell, is + a tracked file, or a directory holding one. `tracked` is `git ls-files`. + """ + @spec path_violations([row()], MapSet.t()) :: [String.t()] + def path_violations(rows, tracked) do + Enum.flat_map(rows, fn row -> + named = + row.paths ++ + for(check <- row.checks, path <- check_path(check), do: path) + + for path <- named, not tracked?(path, tracked) do + "line #{row.line} #{row.control}: #{path} does not exist in this tree" + end + end) + end + + defp check_path({:test, file, _}), do: [file] + defp check_path({:script, path}), do: [path] + defp check_path(_), do: [] + + defp tracked?(path, tracked) do + trimmed = String.trim_trailing(path, "/") + + MapSet.member?(tracked, trimmed) or + Enum.any?(tracked, &String.starts_with?(&1, trimmed <> "/")) + end + + # --------------------------------------------------------------------------------------------- + # AC3: checks + + @doc """ + AC3: a `Trinity's` row names at least one check, and every check any row names exists. + + `resolve` carries three functions: `:test_names` (a file to the literal names of its tests, or + `:missing`), `:mix_task?` and `:function?`. + """ + @spec check_violations([row()], map()) :: [String.t()] + def check_violations(rows, resolve) do + Enum.flat_map(rows, fn row -> + at = "line #{row.line} #{row.control}" + + none = + if row.ownership == "Trinity's" and row.checks == [], + do: ["#{at}: a Trinity's row names no test or enforcer"], + else: [] + + none ++ Enum.flat_map(row.checks, &missing_check(&1, at, resolve)) + end) + end + + defp missing_check({:test, file, name}, at, resolve) do + case resolve.test_names.(file) do + :missing -> + ["#{at}: #{file} does not exist"] + + names -> + if name in names, do: [], else: ["#{at}: #{file} has no test named #{inspect(name)}"] + end + end + + defp missing_check({:mix, task}, at, resolve) do + if resolve.mix_task?.(task), do: [], else: ["#{at}: there is no Mix task #{task}"] + end + + defp missing_check({:fun, mod, fun, arity}, at, resolve) do + if resolve.function?.(mod, fun, arity), + do: [], + else: ["#{at}: #{mod}.#{fun}/#{arity} does not exist"] + end + + defp missing_check({:script, _path}, _at, _resolve), do: [] + + # --------------------------------------------------------------------------------------------- + # Ownership, and its authority + + @doc """ + The ownership cell agrees with the deployment's-part cell, and every row that gives the + deployment a part cites what says so: a row of the responsibility matrix, or a register row + whose status is `real-world dependency`. + """ + @spec ownership_violations([row()]) :: [String.t()] + def ownership_violations(rows), do: Enum.flat_map(rows, &ownership/1) + + defp ownership(%{ownership: "Trinity's", part: part} = row) do + when_true( + part != "none", + "line #{row.line} #{row.control}: a Trinity's row gives the deployment a part; it is shared" + ) + end + + defp ownership(%{ownership: o} = row) when o in ["shared", "the deployment's"] do + at = "line #{row.line} #{row.control}" + rwd? = Enum.any?(row.register, fn {_, s} -> s == "real-world dependency" end) + + when_true(row.part in ["", "none"], "#{at}: a #{o} row must say what the deployment supplies") ++ + when_true( + row.crm == [] and not rwd?, + "#{at}: a #{o} row cites neither the responsibility matrix nor a register row " <> + "recording a real-world dependency, so nothing in the pack backs the deployment's part" + ) + end + + defp ownership(_row), do: [] + + # --------------------------------------------------------------------------------------------- + # The customer responsibility matrix + + @doc """ + The responsibility matrix's rows, keyed by their first cell, each with the ownership its three + cells imply: software cell beginning `No` or **NOT IN TREE** is `the deployment's`; beginning + **Yes** with `No` for both customer and host is `Trinity's`; anything else is `shared`. + """ + @spec crm(String.t()) :: [ + %{key: String.t(), section: String.t(), derived: String.t(), line: pos_integer()} + ] + def crm(text) do + text + |> numbered() + |> Enum.reduce({nil, []}, fn {line, n}, {section, acc} -> + cond do + String.starts_with?(line, "## ") -> + {String.trim_leading(line, "## "), acc} + + String.starts_with?(line, "| ") and section != nil -> + {section, crm_line(cells(line), section, n) ++ acc} + + true -> + {section, acc} + end + end) + |> elem(1) + |> Enum.reverse() + end + + defp crm_line(["Control" | _], _section, _n), do: [] + + defp crm_line([key, software, customer, host], section, n), + do: [crm_row(key, section, software, customer, host, n)] + + defp crm_line(_cells, _section, _n), do: [] + + defp crm_row(key, section, software, customer, host, n) do + none? = software =~ ~r/^(\*\*)?(No\b|NOT IN TREE)/ + full? = software =~ ~r/^\*\*Yes\b/ + deployment? = customer != "No" or host != "No" + + derived = + cond do + none? -> "the deployment's" + full? and not deployment? -> "Trinity's" + true -> "shared" + end + + %{key: key, section: section, derived: derived, line: n} + end + + @doc "Every matrix row a mapping row cites exists, and the mapping's ownership is the matrix's." + @spec crm_violations([row()], list()) :: [String.t()] + def crm_violations(rows, crm) do + by_key = Map.new(crm, &{&1.key, &1}) + + for row <- rows, key <- row.crm, reduce: [] do + acc -> + at = "line #{row.line} #{row.control}" + + case Map.get(by_key, key) do + nil -> + acc ++ ["#{at}: the responsibility matrix has no row #{inspect(key)}"] + + %{derived: derived} when derived != row.ownership -> + acc ++ + [ + "#{at}: says #{row.ownership}, and the responsibility matrix's row #{inspect(key)} " <> + "says #{derived}. The matrix is the authority for ownership" + ] + + _ -> + acc + end + end + end + + # --------------------------------------------------------------------------------------------- + # AC5: the standards register + + @doc """ + The register's rows, keyed by their first cell, each with the statuses its status cell states + (any of `#{inspect(@statuses)}`). + """ + @spec register(String.t()) :: [%{key: String.t(), statuses: [String.t()], line: pos_integer()}] + def register(text) do + text + |> numbered() + |> Enum.reduce({nil, []}, fn {line, n}, {status_at, acc} -> + cond do + not String.starts_with?(line, "|") -> + {nil, acc} + + line =~ ~r/^\|[-|: ]+\|$/ -> + {status_at, acc} + + is_nil(status_at) -> + {Enum.find_index(cells(line), &(&1 == "Status")), acc} + + true -> + cells = cells(line) + status = Enum.at(cells, status_at, "") + found = Enum.filter(@statuses, &String.contains?(status, &1)) + {status_at, [%{key: hd(cells), statuses: found, line: n} | acc]} + end + end) + |> elem(1) + |> Enum.reverse() + end + + @doc """ + AC5: a row citing the register repeats a status that register row states, and claims no more: + a row citing `real-world dependency` or `not claimed` cannot be `Trinity's`. + """ + @spec register_violations([row()], list()) :: [String.t()] + def register_violations(rows, register) do + by_key = Map.new(register, &{&1.key, &1}) + + for row <- rows, {key, stated} <- row.register, reduce: [] do + acc -> + at = "line #{row.line} #{row.control}" + + acc ++ + case Map.get(by_key, key) do + nil -> + ["#{at}: the standards register has no row #{inspect(key)}"] + + %{statuses: statuses} -> + List.flatten([ + if(stated in statuses, + do: [], + else: [ + "#{at}: says the register records #{inspect(stated)} for #{inspect(key)}; " <> + "the register's status cell states #{inspect(statuses)}" + ] + ), + if( + stated in ["real-world dependency", "not claimed"] and + row.ownership == "Trinity's", + do: [ + "#{at}: the register records #{stated} for #{inspect(key)}, and the row says " <> + "Trinity's, which claims more than the register does" + ], + else: [] + ) + ]) + end + end + end + + @doc "The SP 800-53 identifiers the register names in a row's first cell, with that row's key." + @spec register_controls(list()) :: [{String.t(), String.t()}] + def register_controls(register) do + for %{key: key} <- register, + String.contains?(key, "800-53"), + [_, id] <- Regex.scan(~r/\b([A-Z]{2}-\d+(?:\(\d+\))?)/, key), + do: {key, id} + end + + # --------------------------------------------------------------------------------------------- + # Populations + + @doc "Members of a population that no row cites. `cited` is the set the rows name." + @spec uncited([String.t()], Enumerable.t(), String.t()) :: [String.t()] + def uncited(population, cited, what) do + cited = MapSet.new(cited) + + for member <- population, + not MapSet.member?(cited, member), + do: "#{what} #{member} is cited by no row" + end + + @doc "What a set of rows names as checks, in the form a population is compared in." + @spec cited_checks([row()]) :: %{ + tests: MapSet.t(), + mix: MapSet.t(), + funs: MapSet.t(), + scripts: MapSet.t() + } + def cited_checks(rows) do + checks = Enum.flat_map(rows, & &1.checks) + + %{ + tests: for({:test, file, _} <- checks, into: MapSet.new(), do: file), + mix: for({:mix, task} <- checks, into: MapSet.new(), do: task), + funs: for({:fun, m, f, a} <- checks, into: MapSet.new(), do: "#{m}.#{f}/#{a}"), + scripts: for({:script, p} <- checks, into: MapSet.new(), do: p) + } + end + + @doc """ + A step of the `gate` alias as the name a row cites it by: the Mix task, or the script's path. + `cmd env ERL_AFLAGS= mix hex.audit` is `hex.audit`; `cmd env ERL_AFLAGS= ./scripts/prod_check.sh` + is `scripts/prod_check.sh`. + """ + @spec gate_step(String.t()) :: String.t() + def gate_step(step) do + words = + step |> String.split(" ", trim: true) |> Enum.reject(&(&1 in ["cmd", "env", "ERL_AFLAGS="])) + + case words do + ["mix", task | _] -> task + ["./" <> script | _] -> script + [task | _] -> task + end + end + + @doc """ + Every family (SP 800-53) or practice (SP 800-218) in the catalogue has a row, or is listed under + "no row" with a reason; and nothing listed there also has a row, which would contradict it. + """ + @spec coverage_violations( + [String.t()], + [String.t()], + [{pos_integer(), String.t(), String.t()}], + String.t() + ) :: + [String.t()] + def coverage_violations(population, covered, listed, what) do + covered = MapSet.new(covered) + listed_ids = MapSet.new(listed, fn {_, id, _} -> id end) + + List.flatten([ + for( + id <- population, + not MapSet.member?(covered, id), + not MapSet.member?(listed_ids, id), + do: "#{what} #{id} has no row and is not listed with a reason" + ), + for( + {n, id, reason} <- listed, + String.trim(reason) == "", + do: "line #{n}: #{what} #{id} is listed with no reason" + ), + for( + {n, id, _} <- listed, + MapSet.member?(covered, id), + do: "line #{n}: #{what} #{id} is listed as having no row, and has one" + ), + for( + {n, id, _} <- listed, + id not in population, + do: "line #{n}: #{id} is not a #{what} in the catalogue" + ) + ]) + end + + # --------------------------------------------------------------------------------------------- + # AC4: what the document says about itself + + @required_limits [ + {~r/not an assessment/i, "not an assessment"}, + {~r/not an authori[sz]ation to operate/i, "not an authorization to operate (ATO)"}, + {~r/not a System Security Plan/i, "not a System Security Plan"}, + {~r/not a claim that any baseline is met/i, "not a claim that any baseline is met"} + ] + + @forbidden ~r/\b(compliant|compliance|complies|satisf(?:y|ies|ied)|certified|accredited|novel)\b/i + + @doc """ + AC4: the document's first section is #{inspect(@opening)}, it comes before any table, and it + says each of the four limits. + """ + @spec opening_violations(String.t()) :: [String.t()] + def opening_violations(doc) do + lines = numbered(doc) + first_heading = Enum.find(lines, fn {l, _} -> String.starts_with?(l, "## ") end) + first_table = Enum.find(lines, fn {l, _} -> String.starts_with?(l, "| ") end) + opening = section(doc, @opening) |> Enum.map_join("\n", &elem(&1, 0)) + + List.flatten([ + case first_heading do + {@opening, _} -> + [] + + other -> + ["the first section is #{inspect(other && elem(other, 0))}, not #{inspect(@opening)}"] + end, + case {first_heading, first_table} do + {{_, h}, {_, t}} when t < h -> + ["a table (line #{t}) comes before the statement of limits"] + + _ -> + [] + end, + for( + {pattern, says} <- @required_limits, + not Regex.match?(pattern, opening), + do: "the opening section does not say the mapping is #{says}" + ) + ]) + end + + @doc """ + Outside the opening section, which says what the mapping is not, the document claims nothing a + mapping cannot: no compliance, satisfaction, certification, accreditation or novelty. And no em + dash anywhere, which is this project's writing rule. + """ + @spec claim_violations(String.t()) :: [String.t()] + def claim_violations(doc) do + opening = section(doc, @opening) |> MapSet.new(&elem(&1, 1)) + + for {line, n} <- numbered(doc), reduce: [] do + acc -> + words = + if MapSet.member?(opening, n), + do: [], + else: Regex.scan(@forbidden, line) |> Enum.map(&hd/1) + + dash = if String.contains?(line, "—"), do: ["line #{n}: an em dash"], else: [] + + acc ++ + Enum.map(words, &"line #{n}: #{inspect(&1)} outside the statement of limits") ++ dash + end + end + + # --------------------------------------------------------------------------------------------- + + defp cells(line) do + line + |> String.trim() + |> String.trim_leading("|") + |> String.trim_trailing("|") + |> String.split("|") + |> Enum.map(&String.trim/1) + end + + defp numbered(text), do: text |> String.split("\n") |> Enum.with_index(1) +end diff --git a/test/support/fixtures/nist/sp800-218.tsv b/test/support/fixtures/nist/sp800-218.tsv new file mode 100644 index 0000000..afcd449 --- /dev/null +++ b/test/support/fixtures/nist/sp800-218.tsv @@ -0,0 +1,72 @@ +# NIST SP 800-218 (SSDF 1.1) practices and tasks. Derived; regenerate, never edit. +# source: https://raw.githubusercontent.com/usnistgov/oscal-content/78650f02ad9321bb7b817846f8fbd4f2bcd620de/nist.gov/SP800-218/ver1/json/NIST_SP800-218_ver1_catalog.json +# commit: 78650f02ad9321bb7b817846f8fbd4f2bcd620de +# sha256: b01634a5fdb382e7a12660c379a4d0bc3a2b8e29abccf2861834880005137117 +# catalogue: version 1.0.0, last-modified 2026-05-12T02:02:16.00000-00:00, OSCAL v1.2.2 +# licence: CC0-1.0, the LICENSE.md of usnistgov/oscal-content at that commit +# derived by: elixir scripts/nist_catalogues.exs --sp800-53 PATH --sp800-218 PATH +family PO Prepare the Organization +practice PO.1 active PO +task PO.1.1 active PO +task PO.1.2 active PO +task PO.1.3 active PO +practice PO.2 active PO +task PO.2.1 active PO +task PO.2.2 active PO +task PO.2.3 active PO +practice PO.3 active PO +task PO.3.1 active PO +task PO.3.2 active PO +task PO.3.3 active PO +practice PO.4 active PO +task PO.4.1 active PO +task PO.4.2 active PO +practice PO.5 active PO +task PO.5.1 active PO +task PO.5.2 active PO +family PS Protect Software +practice PS.1 active PS +task PS.1.1 active PS +practice PS.2 active PS +task PS.2.1 active PS +practice PS.3 active PS +task PS.3.1 active PS +task PS.3.2 active PS +family PW Produce Well-Secured Software +practice PW.1 active PW +task PW.1.1 active PW +task PW.1.2 active PW +task PW.1.3 active PW +practice PW.2 active PW +task PW.2.1 active PW +practice PW.4 active PW +task PW.4.1 active PW +task PW.4.2 active PW +task PW.4.4 active PW +practice PW.5 active PW +task PW.5.1 active PW +practice PW.6 active PW +task PW.6.1 active PW +task PW.6.2 active PW +practice PW.7 active PW +task PW.7.1 active PW +task PW.7.2 active PW +practice PW.8 active PW +task PW.8.1 active PW +task PW.8.2 active PW +practice PW.9 active PW +task PW.9.1 active PW +task PW.9.2 active PW +family RV Respond to Vulnerabilities +practice RV.1 active RV +task RV.1.1 active RV +task RV.1.2 active RV +task RV.1.3 active RV +practice RV.2 active RV +task RV.2.1 active RV +task RV.2.2 active RV +practice RV.3 active RV +task RV.3.1 active RV +task RV.3.2 active RV +task RV.3.3 active RV +task RV.3.4 active RV diff --git a/test/support/fixtures/nist/sp800-53r5.tsv b/test/support/fixtures/nist/sp800-53r5.tsv new file mode 100644 index 0000000..b7c6896 --- /dev/null +++ b/test/support/fixtures/nist/sp800-53r5.tsv @@ -0,0 +1,1223 @@ +# NIST SP 800-53 Rev. 5 controls and control enhancements. Derived; regenerate, never edit. +# source: https://raw.githubusercontent.com/usnistgov/oscal-content/78650f02ad9321bb7b817846f8fbd4f2bcd620de/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json +# commit: 78650f02ad9321bb7b817846f8fbd4f2bcd620de +# sha256: 01f37cf90ea99d92242c936cbfbdebcc338eef1f71454e2acac36cc56e9bc062 +# catalogue: version 5.2.0, last-modified 2026-05-11T16:01:09.00000-00:00, OSCAL 1.2.2 +# licence: CC0-1.0, the LICENSE.md of usnistgov/oscal-content at that commit +# derived by: elixir scripts/nist_catalogues.exs --sp800-53 PATH --sp800-218 PATH +family AC Access Control +control AC-1 active AC +control AC-2 active AC +control AC-2(1) active AC +control AC-2(2) active AC +control AC-2(3) active AC +control AC-2(4) active AC +control AC-2(5) active AC +control AC-2(6) active AC +control AC-2(7) active AC +control AC-2(8) active AC +control AC-2(9) active AC +control AC-2(10) withdrawn AC +control AC-2(11) active AC +control AC-2(12) active AC +control AC-2(13) active AC +control AC-3 active AC +control AC-3(1) withdrawn AC +control AC-3(2) active AC +control AC-3(3) active AC +control AC-3(4) active AC +control AC-3(5) active AC +control AC-3(6) withdrawn AC +control AC-3(7) active AC +control AC-3(8) active AC +control AC-3(9) active AC +control AC-3(10) active AC +control AC-3(11) active AC +control AC-3(12) active AC +control AC-3(13) active AC +control AC-3(14) active AC +control AC-3(15) active AC +control AC-4 active AC +control AC-4(1) active AC +control AC-4(2) active AC +control AC-4(3) active AC +control AC-4(4) active AC +control AC-4(5) active AC +control AC-4(6) active AC +control AC-4(7) active AC +control AC-4(8) active AC +control AC-4(9) active AC +control AC-4(10) active AC +control AC-4(11) active AC +control AC-4(12) active AC +control AC-4(13) active AC +control AC-4(14) active AC +control AC-4(15) active AC +control AC-4(16) withdrawn AC +control AC-4(17) active AC +control AC-4(18) withdrawn AC +control AC-4(19) active AC +control AC-4(20) active AC +control AC-4(21) active AC +control AC-4(22) active AC +control AC-4(23) active AC +control AC-4(24) active AC +control AC-4(25) active AC +control AC-4(26) active AC +control AC-4(27) active AC +control AC-4(28) active AC +control AC-4(29) active AC +control AC-4(30) active AC +control AC-4(31) active AC +control AC-4(32) active AC +control AC-5 active AC +control AC-6 active AC +control AC-6(1) active AC +control AC-6(2) active AC +control AC-6(3) active AC +control AC-6(4) active AC +control AC-6(5) active AC +control AC-6(6) active AC +control AC-6(7) active AC +control AC-6(8) active AC +control AC-6(9) active AC +control AC-6(10) active AC +control AC-7 active AC +control AC-7(1) withdrawn AC +control AC-7(2) active AC +control AC-7(3) active AC +control AC-7(4) active AC +control AC-8 active AC +control AC-9 active AC +control AC-9(1) active AC +control AC-9(2) active AC +control AC-9(3) active AC +control AC-9(4) active AC +control AC-10 active AC +control AC-11 active AC +control AC-11(1) active AC +control AC-12 active AC +control AC-12(1) active AC +control AC-12(2) active AC +control AC-12(3) active AC +control AC-13 withdrawn AC +control AC-14 active AC +control AC-14(1) withdrawn AC +control AC-15 withdrawn AC +control AC-16 active AC +control AC-16(1) active AC +control AC-16(2) active AC +control AC-16(3) active AC +control AC-16(4) active AC +control AC-16(5) active AC +control AC-16(6) active AC +control AC-16(7) active AC +control AC-16(8) active AC +control AC-16(9) active AC +control AC-16(10) active AC +control AC-17 active AC +control AC-17(1) active AC +control AC-17(2) active AC +control AC-17(3) active AC +control AC-17(4) active AC +control AC-17(5) withdrawn AC +control AC-17(6) active AC +control AC-17(7) withdrawn AC +control AC-17(8) withdrawn AC +control AC-17(9) active AC +control AC-17(10) active AC +control AC-18 active AC +control AC-18(1) active AC +control AC-18(2) withdrawn AC +control AC-18(3) active AC +control AC-18(4) active AC +control AC-18(5) active AC +control AC-19 active AC +control AC-19(1) withdrawn AC +control AC-19(2) withdrawn AC +control AC-19(3) withdrawn AC +control AC-19(4) active AC +control AC-19(5) active AC +control AC-20 active AC +control AC-20(1) active AC +control AC-20(2) active AC +control AC-20(3) active AC +control AC-20(4) active AC +control AC-20(5) active AC +control AC-21 active AC +control AC-21(1) active AC +control AC-21(2) active AC +control AC-22 active AC +control AC-23 active AC +control AC-24 active AC +control AC-24(1) active AC +control AC-24(2) active AC +control AC-25 active AC +family AT Awareness and Training +control AT-1 active AT +control AT-2 active AT +control AT-2(1) active AT +control AT-2(2) active AT +control AT-2(3) active AT +control AT-2(4) active AT +control AT-2(5) active AT +control AT-2(6) active AT +control AT-3 active AT +control AT-3(1) active AT +control AT-3(2) active AT +control AT-3(3) active AT +control AT-3(4) withdrawn AT +control AT-3(5) active AT +control AT-4 active AT +control AT-5 withdrawn AT +control AT-6 active AT +family AU Audit and Accountability +control AU-1 active AU +control AU-2 active AU +control AU-2(1) withdrawn AU +control AU-2(2) withdrawn AU +control AU-2(3) withdrawn AU +control AU-2(4) withdrawn AU +control AU-3 active AU +control AU-3(1) active AU +control AU-3(2) withdrawn AU +control AU-3(3) active AU +control AU-4 active AU +control AU-4(1) active AU +control AU-5 active AU +control AU-5(1) active AU +control AU-5(2) active AU +control AU-5(3) active AU +control AU-5(4) active AU +control AU-5(5) active AU +control AU-6 active AU +control AU-6(1) active AU +control AU-6(2) withdrawn AU +control AU-6(3) active AU +control AU-6(4) active AU +control AU-6(5) active AU +control AU-6(6) active AU +control AU-6(7) active AU +control AU-6(8) active AU +control AU-6(9) active AU +control AU-6(10) withdrawn AU +control AU-7 active AU +control AU-7(1) active AU +control AU-7(2) withdrawn AU +control AU-8 active AU +control AU-8(1) withdrawn AU +control AU-8(2) withdrawn AU +control AU-9 active AU +control AU-9(1) active AU +control AU-9(2) active AU +control AU-9(3) active AU +control AU-9(4) active AU +control AU-9(5) active AU +control AU-9(6) active AU +control AU-9(7) active AU +control AU-10 active AU +control AU-10(1) active AU +control AU-10(2) active AU +control AU-10(3) active AU +control AU-10(4) active AU +control AU-10(5) withdrawn AU +control AU-11 active AU +control AU-11(1) active AU +control AU-12 active AU +control AU-12(1) active AU +control AU-12(2) active AU +control AU-12(3) active AU +control AU-12(4) active AU +control AU-13 active AU +control AU-13(1) active AU +control AU-13(2) active AU +control AU-13(3) active AU +control AU-14 active AU +control AU-14(1) active AU +control AU-14(2) withdrawn AU +control AU-14(3) active AU +control AU-15 withdrawn AU +control AU-16 active AU +control AU-16(1) active AU +control AU-16(2) active AU +control AU-16(3) active AU +family CA Assessment, Authorization, and Monitoring +control CA-1 active CA +control CA-2 active CA +control CA-2(1) active CA +control CA-2(2) active CA +control CA-2(3) active CA +control CA-3 active CA +control CA-3(1) withdrawn CA +control CA-3(2) withdrawn CA +control CA-3(3) withdrawn CA +control CA-3(4) withdrawn CA +control CA-3(5) withdrawn CA +control CA-3(6) active CA +control CA-3(7) active CA +control CA-4 withdrawn CA +control CA-5 active CA +control CA-5(1) active CA +control CA-6 active CA +control CA-6(1) active CA +control CA-6(2) active CA +control CA-7 active CA +control CA-7(1) active CA +control CA-7(2) withdrawn CA +control CA-7(3) active CA +control CA-7(4) active CA +control CA-7(5) active CA +control CA-7(6) active CA +control CA-8 active CA +control CA-8(1) active CA +control CA-8(2) active CA +control CA-8(3) active CA +control CA-9 active CA +control CA-9(1) active CA +family CM Configuration Management +control CM-1 active CM +control CM-2 active CM +control CM-2(1) withdrawn CM +control CM-2(2) active CM +control CM-2(3) active CM +control CM-2(4) withdrawn CM +control CM-2(5) withdrawn CM +control CM-2(6) active CM +control CM-2(7) active CM +control CM-3 active CM +control CM-3(1) active CM +control CM-3(2) active CM +control CM-3(3) active CM +control CM-3(4) active CM +control CM-3(5) active CM +control CM-3(6) active CM +control CM-3(7) active CM +control CM-3(8) active CM +control CM-4 active CM +control CM-4(1) active CM +control CM-4(2) active CM +control CM-5 active CM +control CM-5(1) active CM +control CM-5(2) withdrawn CM +control CM-5(3) withdrawn CM +control CM-5(4) active CM +control CM-5(5) active CM +control CM-5(6) active CM +control CM-5(7) withdrawn CM +control CM-6 active CM +control CM-6(1) active CM +control CM-6(2) active CM +control CM-6(3) withdrawn CM +control CM-6(4) withdrawn CM +control CM-7 active CM +control CM-7(1) active CM +control CM-7(2) active CM +control CM-7(3) active CM +control CM-7(4) active CM +control CM-7(5) active CM +control CM-7(6) active CM +control CM-7(7) active CM +control CM-7(8) active CM +control CM-7(9) active CM +control CM-8 active CM +control CM-8(1) active CM +control CM-8(2) active CM +control CM-8(3) active CM +control CM-8(4) active CM +control CM-8(5) withdrawn CM +control CM-8(6) active CM +control CM-8(7) active CM +control CM-8(8) active CM +control CM-8(9) active CM +control CM-9 active CM +control CM-9(1) active CM +control CM-10 active CM +control CM-10(1) active CM +control CM-11 active CM +control CM-11(1) withdrawn CM +control CM-11(2) active CM +control CM-11(3) active CM +control CM-12 active CM +control CM-12(1) active CM +control CM-13 active CM +control CM-14 active CM +family CP Contingency Planning +control CP-1 active CP +control CP-2 active CP +control CP-2(1) active CP +control CP-2(2) active CP +control CP-2(3) active CP +control CP-2(4) withdrawn CP +control CP-2(5) active CP +control CP-2(6) active CP +control CP-2(7) active CP +control CP-2(8) active CP +control CP-3 active CP +control CP-3(1) active CP +control CP-3(2) active CP +control CP-4 active CP +control CP-4(1) active CP +control CP-4(2) active CP +control CP-4(3) active CP +control CP-4(4) active CP +control CP-4(5) active CP +control CP-5 withdrawn CP +control CP-6 active CP +control CP-6(1) active CP +control CP-6(2) active CP +control CP-6(3) active CP +control CP-7 active CP +control CP-7(1) active CP +control CP-7(2) active CP +control CP-7(3) active CP +control CP-7(4) active CP +control CP-7(5) withdrawn CP +control CP-7(6) active CP +control CP-8 active CP +control CP-8(1) active CP +control CP-8(2) active CP +control CP-8(3) active CP +control CP-8(4) active CP +control CP-8(5) active CP +control CP-9 active CP +control CP-9(1) active CP +control CP-9(2) active CP +control CP-9(3) active CP +control CP-9(4) withdrawn CP +control CP-9(5) active CP +control CP-9(6) active CP +control CP-9(7) active CP +control CP-9(8) active CP +control CP-10 active CP +control CP-10(1) withdrawn CP +control CP-10(2) active CP +control CP-10(3) withdrawn CP +control CP-10(4) active CP +control CP-10(5) withdrawn CP +control CP-10(6) active CP +control CP-11 active CP +control CP-12 active CP +control CP-13 active CP +family IA Identification and Authentication +control IA-1 active IA +control IA-2 active IA +control IA-2(1) active IA +control IA-2(2) active IA +control IA-2(3) withdrawn IA +control IA-2(4) withdrawn IA +control IA-2(5) active IA +control IA-2(6) active IA +control IA-2(7) withdrawn IA +control IA-2(8) active IA +control IA-2(9) withdrawn IA +control IA-2(10) active IA +control IA-2(11) withdrawn IA +control IA-2(12) active IA +control IA-2(13) active IA +control IA-3 active IA +control IA-3(1) active IA +control IA-3(2) withdrawn IA +control IA-3(3) active IA +control IA-3(4) active IA +control IA-4 active IA +control IA-4(1) active IA +control IA-4(2) withdrawn IA +control IA-4(3) withdrawn IA +control IA-4(4) active IA +control IA-4(5) active IA +control IA-4(6) active IA +control IA-4(7) withdrawn IA +control IA-4(8) active IA +control IA-4(9) active IA +control IA-5 active IA +control IA-5(1) active IA +control IA-5(2) active IA +control IA-5(3) withdrawn IA +control IA-5(4) withdrawn IA +control IA-5(5) active IA +control IA-5(6) active IA +control IA-5(7) active IA +control IA-5(8) active IA +control IA-5(9) active IA +control IA-5(10) active IA +control IA-5(11) withdrawn IA +control IA-5(12) active IA +control IA-5(13) active IA +control IA-5(14) active IA +control IA-5(15) active IA +control IA-5(16) active IA +control IA-5(17) active IA +control IA-5(18) active IA +control IA-6 active IA +control IA-7 active IA +control IA-8 active IA +control IA-8(1) active IA +control IA-8(2) active IA +control IA-8(3) withdrawn IA +control IA-8(4) active IA +control IA-8(5) active IA +control IA-8(6) active IA +control IA-9 active IA +control IA-9(1) withdrawn IA +control IA-9(2) withdrawn IA +control IA-10 active IA +control IA-11 active IA +control IA-12 active IA +control IA-12(1) active IA +control IA-12(2) active IA +control IA-12(3) active IA +control IA-12(4) active IA +control IA-12(5) active IA +control IA-12(6) active IA +control IA-13 active IA +control IA-13(1) active IA +control IA-13(2) active IA +control IA-13(3) active IA +family IR Incident Response +control IR-1 active IR +control IR-2 active IR +control IR-2(1) active IR +control IR-2(2) active IR +control IR-2(3) active IR +control IR-3 active IR +control IR-3(1) active IR +control IR-3(2) active IR +control IR-3(3) active IR +control IR-4 active IR +control IR-4(1) active IR +control IR-4(2) active IR +control IR-4(3) active IR +control IR-4(4) active IR +control IR-4(5) active IR +control IR-4(6) active IR +control IR-4(7) active IR +control IR-4(8) active IR +control IR-4(9) active IR +control IR-4(10) active IR +control IR-4(11) active IR +control IR-4(12) active IR +control IR-4(13) active IR +control IR-4(14) active IR +control IR-4(15) active IR +control IR-5 active IR +control IR-5(1) active IR +control IR-6 active IR +control IR-6(1) active IR +control IR-6(2) active IR +control IR-6(3) active IR +control IR-7 active IR +control IR-7(1) active IR +control IR-7(2) active IR +control IR-8 active IR +control IR-8(1) active IR +control IR-9 active IR +control IR-9(1) withdrawn IR +control IR-9(2) active IR +control IR-9(3) active IR +control IR-9(4) active IR +control IR-10 withdrawn IR +family MA Maintenance +control MA-1 active MA +control MA-2 active MA +control MA-2(1) withdrawn MA +control MA-2(2) active MA +control MA-3 active MA +control MA-3(1) active MA +control MA-3(2) active MA +control MA-3(3) active MA +control MA-3(4) active MA +control MA-3(5) active MA +control MA-3(6) active MA +control MA-4 active MA +control MA-4(1) active MA +control MA-4(2) withdrawn MA +control MA-4(3) active MA +control MA-4(4) active MA +control MA-4(5) active MA +control MA-4(6) active MA +control MA-4(7) active MA +control MA-5 active MA +control MA-5(1) active MA +control MA-5(2) active MA +control MA-5(3) active MA +control MA-5(4) active MA +control MA-5(5) active MA +control MA-6 active MA +control MA-6(1) active MA +control MA-6(2) active MA +control MA-6(3) active MA +control MA-7 active MA +family MP Media Protection +control MP-1 active MP +control MP-2 active MP +control MP-2(1) withdrawn MP +control MP-2(2) withdrawn MP +control MP-3 active MP +control MP-4 active MP +control MP-4(1) withdrawn MP +control MP-4(2) active MP +control MP-5 active MP +control MP-5(1) withdrawn MP +control MP-5(2) withdrawn MP +control MP-5(3) active MP +control MP-5(4) withdrawn MP +control MP-6 active MP +control MP-6(1) active MP +control MP-6(2) active MP +control MP-6(3) active MP +control MP-6(4) withdrawn MP +control MP-6(5) withdrawn MP +control MP-6(6) withdrawn MP +control MP-6(7) active MP +control MP-6(8) active MP +control MP-7 active MP +control MP-7(1) withdrawn MP +control MP-7(2) active MP +control MP-8 active MP +control MP-8(1) active MP +control MP-8(2) active MP +control MP-8(3) active MP +control MP-8(4) active MP +family PE Physical and Environmental Protection +control PE-1 active PE +control PE-2 active PE +control PE-2(1) active PE +control PE-2(2) active PE +control PE-2(3) active PE +control PE-3 active PE +control PE-3(1) active PE +control PE-3(2) active PE +control PE-3(3) active PE +control PE-3(4) active PE +control PE-3(5) active PE +control PE-3(6) withdrawn PE +control PE-3(7) active PE +control PE-3(8) active PE +control PE-4 active PE +control PE-5 active PE +control PE-5(1) withdrawn PE +control PE-5(2) active PE +control PE-5(3) withdrawn PE +control PE-6 active PE +control PE-6(1) active PE +control PE-6(2) active PE +control PE-6(3) active PE +control PE-6(4) active PE +control PE-7 withdrawn PE +control PE-8 active PE +control PE-8(1) active PE +control PE-8(2) withdrawn PE +control PE-8(3) active PE +control PE-9 active PE +control PE-9(1) active PE +control PE-9(2) active PE +control PE-10 active PE +control PE-10(1) withdrawn PE +control PE-11 active PE +control PE-11(1) active PE +control PE-11(2) active PE +control PE-12 active PE +control PE-12(1) active PE +control PE-13 active PE +control PE-13(1) active PE +control PE-13(2) active PE +control PE-13(3) withdrawn PE +control PE-13(4) active PE +control PE-14 active PE +control PE-14(1) active PE +control PE-14(2) active PE +control PE-15 active PE +control PE-15(1) active PE +control PE-16 active PE +control PE-17 active PE +control PE-18 active PE +control PE-18(1) withdrawn PE +control PE-19 active PE +control PE-19(1) active PE +control PE-20 active PE +control PE-21 active PE +control PE-22 active PE +control PE-23 active PE +family PL Planning +control PL-1 active PL +control PL-2 active PL +control PL-2(1) withdrawn PL +control PL-2(2) withdrawn PL +control PL-2(3) withdrawn PL +control PL-3 withdrawn PL +control PL-4 active PL +control PL-4(1) active PL +control PL-5 withdrawn PL +control PL-6 withdrawn PL +control PL-7 active PL +control PL-8 active PL +control PL-8(1) active PL +control PL-8(2) active PL +control PL-9 active PL +control PL-10 active PL +control PL-11 active PL +family PM Program Management +control PM-1 active PM +control PM-2 active PM +control PM-3 active PM +control PM-4 active PM +control PM-5 active PM +control PM-5(1) active PM +control PM-6 active PM +control PM-7 active PM +control PM-7(1) active PM +control PM-8 active PM +control PM-9 active PM +control PM-10 active PM +control PM-11 active PM +control PM-12 active PM +control PM-13 active PM +control PM-14 active PM +control PM-15 active PM +control PM-16 active PM +control PM-16(1) active PM +control PM-17 active PM +control PM-18 active PM +control PM-19 active PM +control PM-20 active PM +control PM-20(1) active PM +control PM-21 active PM +control PM-22 active PM +control PM-23 active PM +control PM-24 active PM +control PM-25 active PM +control PM-26 active PM +control PM-27 active PM +control PM-28 active PM +control PM-29 active PM +control PM-30 active PM +control PM-30(1) active PM +control PM-31 active PM +control PM-32 active PM +family PS Personnel Security +control PS-1 active PS +control PS-2 active PS +control PS-3 active PS +control PS-3(1) active PS +control PS-3(2) active PS +control PS-3(3) active PS +control PS-3(4) active PS +control PS-4 active PS +control PS-4(1) active PS +control PS-4(2) active PS +control PS-5 active PS +control PS-6 active PS +control PS-6(1) withdrawn PS +control PS-6(2) active PS +control PS-6(3) active PS +control PS-7 active PS +control PS-8 active PS +control PS-9 active PS +family PT Personally Identifiable Information Processing and Transparency +control PT-1 active PT +control PT-2 active PT +control PT-2(1) active PT +control PT-2(2) active PT +control PT-3 active PT +control PT-3(1) active PT +control PT-3(2) active PT +control PT-4 active PT +control PT-4(1) active PT +control PT-4(2) active PT +control PT-4(3) active PT +control PT-5 active PT +control PT-5(1) active PT +control PT-5(2) active PT +control PT-6 active PT +control PT-6(1) active PT +control PT-6(2) active PT +control PT-7 active PT +control PT-7(1) active PT +control PT-7(2) active PT +control PT-8 active PT +family RA Risk Assessment +control RA-1 active RA +control RA-2 active RA +control RA-2(1) active RA +control RA-3 active RA +control RA-3(1) active RA +control RA-3(2) active RA +control RA-3(3) active RA +control RA-3(4) active RA +control RA-4 withdrawn RA +control RA-5 active RA +control RA-5(1) withdrawn RA +control RA-5(2) active RA +control RA-5(3) active RA +control RA-5(4) active RA +control RA-5(5) active RA +control RA-5(6) active RA +control RA-5(7) withdrawn RA +control RA-5(8) active RA +control RA-5(9) withdrawn RA +control RA-5(10) active RA +control RA-5(11) active RA +control RA-6 active RA +control RA-7 active RA +control RA-8 active RA +control RA-9 active RA +control RA-10 active RA +family SA System and Services Acquisition +control SA-1 active SA +control SA-2 active SA +control SA-3 active SA +control SA-3(1) active SA +control SA-3(2) active SA +control SA-3(3) active SA +control SA-4 active SA +control SA-4(1) active SA +control SA-4(2) active SA +control SA-4(3) active SA +control SA-4(4) withdrawn SA +control SA-4(5) active SA +control SA-4(6) active SA +control SA-4(7) active SA +control SA-4(8) active SA +control SA-4(9) active SA +control SA-4(10) active SA +control SA-4(11) active SA +control SA-4(12) active SA +control SA-5 active SA +control SA-5(1) withdrawn SA +control SA-5(2) withdrawn SA +control SA-5(3) withdrawn SA +control SA-5(4) withdrawn SA +control SA-5(5) withdrawn SA +control SA-6 withdrawn SA +control SA-7 withdrawn SA +control SA-8 active SA +control SA-8(1) active SA +control SA-8(2) active SA +control SA-8(3) active SA +control SA-8(4) active SA +control SA-8(5) active SA +control SA-8(6) active SA +control SA-8(7) active SA +control SA-8(8) active SA +control SA-8(9) active SA +control SA-8(10) active SA +control SA-8(11) active SA +control SA-8(12) active SA +control SA-8(13) active SA +control SA-8(14) active SA +control SA-8(15) active SA +control SA-8(16) active SA +control SA-8(17) active SA +control SA-8(18) active SA +control SA-8(19) active SA +control SA-8(20) active SA +control SA-8(21) active SA +control SA-8(22) active SA +control SA-8(23) active SA +control SA-8(24) active SA +control SA-8(25) active SA +control SA-8(26) active SA +control SA-8(27) active SA +control SA-8(28) active SA +control SA-8(29) active SA +control SA-8(30) active SA +control SA-8(31) active SA +control SA-8(32) active SA +control SA-8(33) active SA +control SA-9 active SA +control SA-9(1) active SA +control SA-9(2) active SA +control SA-9(3) active SA +control SA-9(4) active SA +control SA-9(5) active SA +control SA-9(6) active SA +control SA-9(7) active SA +control SA-9(8) active SA +control SA-10 active SA +control SA-10(1) active SA +control SA-10(2) active SA +control SA-10(3) active SA +control SA-10(4) active SA +control SA-10(5) active SA +control SA-10(6) active SA +control SA-10(7) active SA +control SA-11 active SA +control SA-11(1) active SA +control SA-11(2) active SA +control SA-11(3) active SA +control SA-11(4) active SA +control SA-11(5) active SA +control SA-11(6) active SA +control SA-11(7) active SA +control SA-11(8) active SA +control SA-11(9) active SA +control SA-12 withdrawn SA +control SA-12(1) withdrawn SA +control SA-12(2) withdrawn SA +control SA-12(3) withdrawn SA +control SA-12(4) withdrawn SA +control SA-12(5) withdrawn SA +control SA-12(6) withdrawn SA +control SA-12(7) withdrawn SA +control SA-12(8) withdrawn SA +control SA-12(9) withdrawn SA +control SA-12(10) withdrawn SA +control SA-12(11) withdrawn SA +control SA-12(12) withdrawn SA +control SA-12(13) withdrawn SA +control SA-12(14) withdrawn SA +control SA-12(15) withdrawn SA +control SA-13 withdrawn SA +control SA-14 withdrawn SA +control SA-14(1) withdrawn SA +control SA-15 active SA +control SA-15(1) active SA +control SA-15(2) active SA +control SA-15(3) active SA +control SA-15(4) withdrawn SA +control SA-15(5) active SA +control SA-15(6) active SA +control SA-15(7) active SA +control SA-15(8) active SA +control SA-15(9) withdrawn SA +control SA-15(10) active SA +control SA-15(11) active SA +control SA-15(12) active SA +control SA-15(13) active SA +control SA-16 active SA +control SA-17 active SA +control SA-17(1) active SA +control SA-17(2) active SA +control SA-17(3) active SA +control SA-17(4) active SA +control SA-17(5) active SA +control SA-17(6) active SA +control SA-17(7) active SA +control SA-17(8) active SA +control SA-17(9) active SA +control SA-18 withdrawn SA +control SA-18(1) withdrawn SA +control SA-18(2) withdrawn SA +control SA-19 withdrawn SA +control SA-19(1) withdrawn SA +control SA-19(2) withdrawn SA +control SA-19(3) withdrawn SA +control SA-19(4) withdrawn SA +control SA-20 active SA +control SA-21 active SA +control SA-21(1) withdrawn SA +control SA-22 active SA +control SA-22(1) withdrawn SA +control SA-23 active SA +control SA-24 active SA +family SC System and Communications Protection +control SC-1 active SC +control SC-2 active SC +control SC-2(1) active SC +control SC-2(2) active SC +control SC-3 active SC +control SC-3(1) active SC +control SC-3(2) active SC +control SC-3(3) active SC +control SC-3(4) active SC +control SC-3(5) active SC +control SC-4 active SC +control SC-4(1) withdrawn SC +control SC-4(2) active SC +control SC-5 active SC +control SC-5(1) active SC +control SC-5(2) active SC +control SC-5(3) active SC +control SC-6 active SC +control SC-7 active SC +control SC-7(1) withdrawn SC +control SC-7(2) withdrawn SC +control SC-7(3) active SC +control SC-7(4) active SC +control SC-7(5) active SC +control SC-7(6) withdrawn SC +control SC-7(7) active SC +control SC-7(8) active SC +control SC-7(9) active SC +control SC-7(10) active SC +control SC-7(11) active SC +control SC-7(12) active SC +control SC-7(13) active SC +control SC-7(14) active SC +control SC-7(15) active SC +control SC-7(16) active SC +control SC-7(17) active SC +control SC-7(18) active SC +control SC-7(19) active SC +control SC-7(20) active SC +control SC-7(21) active SC +control SC-7(22) active SC +control SC-7(23) active SC +control SC-7(24) active SC +control SC-7(25) active SC +control SC-7(26) active SC +control SC-7(27) active SC +control SC-7(28) active SC +control SC-7(29) active SC +control SC-8 active SC +control SC-8(1) active SC +control SC-8(2) active SC +control SC-8(3) active SC +control SC-8(4) active SC +control SC-8(5) active SC +control SC-9 withdrawn SC +control SC-10 active SC +control SC-11 active SC +control SC-11(1) active SC +control SC-12 active SC +control SC-12(1) active SC +control SC-12(2) active SC +control SC-12(3) active SC +control SC-12(4) withdrawn SC +control SC-12(5) withdrawn SC +control SC-12(6) active SC +control SC-13 active SC +control SC-13(1) withdrawn SC +control SC-13(2) withdrawn SC +control SC-13(3) withdrawn SC +control SC-13(4) withdrawn SC +control SC-14 withdrawn SC +control SC-15 active SC +control SC-15(1) active SC +control SC-15(2) withdrawn SC +control SC-15(3) active SC +control SC-15(4) active SC +control SC-16 active SC +control SC-16(1) active SC +control SC-16(2) active SC +control SC-16(3) active SC +control SC-17 active SC +control SC-18 active SC +control SC-18(1) active SC +control SC-18(2) active SC +control SC-18(3) active SC +control SC-18(4) active SC +control SC-18(5) active SC +control SC-19 withdrawn SC +control SC-20 active SC +control SC-20(1) withdrawn SC +control SC-20(2) active SC +control SC-21 active SC +control SC-21(1) withdrawn SC +control SC-22 active SC +control SC-23 active SC +control SC-23(1) active SC +control SC-23(2) withdrawn SC +control SC-23(3) active SC +control SC-23(4) withdrawn SC +control SC-23(5) active SC +control SC-24 active SC +control SC-25 active SC +control SC-26 active SC +control SC-26(1) withdrawn SC +control SC-27 active SC +control SC-28 active SC +control SC-28(1) active SC +control SC-28(2) active SC +control SC-28(3) active SC +control SC-29 active SC +control SC-29(1) active SC +control SC-30 active SC +control SC-30(1) withdrawn SC +control SC-30(2) active SC +control SC-30(3) active SC +control SC-30(4) active SC +control SC-30(5) active SC +control SC-31 active SC +control SC-31(1) active SC +control SC-31(2) active SC +control SC-31(3) active SC +control SC-32 active SC +control SC-32(1) active SC +control SC-33 withdrawn SC +control SC-34 active SC +control SC-34(1) active SC +control SC-34(2) active SC +control SC-34(3) withdrawn SC +control SC-35 active SC +control SC-36 active SC +control SC-36(1) active SC +control SC-36(2) active SC +control SC-37 active SC +control SC-37(1) active SC +control SC-38 active SC +control SC-39 active SC +control SC-39(1) active SC +control SC-39(2) active SC +control SC-40 active SC +control SC-40(1) active SC +control SC-40(2) active SC +control SC-40(3) active SC +control SC-40(4) active SC +control SC-41 active SC +control SC-42 active SC +control SC-42(1) active SC +control SC-42(2) active SC +control SC-42(3) withdrawn SC +control SC-42(4) active SC +control SC-42(5) active SC +control SC-43 active SC +control SC-44 active SC +control SC-45 active SC +control SC-45(1) active SC +control SC-45(2) active SC +control SC-46 active SC +control SC-47 active SC +control SC-48 active SC +control SC-48(1) active SC +control SC-49 active SC +control SC-50 active SC +control SC-51 active SC +family SI System and Information Integrity +control SI-1 active SI +control SI-2 active SI +control SI-2(1) withdrawn SI +control SI-2(2) active SI +control SI-2(3) active SI +control SI-2(4) active SI +control SI-2(5) active SI +control SI-2(6) active SI +control SI-2(7) active SI +control SI-3 active SI +control SI-3(1) withdrawn SI +control SI-3(2) withdrawn SI +control SI-3(3) withdrawn SI +control SI-3(4) active SI +control SI-3(5) withdrawn SI +control SI-3(6) active SI +control SI-3(7) withdrawn SI +control SI-3(8) active SI +control SI-3(9) withdrawn SI +control SI-3(10) active SI +control SI-4 active SI +control SI-4(1) active SI +control SI-4(2) active SI +control SI-4(3) active SI +control SI-4(4) active SI +control SI-4(5) active SI +control SI-4(6) withdrawn SI +control SI-4(7) active SI +control SI-4(8) withdrawn SI +control SI-4(9) active SI +control SI-4(10) active SI +control SI-4(11) active SI +control SI-4(12) active SI +control SI-4(13) active SI +control SI-4(14) active SI +control SI-4(15) active SI +control SI-4(16) active SI +control SI-4(17) active SI +control SI-4(18) active SI +control SI-4(19) active SI +control SI-4(20) active SI +control SI-4(21) active SI +control SI-4(22) active SI +control SI-4(23) active SI +control SI-4(24) active SI +control SI-4(25) active SI +control SI-5 active SI +control SI-5(1) active SI +control SI-6 active SI +control SI-6(1) withdrawn SI +control SI-6(2) active SI +control SI-6(3) active SI +control SI-7 active SI +control SI-7(1) active SI +control SI-7(2) active SI +control SI-7(3) active SI +control SI-7(4) withdrawn SI +control SI-7(5) active SI +control SI-7(6) active SI +control SI-7(7) active SI +control SI-7(8) active SI +control SI-7(9) active SI +control SI-7(10) active SI +control SI-7(11) withdrawn SI +control SI-7(12) active SI +control SI-7(13) withdrawn SI +control SI-7(14) withdrawn SI +control SI-7(15) active SI +control SI-7(16) active SI +control SI-7(17) active SI +control SI-8 active SI +control SI-8(1) withdrawn SI +control SI-8(2) active SI +control SI-8(3) active SI +control SI-9 withdrawn SI +control SI-10 active SI +control SI-10(1) active SI +control SI-10(2) active SI +control SI-10(3) active SI +control SI-10(4) active SI +control SI-10(5) active SI +control SI-10(6) active SI +control SI-11 active SI +control SI-12 active SI +control SI-12(1) active SI +control SI-12(2) active SI +control SI-12(3) active SI +control SI-13 active SI +control SI-13(1) active SI +control SI-13(2) withdrawn SI +control SI-13(3) active SI +control SI-13(4) active SI +control SI-13(5) active SI +control SI-14 active SI +control SI-14(1) active SI +control SI-14(2) active SI +control SI-14(3) active SI +control SI-15 active SI +control SI-16 active SI +control SI-17 active SI +control SI-18 active SI +control SI-18(1) active SI +control SI-18(2) active SI +control SI-18(3) active SI +control SI-18(4) active SI +control SI-18(5) active SI +control SI-19 active SI +control SI-19(1) active SI +control SI-19(2) active SI +control SI-19(3) active SI +control SI-19(4) active SI +control SI-19(5) active SI +control SI-19(6) active SI +control SI-19(7) active SI +control SI-19(8) active SI +control SI-20 active SI +control SI-21 active SI +control SI-22 active SI +control SI-23 active SI +family SR Supply Chain Risk Management +control SR-1 active SR +control SR-2 active SR +control SR-2(1) active SR +control SR-3 active SR +control SR-3(1) active SR +control SR-3(2) active SR +control SR-3(3) active SR +control SR-4 active SR +control SR-4(1) active SR +control SR-4(2) active SR +control SR-4(3) active SR +control SR-4(4) active SR +control SR-5 active SR +control SR-5(1) active SR +control SR-5(2) active SR +control SR-6 active SR +control SR-6(1) active SR +control SR-7 active SR +control SR-8 active SR +control SR-9 active SR +control SR-9(1) active SR +control SR-10 active SR +control SR-11 active SR +control SR-11(1) active SR +control SR-11(2) active SR +control SR-11(3) active SR +control SR-12 active SR From 69cbfe8d8c17455819ff1ba386bfac68c1a08013 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 09:16:53 -0400 Subject: [PATCH 2/2] fix(s132): SC-8 and IA-2 rows on the owner's ruling SC-8 for connections Trinity makes is shared: its test checks the offered versions only, and the trust store is the deployment's. The inbound TLS rows name force_ssl's trust of x-forwarded-proto. The web pages' IA-2 row is shared and marked Partial: a product gap tracked as slice 136, with a proxy only as an interim compensating control, not a customer duty. Signed-off-by: Ayla Croft --- docs/regulated/control-mapping.md | 6 +++--- docs/regulated/customer-responsibility-matrix.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/regulated/control-mapping.md b/docs/regulated/control-mapping.md index 7429399..25186ea 100644 --- a/docs/regulated/control-mapping.md +++ b/docs/regulated/control-mapping.md @@ -93,7 +93,7 @@ register does not claim is never Trinity's. | `IA-2` | Trinity is never the identity provider for a deployment's users; it validates the tokens the deployment's provider issues | `lib/trinity/mcp/auth.ex` | none | the deployment's | Operating the identity provider, and authenticating users there | CRM "Operating the IdP" | | `IA-2` | An inbound MCP request is refused unless it carries a token the configured issuer signed for this resource: a wrong audience, an expired token and the static bearer are refused, each with a receipt | `lib/trinity/mcp/auth.ex`, `lib/trinity/mcp/auth/token.ex` | `test/trinity/mcp/auth/resource_server_test.exs` "AC1: 401 with resource_metadata; the PRM names the AS; wrong audience, expired and the static bearer are 401; each refusal receipted"; `test/trinity/mcp/auth/token_test.exs` "each claim check is its own refusal" | shared | The issuer, audience and resource Trinity is configured to accept | CRM "Validating a bearer token on an inbound MCP request" | | `IA-2` | Under the production profile Trinity mints no token and holds no issuer key; the personal issuer refuses to start under an external authority, and under `TRINITY_PROFILE=regulated` | `lib/trinity/mcp/auth/embedded.ex`, `lib/trinity/profile.ex` | `Trinity.Profile.check_embedded_as/2`; `test/trinity/mcp/auth/embedded_test.exs` "the production profile holds no key material of its own: no keys, no JWKS, no AS metadata"; `test/trinity/mcp/auth/embedded_test.exs` "the personal profile refuses to start under an external authority adapter" | shared | The identity provider that does issue the tokens | CRM "Choosing the profile"; Register "Trinity issues no production authority": tree property | -| `IA-2` | The web pages, among them the permissions page where approvals are answered, carry no authentication of their own; the bearer is required on `/mcp` only, and the image binds every interface | `lib/trinity_web/router.ex`, `docs/mcp-server.md`, `ci/ironbank/Dockerfile` | none | the deployment's | An authenticating reverse proxy in front of the port, or a network that only the owner reaches | CRM "Authenticating access to the web pages" | +| `IA-2` | Partial. The web pages, among them the permissions page where approvals are answered, carry no authentication of their own (a product gap, tracked as slice 136); the bearer is required on `/mcp` only, and the image binds every interface | `lib/trinity_web/router.ex`, `docs/mcp-server.md`, `ci/ironbank/Dockerfile` | none | shared | The OIDC issuer; until slice 136, an authenticating reverse proxy or an owner-only network as an interim compensating control with a POA&M entry | CRM "Authenticating access to the web pages" | | `IA-5(7)` | The gate fails on a credential-shaped string anywhere in the tracked tree | `lib/mix/tasks/trinity.secrets.scan.ex` | `mix trinity.secrets.scan`; `test/secrets_scan_test.exs` "RED: a planted fake key of each shape is found" | Trinity's | none | none | | `IA-11` | An expired token is refused, and with introspection enabled the issuer is asked about each token; a client then goes back to the identity provider | `lib/trinity/mcp/auth/token.ex`, `config/runtime.exs` | `test/trinity/mcp/auth/token_test.exs` "introspection: an opaque token is asked about at the issuer (RFC 7662), the answer's claims checked as a JWT's are" | shared | Token lifetime, revocation and re-authentication policy, at the identity provider | CRM "Token revocation and session lifetime" | | `IR-6` | Trinity reports nothing to anyone; `incident.md` sets out the clocks that may apply and what the record gives an investigator | `docs/regulated/incident.md` | none | the deployment's | Reporting, within whatever clock applies | CRM "Reporting to a government customer within a clock" | @@ -104,8 +104,8 @@ register does not claim is never Trinity's. | `SA-11` | The test suite cannot reach the network: outside the opt-in live run, a connect to a listener that is definitely there is refused | `test/support/network_guard.ex` | `test/network_guard_test.exs` "RED: the default test run refuses a connect to a listener that is definitely there" | Trinity's | none | CRM "Suppressing network access in the test suite" | | `SC-7` | Nothing in the tree restricts which hosts the process may reach | `docs/regulated/authorization-boundary.md` | none | the deployment's | Security groups, an egress proxy or a firewall | CRM "Restricting which hosts the process may reach" | | `SC-7` | The model endpoint is outside the boundary unless the deployment puts it inside | `docs/regulated/authorization-boundary.md` | none | the deployment's | Self-hosting the model, where that is required | CRM "Keeping the model inside your boundary" | -| `SC-8` | Connections Trinity makes offer TLS 1.2 and 1.3 and nothing older | `README.md` | `test/supply_chain_test.exs` "the runtime's default TLS versions are 1.2 and 1.3, and nothing older" | Trinity's | none | CRM "TLS on connections Trinity makes"; Register "TLS floor 1.2 in FIPS mode": :unknown | -| `SC-8` | The headless release serves plain HTTP on `TRINITY_BIND`: the loopback by default, every interface in the image | `config/runtime.exs`, `ci/ironbank/Dockerfile` | none | the deployment's | TLS in front of the process | CRM "TLS on connections made to Trinity" | +| `SC-8` | Connections Trinity makes offer TLS 1.2 and 1.3 and nothing older; the check covers the versions offered, not peer verification or the trust store | `README.md` | `test/supply_chain_test.exs` "the runtime's default TLS versions are 1.2 and 1.3, and nothing older" | shared | The trust store peers are verified against | CRM "TLS on connections Trinity makes"; Register "TLS floor 1.2 in FIPS mode": :unknown | +| `SC-8` | The headless release serves plain HTTP on `TRINITY_BIND`: the loopback by default, every interface in the image; `force_ssl` trusts `x-forwarded-proto`, a header any client can send unless a proxy strips it | `config/runtime.exs`, `ci/ironbank/Dockerfile`, `config/prod.exs` | none | the deployment's | TLS in front of the process, and a proxy that strips inbound `x-forwarded-*` headers | CRM "TLS on connections made to Trinity" | | `SC-12` | The receipt signing key is generated once, mode 0600, registered, and read at every signature by the custody module and by nothing else | `lib/trinity/receipts/key_custody.ex`, `lib/trinity/keys.ex` | `test/trinity/receipts/signer_test.exs` "boot generates the key once (0600), appends its registry row, and a second boot reuses it"; `test/trinity/keys/custody_census_test.exs` "no module outside the custody seam reads key material from disk" | Trinity's | none | CRM "Receipt signing key generation and use" | | `SC-12` | Key material lives in the data directory's keys directory by default, sealed when a key source is configured | `lib/trinity/paths.ex`, `lib/trinity/keys/local.ex` | `test/trinity/keys/signer_seam_test.exs` "the signing key is ciphertext on disk and the registry says so" | shared | Where the keys live, and who can read them | CRM "Where key material lives"; Register "Exclusive deployment control of encryption keys": :unknown | | `SC-12` | Keys pass through one behaviour, so a KMS or HSM adapter is a module and a configuration line; none ships in this tree | `lib/trinity/keys.ex` | `test/trinity/keys/signer_seam_test.exs` "a sealed key still signs, which is the point of sealing it" | shared | A KMS or HSM adapter, where one is required | CRM "A KMS or HSM adapter" | diff --git a/docs/regulated/customer-responsibility-matrix.md b/docs/regulated/customer-responsibility-matrix.md index eddb628..ee959c5 100644 --- a/docs/regulated/customer-responsibility-matrix.md +++ b/docs/regulated/customer-responsibility-matrix.md @@ -64,9 +64,9 @@ deployment, and it does not supply what the deployment has to. | Control | Trinity software | Customer | Host or AWS | |---|---|---|---| -| TLS on connections Trinity makes | **Yes.** The runtime offers TLS 1.2 and 1.3 and nothing older, `test/supply_chain_test.exs` | No | No | -| TLS on connections made to Trinity | **No.** The headless release serves plain HTTP on `TRINITY_BIND`: the loopback by default (`config/runtime.exs`), every interface in the image (`ci/ironbank/Dockerfile`) | **Yes.** Terminate TLS in front of it | **Yes** | -| Authenticating access to the web pages | **No.** The bearer is required on `/mcp`; the web pages, among them the permissions page where approvals are answered, carry no authentication of their own (`lib/trinity_web/router.ex`, `docs/mcp-server.md`). The image binds every interface | **Yes.** An authenticating reverse proxy in front of the port, or a network that only the owner reaches | **Yes** | +| TLS on connections Trinity makes | **Yes.** The runtime offers TLS 1.2 and 1.3 and nothing older, `test/supply_chain_test.exs`. That test checks the versions offered and nothing else: not peer verification, not the trust store | **Yes.** The trust store peers are verified against (in a DoD deployment, the DoD roots) | No | +| TLS on connections made to Trinity | **No.** The headless release serves plain HTTP on `TRINITY_BIND`: the loopback by default (`config/runtime.exs`), every interface in the image (`ci/ironbank/Dockerfile`). `config/prod.exs` sets `force_ssl` with `rewrite_on: [:x_forwarded_proto]`, so a request carrying `x-forwarded-proto: https` is treated as HTTPS, and any client can send that header unless a proxy strips it | **Yes.** Terminate TLS in front of it, and strip inbound `x-forwarded-*` headers at that proxy | **Yes** | +| Authenticating access to the web pages | **Partial.** MCP endpoints require a token from the external issuer (implemented). The web pages, among them the permissions page where approvals are answered, carry no authentication of their own (`lib/trinity_web/router.ex`, `docs/mcp-server.md`): a product gap, tracked as slice 136, which authenticates them against the same issuer. The image binds every interface | **Yes.** The OIDC issuer. Until slice 136 lands, an authenticating reverse proxy in front of the port, or a network only the owner reaches, as an interim compensating control with a POA&M entry, never the sole mitigation | **Yes** | ## Backups