trooth 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +160 -108
  2. package/bin/trooth.mjs +309 -71
  3. package/package.json +3 -5
package/README.md CHANGED
@@ -1,59 +1,56 @@
1
1
  # trooth
2
2
 
3
- The Trooth Network from your terminal.
3
+ The Trooth Network from your terminal. Published on npm as **`trooth`**.
4
4
 
5
- The Trooth Network gives every company one public, signed record: what it is,
6
- what it sells, who runs it, where its data lives, how it handles AI, and what a
7
- buyer needs before signing. DNS tells a machine where a company is. A TLS
8
- certificate tells it the connection is authentic. Neither says anything about
9
- the company itself. The Trooth Network is that layer, and this CLI reads it.
5
+ Trooth operates the Trooth Network: one public, signed, machine-readable record per company, carrying its identity, products and demos, commercial terms, domain and marketing links, people, documents, security and privacy posture, AI practices, procurement terms and relationships. It is Trooth's only product and it is free.
10
6
 
11
- **`check` reads only public, already-published records. No key, no account.**
12
- **`lint` is entirely local: it opens files and opens no sockets.**
13
- **Trooth publishes facts and counts, never one number that sums a company up.**
7
+ DNS says where a company is. A TLS certificate says the connection is authentic. The Trooth Network says who the company is and what it does with your data.
14
8
 
15
- ## Install / run
9
+ This CLI is the terminal interface to that record. It does two things:
10
+
11
+ - `trooth check <domain>` reads a company's published record from the public Network. No key, no account, and nothing about you is sent beyond what any web request carries (your IP address and a `trooth-cli/<version>` user-agent).
12
+ - `trooth lint [path]` reads what your own infrastructure declares and prints those declarations as facts. Entirely local: it opens files and opens no sockets.
13
+
14
+ **Trooth witnesses and dates facts. It does not grade, rate or rank anyone.** The CLI prints counts, reported apart, and never adds them into one number.
15
+
16
+ ## Install
16
17
 
17
18
  ```bash
18
- # No install needed:
19
+ # No install:
19
20
  npx trooth check stripe.com
20
21
 
21
- # or install it:
22
- npm i -g trooth
23
- trooth check trooth.co
22
+ # Or install it:
23
+ npm install -g trooth
24
+ trooth --version
24
25
  ```
25
26
 
26
- Requires Node 18+ (uses built-in `fetch`). Zero dependencies.
27
+ Node 18 or newer, because the binary uses the built-in `fetch`. Zero dependencies.
27
28
 
28
29
  ## Commands
29
30
 
30
31
  | Command | What it does |
31
- | --- | --- |
32
- | `trooth check <domain>` | Reads a company's record from the live Trooth Network (`GET https://api.trooth.co/directory/api/vendors`) and prints whether it is listed, when it was witnessed and first published, the live-probe and attestation counts, the badge id, and the signature and key id you can verify. Prints an honest "not listed yet" when a company has no published record. |
33
- | `trooth lint [path]` | Reads the infrastructure the given directory **declares** and prints those declarations as facts, plus a canonical SHA-256 digest of them. Entirely local and offline. |
34
- | `trooth --help` / `--version` | Help / version. |
32
+ |---|---|
33
+ | `trooth check <domain>` | Reads a company's record from the live Network and prints whether it is listed, when it was last witnessed and first published, the live-probe and self-attestation counts, the badge id, the id of the key that signed the record, and the three newest events in its ledger, newest first. `--json` adds the signature itself and every event the feed returns. |
34
+ | `trooth lint [path]` | Reads the infrastructure the given directory declares and prints those declarations plus a canonical SHA-256 digest of them. Local and offline. `path` defaults to `.`. |
35
+ | `trooth --help` | Help. Also `-h` and `help`. |
36
+ | `trooth --version` | Version. Also `-v` and `version`. |
35
37
 
36
38
  ## Flags
37
39
 
38
- - `--json`: stdout carries exactly one JSON document and nothing else; every
39
- diagnostic goes to stderr. On an error the document is
40
- `{"ok": false, "error": "...", "exit": N}`.
40
+ `--json` is the only flag `check` and `lint` take. With it, stdout carries exactly one JSON document and nothing else, and every diagnostic goes to stderr. On an error the document is `{"ok": false, "error": "...", "exit": N}`; a non-2xx response adds `http_status`, and `lint` with nothing to read adds `files_opened`. `--help` and `--version` print plain text whether or not `--json` is given.
41
41
 
42
- Any other flag is a usage error (exit 2) that names the flag and lists the
43
- known ones.
42
+ Any other flag is a usage error. The message names the flag, and for a `--` flag given to `check` or `lint` it also lists the ones that exist.
44
43
 
45
44
  ## Exit codes
46
45
 
47
46
  | Code | Meaning |
48
- | --- | --- |
49
- | 0 | ok: company listed; lint read at least one declaration; help / version |
50
- | 1 | finding: company not listed; lint found nothing to read |
51
- | 2 | usage error: missing argument, unknown flag or command, unreadable path |
52
- | 3 | Trooth unreachable, non-2xx response, or malformed response |
47
+ |---|---|
48
+ | 0 | The company is listed, or `lint` read at least one declaration, or you asked for help or the version. |
49
+ | 1 | The company is not listed, or `lint` found nothing to read. |
50
+ | 2 | Usage error: a missing argument, an unknown flag or command, or a path that does not exist. |
51
+ | 3 | Trooth could not be read: unreachable, a non-2xx response, or a response that is not JSON. An unexpected failure inside the CLI also exits 3. |
53
52
 
54
- A company that is not listed and a network that could not be read are different
55
- answers and they exit differently, so a pipeline never reads a Trooth outage as
56
- "this vendor is unverified".
53
+ A company with no record and a Network that could not be read are different answers, so they exit differently. A pipeline can tell them apart without parsing prose, and a Trooth outage never reads as a company with no record.
57
54
 
58
55
  ## `trooth check`
59
56
 
@@ -61,25 +58,37 @@ answers and they exit differently, so a pipeline never reads a Trooth outage as
61
58
  trooth check trooth.co
62
59
  ```
63
60
 
61
+ Example output. The values are illustrative; the shape is what the binary prints.
62
+
64
63
  ```
65
- Trooth Network // witnessed · public · read-only //
64
+ Trooth Network // public · signed · read-only //
66
65
  Trooth, LLC trooth.co
67
- Standing: listed and witnessed witnessed 2026-08-30 first published 2026-08-01
66
+ Listing state: listed and witnessed last witnessed 2026-08-30 first published 2026-08-01
67
+
68
+ Live probes: 65 read; 64 as expected
69
+ Self-attestations: 35 asked; 27 attested
70
+ Live probes are readings Trooth took itself, from the company's public surface.
71
+ Self-attestations are what the company attested about itself; Trooth records them
72
+ and did not witness them. The two are reported apart and never added into one number.
73
+ Badge rw_... Key ed25519-2026-01
74
+
75
+ Latest ledger events, newest first
76
+ • 2026-08-30 record published to the Trooth Network
77
+ • 2026-08-30 reading completed
78
+ • 2026-08-01 record published to the Trooth Network
79
+ --json carries the whole ledger, with the feed's own wording for each event.
80
+
81
+ A dated, point-in-time record. Trooth issues no verdict and no single number.
82
+ Full record: https://trooth.co/network/trooth.co · Signing keys: https://api.trooth.co/public/keys
83
+ ```
68
84
 
69
- Live probes 64/65 Attestations 27/35
70
- Probes are checks Trooth read for itself. Attestations are the company's own declarations.
71
- They are counts, reported apart on purpose. Trooth never adds them up into one number.
72
- Badge bronze_rw_... Key ed25519-2026-01
85
+ The line that begins `Listing state:` gives the listing state. For a listed company it reads `listed and witnessed`, then the date of the most recent published reading (`last witnessed`) and the date the record was first published. The two counts use the same form as the record page on trooth.co: live probes (how many were read at the last reading, and how many of those returned the expected result) and self-attestations (how many the company was asked for, and how many it attested). Live probes are readings Trooth took itself; self-attestations are the company's statements about itself, which Trooth records and does not witness.
73
86
 
74
- Recent witness events
75
- • 2026-08-30 rewitnessed 64 of 65 live probes re-run
87
+ The events section prints the three newest entries in the record's ledger, newest first, by timestamp; entries with the same timestamp keep the feed's order, the later entry first. Known event types get a plain label: `scan_completed` prints as "reading completed", `standing_published` as "record published to the Trooth Network" and `rewitnessed` as "live probes re-read". A type the CLI does not know prints with its underscores turned into spaces. The human view leaves out each event's detail text. `--json` carries every event the feed returns, with its type and detail exactly as the feed has them.
76
88
 
77
- A witnessed, point-in-time reading of public evidence. Not a certification. Not one number.
78
- Full record: https://trooth.co/network/trooth.co · Signing keys: https://api.trooth.co/public/keys
79
- ```
89
+ For scripting:
80
90
 
81
91
  ```bash
82
- # For scripting: one JSON document on stdout, exit 1 when a company is not listed.
83
92
  trooth check trooth.co --json
84
93
  ```
85
94
 
@@ -90,10 +99,15 @@ trooth check trooth.co --json
90
99
  "company_name": "Trooth, LLC",
91
100
  "witnessed_at": "2026-08-30T00:00:00Z",
92
101
  "first_published_at": "2026-08-01T00:00:00Z",
93
- "badge_id": "bronze_rw_...",
102
+ "badge_id": "rw_...",
94
103
  "probes": { "passed": 64, "total": 65 },
95
104
  "attested": { "passed": 27, "total": 35 },
96
- "events": [ { "type": "rewitnessed", "at": "2026-08-30T00:00:00Z", "detail": "64 of 65 live probes re-run" } ],
105
+ "events": [
106
+ { "type": "scan_completed", "at": "2026-08-01T00:00:00Z", "detail": "65 probes read · 64 returned the expected result · 27 declarations recorded · reading signed" },
107
+ { "type": "standing_published", "at": "2026-08-01T00:00:00Z", "detail": "point-in-time · published to the Trooth Network" },
108
+ { "type": "scan_completed", "at": "2026-08-30T00:00:00Z", "detail": "65 probes read · 64 returned the expected result · 27 declarations recorded · reading signed" },
109
+ { "type": "standing_published", "at": "2026-08-30T00:00:00Z", "detail": "point-in-time · published to the Trooth Network" }
110
+ ],
97
111
  "receipt_signature": "...",
98
112
  "authority_key_id": "ed25519-2026-01",
99
113
  "verify_keys": "https://api.trooth.co/public/keys",
@@ -101,106 +115,144 @@ trooth check trooth.co --json
101
115
  }
102
116
  ```
103
117
 
104
- Those are the only fields `check --json` emits. Anything else the feed happens
105
- to carry is dropped on the way out, so a script written against this shape
106
- keeps working — and a field that would carry a score, tier, grade, rank or
107
- rating is dropped no matter what the feed sends.
118
+ In `probes`, `total` is how many live probes were read at the last reading and `passed` (the API's field name) is how many returned the expected result. In `attested`, `total` is how many declarations were asked for and `passed` is how many the company attested. `witnessed_at` is the date of the most recent published reading. `events` is the whole ledger, oldest first, in the feed's order. Each event's `detail` is written when the event is stored and is not rewritten afterward, so older and newer events of the same type can be worded differently.
119
+
120
+ `category` and `description` are added when the record carries them. Those are the only fields `check --json` emits: anything else the feed happens to carry is dropped on the way out, so a script written against this shape keeps working. A field whose name contains `score`, `tier`, `grade`, `rank`, `rating`, `level` or `percent`, or is `rate`, is dropped no matter what the feed sends.
121
+
122
+ A company with no record exits 1 and emits `{"domain": "...", "listed": false, "record_url": "..."}`. That is not a judgment. It means the Network's public feed carries no record for that domain. A company gets a record at [trooth.co/get-started](https://trooth.co/get-started), free.
123
+
124
+ `receipt_signature` is Trooth's Ed25519 signature and `authority_key_id` names the key that made it; the public keys are listed at [trooth.co/verify/keys](https://trooth.co/verify/keys). You cannot re-run that signature check from this output yet: the exact bytes the signature covers are not published, and this JSON does not carry every field that goes into them. [`trooth-signatures`](https://github.com/troothllc/trooth-signatures) states that gap and what will close it.
108
125
 
109
126
  ## `trooth lint`
110
127
 
111
- `lint` reads what your infrastructure **declares** and reports it. It does not
112
- judge it. There is no verdict, no pass mark, no severity and no score, and
113
- nothing is checked against a named standard or regulation — declaring public
114
- ingress is not a failing, because a load balancer is supposed to be public.
115
- What the facts mean is your call.
128
+ `lint` reads what your infrastructure **declares** and reports it. It does not judge it. There is no verdict, no threshold, no severity and no rating, and nothing is checked against a named standard or regulation. Declaring public ingress is not a failing: a load balancer is supposed to be public. What the facts mean is your decision.
116
129
 
117
130
  ```bash
118
131
  trooth lint ./infra
119
132
  ```
120
133
 
134
+ Output for the small fixture in this repository (`trooth lint tests/fixtures/infra`, trooth 0.4.3):
135
+
121
136
  ```
122
137
  trooth lint // local · offline · declarations only //
123
- ./infra 14 declaration file(s) read
124
- terraform 11 · kubernetes 3
138
+ tests/fixtures/infra 2 declaration file(s) read
139
+ terraform 1 · kubernetes 1
125
140
 
126
141
  Declared
127
- Regions and zones eu-west-1, us-east-1
128
- Storage declarations 9
129
- of those declaring encryption 9
130
- Logging declarations 4
131
- Identity declarations 6
142
+ Regions and zones us-east-1
143
+ Storage declarations 1
144
+ of those declaring encryption 1
145
+ Logging declarations 0
146
+ Identity declarations 0
132
147
  Open to any address (0.0.0.0/0, ::/0) 1
133
- Marked public 2
148
+ Marked public 0
134
149
  Inline credential literals 0
135
150
 
136
- Digest sha256:a963291330bce2ab…
151
+ Most declared resource types
152
+ 1 aws_s3_bucket
153
+ 1 aws_s3_bucket_server_side_encryption_configuration
154
+ 1 aws_security_group_rule
155
+
156
+ Digest sha256:a40bf6cf27dfd0b9079ac48a326fb2d1423b076c96b405451dde1ed2d7848944
157
+ A SHA-256 over the facts above, in canonical form, with the timestamp excluded.
158
+ The same tree read by the same trooth version produces the same digest, so you can
159
+ record it as evidence that a state was observed without publishing the tree.
160
+
161
+ How this was read
162
+ A pattern reader, not a Terraform evaluator: variables and modules are not resolved.
163
+ .tf by pattern, one resource block at a time. .tf.json and plan JSON parsed, one
164
+ resource at a time. Kubernetes YAML by pattern, one document at a time, by kind.
165
+ Dockerfiles for regions, open addresses, public markers and credential literals only.
166
+
167
+ Counts of what the files declare. Not a judgment: a public load balancer is
168
+ supposed to be public. Trooth issues no verdict here and checks nothing against
169
+ any standard. Nothing left this machine: lint opens files and opens no sockets.
170
+ Publish what you choose on your record at https://trooth.co/dashboard.
137
171
  ```
138
172
 
139
- It reads `.tf`, `.tf.json`, Kubernetes YAML (anything with `apiVersion` and
140
- `kind`), `terraform show -json` plan files and Dockerfiles. It is a declaration
141
- reader, not a full HCL parser, and it says so rather than pretending otherwise.
173
+ The bucket's encryption configuration is a setting on the bucket, not a second store, so the fixture has one storage declaration, and that one declares encryption.
174
+
175
+ It reads `.tf`, `.tf.json`, Kubernetes YAML (anything carrying both `apiVersion` and `kind`), `terraform show -json` plan files and Dockerfiles. It is a pattern reader, not a Terraform evaluator: variables, modules and `for_each` are never resolved, so a count can differ from what Terraform itself would plan. `.tf` files are read by pattern, one resource block at a time. `.tf.json` and plan files are parsed as JSON, one resource at a time. Kubernetes YAML is read by pattern, one document at a time, and classified by its `kind`. Dockerfiles are read for regions, open addresses, public markers and credential literals only. Storage, logging and identity are counted by a resource's type or a document's kind, never by the words around it. The output states this under "How this was read".
142
176
 
143
- **Nothing leaves the machine.** No file name, no line, no code and no value is
144
- ever printed or transmitted — only counts, resource type names and region
145
- strings. The credential count is a count: it never shows the literal it found.
177
+ Nothing leaves the machine. No file name, no line, no code and no value is printed or transmitted, only the path you gave it, counts, resource type names and region strings. The credential count is a count: the literal it found is never shown.
146
178
 
147
- **The digest** is a SHA-256 over the canonical fact document with the timestamp
148
- excluded, so the same tree always produces the same digest. Record it in CI, or
149
- on your Trooth record, as evidence that a given state was observed — without
150
- publishing the tree it came from.
179
+ The digest is a SHA-256 over the `facts` object in canonical form. The timestamp, the path and the CLI version are outside it, so the same tree read by the same trooth version produces the same digest. A release that changes how something is counted changes the digest of the same tree: the fixture above digests differently under 0.4.2 and 0.4.3. Record the digest in CI, or on your own record, as evidence that a given state was observed, without publishing the tree it came from.
151
180
 
152
181
  ```bash
153
- # In CI: keep the fact document as a build artifact.
154
- trooth lint --json > trooth-attestation.json
182
+ # Keep the fact document as a build artifact.
183
+ trooth lint --json > trooth-lint.json
155
184
  ```
156
185
 
157
- ## Use it from an AI assistant
186
+ ## In GitHub Actions
158
187
 
159
- The same public network powers Trooth's read-only MCP server, so ChatGPT,
160
- Claude, Cursor, or any MCP client can ask about a company in plain words:
188
+ The same `lint`, as a step, is [`troothllc/trooth-action`](https://github.com/troothllc/trooth-action). It is advisory by default: it writes what your infrastructure declares to the job summary and does not fail your workflow over anything it read unless you opt in to one of two gates. It does fail the step when it could not read at all: a path that does not exist, or a CLI it could not install or start.
189
+
190
+ ```yaml
191
+ - uses: troothllc/trooth-action@v1
192
+ with:
193
+ path: ./infra
194
+ ```
195
+
196
+ That repository's README documents the inputs and outputs.
197
+
198
+ ## Environment
199
+
200
+ | Variable | Effect |
201
+ |---|---|
202
+ | `TROOTH_API` | Base URL for `check`. Defaults to `https://api.trooth.co`. `lint` ignores it, because `lint` makes no requests. |
203
+ | `NO_COLOR` | Disables ANSI color. Color is already off when stdout is not a TTY. |
204
+
205
+ There is no API key. The binary asks for no credential of any kind and has no write path.
206
+
207
+ ## From an AI assistant
208
+
209
+ The same public Network powers Trooth's read-only MCP server, so ChatGPT, Claude, Cursor or any MCP client can ask about a company in plain words:
161
210
 
162
211
  ```
163
212
  https://api.trooth.co/public/mcp
164
213
  ```
165
214
 
166
- ## Notes
215
+ Four read-only tools, public data, no key. The pattern is written up at [trooth.co/docs/agents](https://trooth.co/docs/agents).
167
216
 
168
- - `check` reads only the **public** record. There is no key and no account, and
169
- it never sends anything about you. It is a read.
170
- - A "not listed" result is not a judgement. It means no record has been
171
- published for that domain yet. A company gets one at
172
- <https://trooth.co/get-started>.
173
- - Set `TROOTH_API` to point at a different base URL (defaults to
174
- `https://api.trooth.co`). `lint` ignores it: `lint` makes no requests.
175
- - Set `NO_COLOR=1` to disable ANSI colour. Colour is already off when stdout is
176
- not a TTY.
217
+ ## Changed in 0.4.4
177
218
 
178
- ## Changes in 0.4.0
219
+ - `check` asks the Trooth Network for the one record it needs (`/directory/api/vendors/<domain>`) instead of downloading the whole directory list and searching it. When the API does not serve that path yet, it reads the list as 0.4.3 did. The printed output, `check --json` and the exit codes are unchanged.
220
+
221
+ ## Changed in 0.4.3
222
+
223
+ - `check` labels the listing state `Listing state:` and prints the two counts in the record page's form (`65 read; 64 as expected`, `35 asked; 27 attested`). It prints the three newest ledger events, newest first, with plain labels and without the detail text. 0.4.2 printed the ledger's three oldest events under a heading that called them recent. `check --json` is unchanged.
224
+ - `lint` no longer counts a storage setting (a bucket's encryption configuration, a bucket policy, a volume attachment) as a second store, and a setting that declares encryption credits the store it refers to. It no longer counts `encrypted = false`, or an empty encryption setting in a plan, as declaring encryption. It classifies by resource type or Kubernetes `kind` only, parses `.tf.json` and plan JSON and counts their resource types, and reads Kubernetes YAML one document at a time. The same tree can therefore produce different counts, and a different digest, than under 0.4.2. The `lint --json` field names are unchanged.
225
+ - The human `lint` output states how each source was read.
226
+
227
+ ## Removed in 0.4.0
179
228
 
180
229
  Breaking, and deliberately so.
181
230
 
182
- - **`scan` and `eu` are removed.** Trooth does not scan infrastructure against a
183
- standard, does not issue a verdict and does not offer a compliance product.
184
- Both commands now exit 2 with a sentence saying so, rather than "unknown
185
- command", so an old CI job or an old README gets an explanation.
186
- - **`lint` is real.** In 0.3.0 it shelled out to `@trooth/os`, which was never
187
- published, so the command failed for everyone who ran it. It is now
188
- implemented in this binary, entirely locally.
189
- - **`scan_id` is gone from `check --json`.** Every other field is unchanged.
190
- - **`--strict` is gone**, along with the only command that took it.
191
- - The package description and keywords no longer name any certification,
192
- standard or regulation, because Trooth does not offer one.
231
+ - The `scan` and `eu` commands are gone. Trooth does not check infrastructure against a standard and does not issue a verdict. Running either exits 2 with a sentence saying what happened, rather than "unknown command", so an old CI job or an old bookmark gets an explanation.
232
+ - `lint` is real. In 0.3.0 it shelled out to a package that was never published, so the command failed for everyone who ran it. It is implemented in this binary now, entirely locally.
233
+ - `scan_id` is gone from `check --json`. Every other field is unchanged.
234
+ - `--strict` is gone, along with the only command that took it.
235
+ - The package description and keywords no longer name any certification, standard or regulation, because Trooth does not offer one.
236
+
237
+ The Trooth Network is Trooth's only product. This CLI, the public API and the MCP server are interfaces to that one record, not separate products.
193
238
 
194
- The Trooth Network is Trooth's only product. This CLI, the public API and the
195
- MCP server are interfaces to it, not separate products.
239
+ ## Security
196
240
 
197
- Trooth automates. Trooth never signs for you.
241
+ Report a vulnerability through the [Vulnerability Disclosure Policy](https://trooth.co/security/vulnerability-disclosure-policy). Nothing in this CLI takes a credential, so there is no key to leak from it.
242
+
243
+ ## Links
244
+
245
+ - The Network: [trooth.co/network](https://trooth.co/network)
246
+ - This CLI on the site: [trooth.co/cli](https://trooth.co/cli)
247
+ - API reference: [trooth.co/docs/api](https://trooth.co/docs/api)
248
+ - Developers: [trooth.co/developers](https://trooth.co/developers)
249
+ - Publish your own record, free: [trooth.co/get-started](https://trooth.co/get-started)
250
+ - Contact: [trooth.co/contact](https://trooth.co/contact)
198
251
 
199
252
  ## License
200
253
 
201
254
  Apache License 2.0. See [LICENSE](LICENSE).
202
255
 
203
- The same licence applies to this repository, to the `trooth` package on npm and
204
- to the copyright header in `bin/trooth.mjs`. They are meant to agree; if you
205
- ever find that they do not, that is a defect and we want to hear about it at
206
- https://trooth.co/contact.
256
+ The same license applies to this repository, to the `trooth` package on npm and to the copyright header in `bin/trooth.mjs`. They are meant to agree. If you find that they do not, that is a defect: please report it at [trooth.co/contact](https://trooth.co/contact).
257
+
258
+ Trooth signs what it witnessed. It never signs on a company's behalf.
package/bin/trooth.mjs CHANGED
@@ -4,10 +4,11 @@
4
4
  // Licensed under the Apache License, Version 2.0. See the LICENSE file in this
5
5
  // repository, or http://www.apache.org/licenses/LICENSE-2.0
6
6
  //
7
- // The Trooth Network is one public record per company: identity, products and demos,
8
- // domain and marketing links, people, documents, security and privacy posture,
9
- // procurement terms, relationships and sub-processors — each one witnessed, signed
10
- // and dated. This CLI is the terminal interface to that record.
7
+ // The Trooth Network is one public, signed record per company: identity, products and
8
+ // demos, domain and marketing links, people, documents, security and privacy posture,
9
+ // procurement terms, relationships and sub-processors. Each fact is dated and labeled
10
+ // with where it came from: witnessed, public record, attested or declared. This CLI is
11
+ // the terminal interface to that record.
11
12
  //
12
13
  // Commands:
13
14
  // trooth check <domain> Read a company's witnessed record from the public Trooth
@@ -18,9 +19,9 @@
18
19
  // trooth --help Show help. trooth --version Show version.
19
20
  //
20
21
  // WHAT THIS TOOL DOES NOT DO, ON PURPOSE:
21
- // It does not score, rate, rank, grade or tier a company or a repository.
22
- // It does not check anything against a named certification, standard or regulation.
23
- // It does not produce a verdict, a pass mark or a percentage.
22
+ // It does not grade, rate or rank a company or a repository.
23
+ // It does not check anything against a named standard, framework or regulation.
24
+ // It does not produce a verdict, a threshold result or a percentage.
24
25
  // It publishes facts and counts, reported apart, and never adds them into one number.
25
26
  //
26
27
  // Exit codes (stable, for scripts):
@@ -39,12 +40,12 @@ import { join, relative, extname, basename } from 'node:path';
39
40
 
40
41
  const API = process.env.TROOTH_API || 'https://api.trooth.co';
41
42
  const require = createRequire(import.meta.url);
42
- let VERSION = '0.4.0';
43
+ let VERSION = '0.4.4';
43
44
  try { VERSION = require('../package.json').version; } catch {}
44
45
 
45
46
  const EXIT = { OK: 0, FINDING: 1, USAGE: 2, UPSTREAM: 3 };
46
47
 
47
- // Colour only when stdout is a TTY and NO_COLOR is unset, so piped output is clean.
48
+ // Color only when stdout is a TTY and NO_COLOR is unset, so piped output is clean.
48
49
  const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
49
50
  const c = (code) => (useColor ? code : '');
50
51
  const J = c('\x1b[32m'), D = c('\x1b[2m'), B = c('\x1b[1m'), R = c('\x1b[31m'), A = c('\x1b[33m'), C = c('\x1b[36m'), X = c('\x1b[0m');
@@ -94,7 +95,7 @@ const FLAGS = {
94
95
  * means an old README, an old CI job or an old blog post gets a sentence that says
95
96
  * what happened, instead of "unknown command". */
96
97
  const RETIRED = {
97
- scan: 'Trooth does not scan infrastructure against a standard and does not issue a verdict. `trooth lint` reads what your infrastructure declares and prints those facts, locally.',
98
+ scan: 'Trooth does not check infrastructure against a standard and does not issue a verdict. `trooth lint` reads what your infrastructure declares and prints those facts, locally.',
98
99
  eu: 'Trooth does not ingest regulation-specific evidence. Publish evidence on your company record at https://trooth.co/dashboard.',
99
100
  preflight: 'Retired. `trooth lint` reads declared infrastructure facts locally instead.',
100
101
  };
@@ -142,7 +143,7 @@ ${B}Examples${X}
142
143
  trooth check stripe.com ${D}# read a company's witnessed record${X}
143
144
  trooth check trooth.co --json ${D}# one JSON document on stdout, for scripting${X}
144
145
  trooth lint ./infra ${D}# read declared facts + print a canonical digest${X}
145
- trooth lint --json >> attestation.json ${D}# the same facts, for a CI artifact${X}
146
+ trooth lint --json > trooth-lint.json ${D}# the same facts as one JSON document, for a CI artifact${X}
146
147
 
147
148
  ${B}Flags${X}
148
149
  --json machine-readable JSON on stdout; diagnostics on stderr
@@ -153,26 +154,33 @@ ${B}Exit codes${X}
153
154
  ${D}check reads only public, already-published records. No key, no account.
154
155
  lint is entirely local: it opens files, and opens no sockets. Your source never leaves.
155
156
  Trooth publishes facts and counts, never one number that sums a company up.
156
- Trooth automates. Trooth never signs for you.${X}
157
+ Trooth signs what it witnessed. It never signs on a company's behalf.${X}
157
158
  `;
158
159
  }
159
160
 
160
161
  /* -------------------------------------------------------------- fetch ---- */
161
162
 
162
- async function callTrooth(path, init, what) {
163
- let res;
163
+ /** One request to the API. Exits 3 when the API cannot be reached; any HTTP
164
+ * status comes back to the caller. */
165
+ async function requestTrooth(path, init, what) {
164
166
  try {
165
- res = await fetch(`${API}${path}`, {
167
+ return await fetch(`${API}${path}`, {
166
168
  ...init,
167
169
  headers: { accept: 'application/json', 'user-agent': `trooth-cli/${VERSION}`, ...(init && init.headers) },
168
170
  });
169
171
  } catch (e) {
170
172
  fail(EXIT.UPSTREAM, `could not reach ${what} at ${API}: ${e && e.message ? e.message : e}`);
171
173
  }
172
- if (!res.ok) {
173
- const body = await res.text().catch(() => '');
174
- fail(EXIT.UPSTREAM, `${what} returned HTTP ${res.status}.${body ? ' ' + body.slice(0, 300).replace(/\s+/g, ' ') : ''}`, { http_status: res.status });
175
- }
174
+ }
175
+
176
+ async function failHttp(res, what) {
177
+ const body = await res.text().catch(() => '');
178
+ fail(EXIT.UPSTREAM, `${what} returned HTTP ${res.status}.${body ? ' ' + body.slice(0, 300).replace(/\s+/g, ' ') : ''}`, { http_status: res.status });
179
+ }
180
+
181
+ async function callTrooth(path, init, what) {
182
+ const res = await requestTrooth(path, init, what);
183
+ if (!res.ok) await failHttp(res, what);
176
184
  try { return await res.json(); }
177
185
  catch { fail(EXIT.UPSTREAM, `${what} returned a response that is not JSON.`); }
178
186
  }
@@ -202,6 +210,37 @@ function count(obj) {
202
210
  return { passed: obj.passed, total: obj.total };
203
211
  }
204
212
 
213
+ /** The two count lines, in the website's form ("65 read; 64 as expected"). */
214
+ function countLines(rec) {
215
+ const lines = [];
216
+ if (rec.probes) lines.push(`${B}Live probes:${X} ${rec.probes.total} read; ${rec.probes.passed} as expected`);
217
+ if (rec.attested) lines.push(`${B}Self-attestations:${X} ${rec.attested.total} asked; ${rec.attested.passed} attested`);
218
+ return lines;
219
+ }
220
+
221
+ /** The feed keeps the ledger oldest first. This returns the `n` most recent
222
+ * events, newest first, ordered by timestamp; events with the same timestamp,
223
+ * or none, keep the feed's order, later entries first. */
224
+ function latestEvents(events, n) {
225
+ const t = (e) => { const v = Date.parse(e.at); return Number.isNaN(v) ? -Infinity : v; };
226
+ return events
227
+ .map((e, i) => ({ e, i }))
228
+ .sort((a, b) => (t(b.e) - t(a.e)) || (b.i - a.i))
229
+ .slice(0, n)
230
+ .map(({ e }) => e);
231
+ }
232
+
233
+ /** Display names for the feed's event types. The type strings themselves are
234
+ * identifiers and are printed unchanged in --json. */
235
+ const EVENT_LABELS = {
236
+ scan_completed: 'reading completed',
237
+ standing_published: 'record published to the Trooth Network',
238
+ rewitnessed: 'live probes re-read',
239
+ };
240
+ function eventLabel(type) {
241
+ return Object.prototype.hasOwnProperty.call(EVENT_LABELS, type) ? EVENT_LABELS[type] : type.replace(/_/g, ' ');
242
+ }
243
+
205
244
  function projectRecord(v, domain) {
206
245
  const events = Array.isArray(v.events)
207
246
  ? v.events.filter((e) => e && e.type).map((e) => ({ type: String(e.type), at: e.at, detail: e.detail }))
@@ -226,25 +265,68 @@ function projectRecord(v, domain) {
226
265
  return scrub(rec);
227
266
  }
228
267
 
268
+ /** The whole list, searched here. This is how every release up to 0.4.3 read a
269
+ * company, and it is now only the fallback in readVendor below. */
270
+ async function readVendorFromList(domain, what) {
271
+ const data = await callTrooth('/directory/api/vendors', { method: 'GET' }, what);
272
+ const vendors = Array.isArray(data && data.vendors) ? data.vendors : [];
273
+ return vendors.find((v) => v && normalizeDomain(v.domain) === domain) || null;
274
+ }
275
+
276
+ /** One company's record from the directory feed, or null when the feed carries
277
+ * no record for that domain. It asks for that one record
278
+ * (/directory/api/vendors/<domain>), which returns the same object the list
279
+ * carries for that domain, and does not download every company to find it. */
280
+ async function readVendor(domain) {
281
+ const what = 'the Trooth Network';
282
+ const res = await requestTrooth(`/directory/api/vendors/${encodeURIComponent(domain)}`, { method: 'GET' }, what);
283
+
284
+ if (res.status === 404) {
285
+ const text = await res.text().catch(() => '');
286
+ let body = null;
287
+ try { body = JSON.parse(text); } catch {}
288
+ // The route answered and has no record for this domain. It says so with a
289
+ // JSON body carrying `listed: false`.
290
+ if (body && typeof body === 'object' && body.listed === false) return null;
291
+ // FALLBACK TO THE LIST. Any other 404 comes from a deploy of the directory
292
+ // worker that predates the single-record route: it answers an unknown path
293
+ // with a plain-text "Not found". Read the list and search it here, as 0.4.3
294
+ // does, so this release keeps working against a worker that has not been
295
+ // redeployed yet, and against a TROOTH_API that points at an older one.
296
+ // This fallback can go in the first release after api.trooth.co serves the
297
+ // route, that is, once a request for a domain with no record there returns
298
+ // a JSON 404 with `listed: false`.
299
+ return readVendorFromList(domain, what);
300
+ }
301
+ if (!res.ok) await failHttp(res, what);
302
+
303
+ let v;
304
+ try { v = await res.json(); }
305
+ catch { fail(EXIT.UPSTREAM, `${what} returned a response that is not JSON.`); }
306
+ if (!v || typeof v !== 'object' || Array.isArray(v) || normalizeDomain(v.domain) !== domain) {
307
+ fail(EXIT.UPSTREAM, `${what} returned a response that is not the record for ${domain}.`);
308
+ }
309
+ return v;
310
+ }
311
+
229
312
  async function check() {
230
313
  const { positional } = parseArgs('check');
231
314
  if (positional.length > 1) fail(EXIT.USAGE, `check takes one <domain>, got: ${positional.join(' ')}`);
232
315
  const domain = normalizeDomain(positional[0]);
233
316
  if (!domain) fail(EXIT.USAGE, 'missing <domain>. Try: trooth check stripe.com');
234
317
 
235
- const data = await callTrooth('/directory/api/vendors', { method: 'GET' }, 'the Trooth Network');
236
- const vendors = Array.isArray(data && data.vendors) ? data.vendors : [];
237
- const vendor = vendors.find((v) => v && normalizeDomain(v.domain) === domain) || null;
318
+ const vendor = await readVendor(domain);
238
319
 
239
320
  if (!vendor) {
240
321
  if (asJson) {
241
322
  emitJson({ domain, listed: false, record_url: `https://trooth.co/network/${encodeURIComponent(domain)}` });
242
323
  } else {
243
- out(`\n${B}${domain}${X} ${D}//${X} ${A}not listed on the Trooth Network yet${X}`);
244
- out(`\n${D}No witnessed record has been published for this domain. That is not a`);
245
- out(`judgement. It simply has not been witnessed. A company gets a record by`);
246
- out(`listing at ${X}${C}https://trooth.co/get-started${X}${D}: Trooth reads its public surface and`);
247
- out(`publishes a signed, dated record that anyone — or any agent — can check.${X}\n`);
324
+ out(`\n${B}${domain}${X} ${D}//${X} ${A}not listed in the Trooth Network's public feed${X}`);
325
+ out(`\n${D}The public feed carries no record for this domain. That says nothing about the`);
326
+ out(`company: a domain that never listed, a listing Trooth has not published, and a`);
327
+ out(`record that was revoked all read this way. A company gets a record by listing at`);
328
+ out(`${X}${C}https://trooth.co/get-started${X}${D}: Trooth reads its public surface and publishes`);
329
+ out(`a signed, dated record that anyone, or any agent, can read.${X}\n`);
248
330
  }
249
331
  process.exit(EXIT.FINDING);
250
332
  }
@@ -254,19 +336,22 @@ async function check() {
254
336
 
255
337
  const when = fmtDate(rec.witnessed_at);
256
338
  const since = fmtDate(rec.first_published_at);
257
- out(`\n${J}${B}Trooth Network${X} ${D}// witnessed · public · read-only //${X}`);
339
+ out(`\n${J}${B}Trooth Network${X} ${D}// public · signed · read-only //${X}`);
258
340
  out(`${B}${rec.company_name}${X} ${C}${domain}${X}`);
259
- out(`Standing: ${J}listed and witnessed${X}` +
260
- (when ? ` ${D}witnessed ${when}${X}` : ` ${D}date not published${X}`) +
341
+ out(`Listing state: ${J}listed and witnessed${X}` +
342
+ (when ? ` ${D}last witnessed ${when}${X}` : ` ${D}date not published${X}`) +
261
343
  (since ? ` ${D}first published ${since}${X}` : ''));
262
344
 
263
- const line = [];
264
- if (rec.probes) line.push(`${B}Live probes${X} ${rec.probes.passed}/${rec.probes.total}`);
265
- if (rec.attested) line.push(`${B}Attestations${X} ${rec.attested.passed}/${rec.attested.total}`);
266
- if (line.length) {
267
- out(`\n${line.join(' ')}`);
268
- out(`${D}Probes are checks Trooth read for itself. Attestations are the company's own declarations.`);
269
- out(`They are counts, reported apart on purpose. Trooth never adds them up into one number.${X}`);
345
+ // Two counts, never a ratio, in the form the website's record page uses: an
346
+ // "N/M" cell reads as a bar, and a bar reads as a grade. The JSON fields keep
347
+ // the feed's names (`passed`, `total`).
348
+ const counts = countLines(rec);
349
+ if (counts.length) {
350
+ out('');
351
+ for (const l of counts) out(l);
352
+ out(`${D}Live probes are readings Trooth took itself, from the company's public surface.`);
353
+ out(`Self-attestations are what the company attested about itself; Trooth records them`);
354
+ out(`and did not witness them. The two are reported apart and never added into one number.${X}`);
270
355
  }
271
356
 
272
357
  const ids = [];
@@ -274,14 +359,14 @@ async function check() {
274
359
  if (rec.authority_key_id) ids.push(`${D}Key ${rec.authority_key_id}${X}`);
275
360
  if (ids.length) out(ids.join(' '));
276
361
 
277
- if (rec.events.length) {
278
- out(`\n${B}Recent witness events${X}`);
279
- for (const e of rec.events.slice(0, 3)) {
280
- out(` ${J}•${X} ${D}${fmtDate(e.at)}${X} ${e.type.replace(/_/g, ' ')}${e.detail ? `${D} ${e.detail}${X}` : ''}`);
281
- }
362
+ const latest = latestEvents(rec.events, 3);
363
+ if (latest.length) {
364
+ out(`\n${B}Latest ledger events, newest first${X}`);
365
+ for (const e of latest) out(` ${J}•${X} ${D}${fmtDate(e.at)}${X} ${eventLabel(e.type)}`);
366
+ out(`${D} --json carries the whole ledger, with the feed's own wording for each event.${X}`);
282
367
  }
283
368
 
284
- out(`\n${D}A witnessed, point-in-time reading of public evidence. Not a certification. Not one number.`);
369
+ out(`\n${D}A dated, point-in-time record. Trooth issues no verdict and no single number.`);
285
370
  out(`Full record: ${X}${C}${rec.record_url}${X}${D} · Signing keys: ${rec.verify_keys}${X}\n`);
286
371
  process.exit(EXIT.OK);
287
372
  }
@@ -292,20 +377,31 @@ async function check() {
292
377
  * It reads the infrastructure a repository DECLARES and reports those
293
378
  * declarations as facts: how many storage resources declare encryption, which
294
379
  * regions appear, how many rules declare exposure to the whole internet. It
295
- * does not judge them. There is no verdict, no pass mark, no severity, no
296
- * score, and nothing is checked against a named standard or regulation.
380
+ * does not judge them. There is no verdict, no threshold, no severity and no
381
+ * rating, and nothing is checked against a named standard or regulation.
297
382
  * Declaring public ingress is not a failing; a load balancer is supposed to be
298
- * public. What the facts mean is the reader's call.
383
+ * public. What the facts mean is the reader's decision.
299
384
  *
300
385
  * It opens files and opens no sockets. Nothing about the repository leaves the
301
- * machine. The digest at the end is a SHA-256 over the canonical fact document
302
- * with the timestamp excluded, so the same tree always produces the same digest
303
- * and you can record it as evidence that a given state was observed, without
386
+ * machine. The digest at the end is a SHA-256 over the `facts` object in
387
+ * canonical form (the timestamp, path and CLI version are outside it), so the
388
+ * same tree read by the same CLI version always produces the same digest and
389
+ * you can record it as evidence that a given state was observed, without
304
390
  * publishing the tree it came from.
305
391
  *
306
- * It is a declaration reader, not an HCL parser: .tf.json, Kubernetes YAML and
307
- * terraform plan JSON are parsed properly, and .tf files are read at the
308
- * attribute level. That limit is stated in the output rather than hidden. */
392
+ * HOW EACH SOURCE IS READ. It is a pattern reader, not a Terraform evaluator:
393
+ * variables, modules and for_each are never resolved.
394
+ * .tf regular expressions, split into top-level resource blocks
395
+ * .tf.json parsed as JSON; each resource is one unit
396
+ * plan JSON parsed as JSON (`terraform show -json`); each planned
397
+ * managed resource is one unit
398
+ * Kubernetes YAML regular expressions, one unit per YAML document,
399
+ * classified by its top-level `kind`
400
+ * Dockerfile read for regions, open addresses, public markers and
401
+ * credential literals only; it declares no resource types
402
+ * Storage, logging and identity are classified on a unit's resource type or
403
+ * kind, never on the text around it. The human output prints this list in
404
+ * short, so the limit is stated where the counts are. */
309
405
 
310
406
  const SKIP_DIRS = new Set([
311
407
  'node_modules', '.git', '.terraform', '.next', 'dist', 'build', 'vendor',
@@ -357,6 +453,125 @@ const PUBLIC_RE = /\b(?:publicly_accessible\s*[:=]\s*true|acl\s*[:=]\s*["']publi
357
453
  // A literal that looks like a credential sitting in the file. Reported as a
358
454
  // count only: no file name, no line, and never the value itself.
359
455
  const SECRET_RE = /\b(?:password|secret|api[_-]?key|access[_-]?key|token|private[_-]?key)\s*[:=]\s*["'][^"'${}\n]{8,}["']/i;
456
+ // A storage-matching Terraform type that names a SETTING on a store rather than
457
+ // a store: aws_s3_bucket_server_side_encryption_configuration, a bucket policy,
458
+ // a volume attachment, a subnet group. The substring match above catches these
459
+ // (the fixture's one bucket used to count as two storage declarations), so they
460
+ // are not counted as storage. A setting that declares encryption and references
461
+ // a store credits that store with declaring encryption. Applied to snake_case
462
+ // Terraform types only; Kubernetes kinds are CamelCase and are not settings.
463
+ const STORAGE_SETTING = /_(?:configuration|policy|acl|versioning|notification|public_access_block|ownership_controls|attachment|object|item|logging|iam_member|iam_binding|access_point|mount_target|snapshot|subnet_group|parameter_group|option_group)$/;
464
+ const isStorageSetting = (type) => type.includes('_') && STORAGE_SETTING.test(type);
465
+ // An attribute set to false, null or empty declares nothing: `encrypted = false`
466
+ // and `storage_encrypted: false` must not count as declaring encryption.
467
+ const NEGATIVE_ATTR_RE = /^[^\n:=]*[:=]\s*(?:false|null|"false"|'false'|""|''|\[\]|\{\})\s*,?\s*$/gim;
468
+ const declaresEncryption = (body) => ENCRYPT_RE.test(body.replace(NEGATIVE_ATTR_RE, ''));
469
+
470
+ /** JSON text with `"key":` rewritten as `key:`, so the attribute patterns above
471
+ * (written for `key = "v"` and `key: "v"`) read JSON sources too. Escaped
472
+ * quotes inside string values are never rewritten. */
473
+ function flattenJson(text) {
474
+ return text.replace(/"([A-Za-z_][\w.-]*)"\s*:/g, '$1:');
475
+ }
476
+
477
+ /** A parsed JSON value with false, null, empty strings, empty arrays and empty
478
+ * objects removed, so a plan's unset attributes read as absent. */
479
+ function prune(v) {
480
+ if (Array.isArray(v)) { const a = v.map(prune).filter((x) => x !== undefined); return a.length ? a : undefined; }
481
+ if (v && typeof v === 'object') {
482
+ const o = {};
483
+ for (const [k, x] of Object.entries(v)) { const y = prune(x); if (y !== undefined) o[k] = y; }
484
+ return Object.keys(o).length ? o : undefined;
485
+ }
486
+ return v === null || v === false || v === '' ? undefined : v;
487
+ }
488
+
489
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
490
+ /** A pattern that finds a Terraform address (aws_s3_bucket.logs) in another
491
+ * resource's body, as `aws_s3_bucket.logs.id` or `${aws_s3_bucket.logs.arn}`,
492
+ * and not inside a longer address such as aws_s3_bucket.logs2. */
493
+ const addressRef = (address) => new RegExp(`(?:^|[^\\w.])${escRe(address)}(?![\\w-])`);
494
+
495
+ /** Every resource under `resource` in a .tf.json document, in either of the
496
+ * shapes Terraform's JSON syntax allows (objects, or arrays of objects). */
497
+ function tfJsonResources(doc) {
498
+ const out = [];
499
+ const each = (v, fn) => { if (Array.isArray(v)) v.forEach((x) => each(x, fn)); else if (v && typeof v === 'object') fn(v); };
500
+ each(doc, (top) => each(top.resource, (byType) => {
501
+ for (const [type, byName] of Object.entries(byType)) {
502
+ if (!/^[a-z0-9_]+$/.test(type)) continue;
503
+ each(byName, (names) => { for (const [name, body] of Object.entries(names)) out.push({ type, name, body }); });
504
+ }
505
+ }));
506
+ return out;
507
+ }
508
+
509
+ /** The managed resources a plan (`terraform show -json`) says will exist:
510
+ * planned_values, every module deep, or resource_changes when a plan has no
511
+ * planned_values. Data sources and deletions are not declarations. */
512
+ function planResources(doc) {
513
+ const out = [];
514
+ const walkModule = (m) => {
515
+ if (!m || typeof m !== 'object') return;
516
+ for (const r of Array.isArray(m.resources) ? m.resources : []) {
517
+ if (r && r.mode !== 'data' && typeof r.type === 'string') out.push({ type: r.type, name: String(r.name ?? ''), body: r.values ?? {} });
518
+ }
519
+ for (const c of Array.isArray(m.child_modules) ? m.child_modules : []) walkModule(c);
520
+ };
521
+ if (doc && doc.planned_values && doc.planned_values.root_module) walkModule(doc.planned_values.root_module);
522
+ else {
523
+ for (const rc of Array.isArray(doc && doc.resource_changes) ? doc.resource_changes : []) {
524
+ const after = rc && rc.change ? rc.change.after : null;
525
+ if (rc && rc.mode !== 'data' && typeof rc.type === 'string' && after) out.push({ type: rc.type, name: String(rc.name ?? ''), body: after });
526
+ }
527
+ }
528
+ return out;
529
+ }
530
+
531
+ /** Split one declaration file into the units lint classifies. Each unit has a
532
+ * `type` (a resource type or Kubernetes kind, or null when there is none),
533
+ * `refs` (patterns another unit's body would contain to refer to this one),
534
+ * `body` (the text attribute patterns run on) and `typed` (whether the type
535
+ * belongs in the resource type list). */
536
+ function unitsOf(kind, file, text) {
537
+ if (kind === 'terraform' && !basename(file).toLowerCase().endsWith('.tf.json')) {
538
+ return text.split(/\n(?=resource\s+")/).map((b) => {
539
+ const h = b.match(/^resource\s+"([a-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/);
540
+ return h ? { type: h[1], refs: [addressRef(`${h[1]}.${h[2]}`)], body: b, typed: false }
541
+ : { type: null, refs: [], body: b, typed: false };
542
+ });
543
+ }
544
+ if (kind === 'kubernetes') {
545
+ return text.split(/^---[^\n]*$/m).map((d) => {
546
+ const k = d.match(/^kind\s*:\s*["']?([A-Za-z][A-Za-z0-9]*)/m);
547
+ return { type: k ? k[1] : null, refs: [], body: d, typed: false };
548
+ });
549
+ }
550
+ if (kind === 'container') return [{ type: null, refs: [], body: text, typed: false }];
551
+
552
+ // .tf.json and plan JSON.
553
+ let doc;
554
+ try { doc = JSON.parse(text); } catch { return [{ type: null, refs: [], body: flattenJson(text), typed: false }]; }
555
+ const asBody = (v) => flattenJson(JSON.stringify(prune(v) ?? {}, null, 1));
556
+ if (kind === 'terraform-plan') {
557
+ return planResources(doc).map((r) => {
558
+ const v = prune(r.body) || {};
559
+ // A plan carries values, not expressions, so a setting names its store
560
+ // by the store's own bucket name or id.
561
+ const refs = ['bucket', 'id'].map((k) => v[k]).filter((x) => typeof x === 'string' && x.length >= 3)
562
+ .map((x) => new RegExp(escRe(JSON.stringify(x))));
563
+ return { type: r.type, refs, body: asBody(r.body), typed: true };
564
+ });
565
+ }
566
+ const units = tfJsonResources(doc).map((r) => ({ type: r.type, refs: [addressRef(`${r.type}.${r.name}`)], body: asBody(r.body), typed: true }));
567
+ // Everything outside `resource` (providers, variables, locals) is one untyped
568
+ // unit, the way the text before the first resource block is in a .tf file.
569
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) {
570
+ const rest = { ...doc }; delete rest.resource;
571
+ units.push({ type: null, refs: [], body: asBody(rest), typed: false });
572
+ }
573
+ return units;
574
+ }
360
575
 
361
576
  function classify(file, text) {
362
577
  const n = basename(file).toLowerCase();
@@ -393,7 +608,9 @@ function lint() {
393
608
  const byKind = { terraform: 0, 'terraform-plan': 0, kubernetes: 0, container: 0 };
394
609
  const regions = new Set();
395
610
  const resourceTypes = new Map();
396
- let read = 0, storage = 0, storageEncrypted = 0, logging = 0, identity = 0;
611
+ const stores = []; // { refs, encrypted }, one per storage declaration
612
+ const encryptingSettings = []; // bodies of storage settings that declare encryption
613
+ let read = 0, logging = 0, identity = 0;
397
614
  let openIngress = 0, publicAccess = 0, inlineCredentials = 0;
398
615
 
399
616
  for (const f of files) {
@@ -407,29 +624,44 @@ function lint() {
407
624
  byKind[kind]++;
408
625
  read++;
409
626
 
410
- for (const m of text.matchAll(REGION_RE)) regions.add(m[1]);
627
+ // JSON sources are read through flattenJson so `"region": "x"` and
628
+ // `"password": "..."` meet the same patterns as HCL and YAML.
629
+ const isJson = extname(f).toLowerCase() === '.json';
630
+ const flat = isJson ? flattenJson(text) : text;
631
+ for (const m of flat.matchAll(REGION_RE)) regions.add(m[1]);
632
+ for (const line of flat.split('\n')) if (SECRET_RE.test(line)) inlineCredentials++;
411
633
 
412
- if (kind === 'terraform') {
634
+ if (kind === 'terraform' && !isJson) {
413
635
  for (const m of text.matchAll(RESOURCE_RE)) {
414
636
  resourceTypes.set(m[1], (resourceTypes.get(m[1]) || 0) + 1);
415
637
  }
416
638
  }
417
639
 
418
- // Terraform is split on top-level resource blocks so an attribute in one
419
- // resource is never credited to another; every other kind is read whole.
420
- const blocks = kind === 'terraform' ? text.split(/\n(?=resource\s+")/) : [text];
421
- for (const b of blocks) {
422
- const header = b.match(/^resource\s+"([a-z0-9_]+)"/);
423
- const subject = header ? header[1] : b;
424
- if (STORAGE_TYPE.test(subject)) { storage++; if (ENCRYPT_RE.test(b)) storageEncrypted++; }
425
- if (LOGGING_TYPE.test(subject)) logging++;
426
- if (IDENTITY_TYPE.test(subject)) identity++;
427
- if (OPEN_CIDR_RE.test(b)) openIngress++;
428
- if (PUBLIC_RE.test(b)) publicAccess++;
640
+ // Each unit is classified on its own type, so an attribute in one resource
641
+ // is never credited to another, and a word in a file's text is never taken
642
+ // for a resource type.
643
+ for (const u of unitsOf(kind, f, text)) {
644
+ const t = u.type;
645
+ if (t && u.typed) resourceTypes.set(t, (resourceTypes.get(t) || 0) + 1);
646
+ if (t && STORAGE_TYPE.test(t)) {
647
+ if (isStorageSetting(t)) { if (declaresEncryption(u.body)) encryptingSettings.push(u.body); }
648
+ else stores.push({ refs: u.refs, encrypted: declaresEncryption(u.body) });
649
+ }
650
+ if (t && LOGGING_TYPE.test(t)) logging++;
651
+ if (t && IDENTITY_TYPE.test(t)) identity++;
652
+ if (OPEN_CIDR_RE.test(u.body)) openIngress++;
653
+ if (PUBLIC_RE.test(u.body)) publicAccess++;
429
654
  }
430
- for (const line of text.split('\n')) if (SECRET_RE.test(line)) inlineCredentials++;
431
655
  }
432
656
 
657
+ // A store declares encryption in its own body, or through a setting resource
658
+ // (anywhere in the tree) that declares encryption and refers to it.
659
+ for (const s of stores) {
660
+ if (!s.encrypted && s.refs.some((re) => encryptingSettings.some((b) => re.test(b)))) s.encrypted = true;
661
+ }
662
+ const storage = stores.length;
663
+ const storageEncrypted = stores.filter((s) => s.encrypted).length;
664
+
433
665
  if (read === 0) {
434
666
  const msg = `no infrastructure declarations found under ${target}.`;
435
667
  diag(`${A}nothing to read${X} ${msg}`);
@@ -466,7 +698,7 @@ function lint() {
466
698
  root: (() => { const r = relative(process.cwd(), target); return !r ? '.' : r.startsWith('..') ? target : r; })(),
467
699
  facts,
468
700
  digest: `sha256:${digest}`,
469
- note: 'Declared facts only. Read locally; nothing was transmitted. No score, no verdict, no assessment against any standard.',
701
+ note: 'Declared facts only. Read locally; nothing was transmitted. No verdict and no assessment against any standard.',
470
702
  };
471
703
 
472
704
  if (asJson) { emitJson(doc); process.exit(EXIT.OK); }
@@ -495,10 +727,16 @@ function lint() {
495
727
 
496
728
  out(`\n${B}Digest${X} ${C}${doc.digest}${X}`);
497
729
  out(`${D}A SHA-256 over the facts above, in canonical form, with the timestamp excluded.`);
498
- out(`The same tree always produces the same digest, so you can record it as evidence`);
499
- out(`that a state was observed without publishing the tree it came from.${X}`);
730
+ out(`The same tree read by the same trooth version produces the same digest, so you can`);
731
+ out(`record it as evidence that a state was observed without publishing the tree.${X}`);
732
+
733
+ out(`\n${B}How this was read${X}`);
734
+ out(`${D} A pattern reader, not a Terraform evaluator: variables and modules are not resolved.`);
735
+ out(` .tf by pattern, one resource block at a time. .tf.json and plan JSON parsed, one`);
736
+ out(` resource at a time. Kubernetes YAML by pattern, one document at a time, by kind.`);
737
+ out(` Dockerfiles for regions, open addresses, public markers and credential literals only.${X}`);
500
738
 
501
- out(`\n${D}Counts of what the files declare. Not a judgement: a public load balancer is`);
739
+ out(`\n${D}Counts of what the files declare. Not a judgment: a public load balancer is`);
502
740
  out(`supposed to be public. Trooth issues no verdict here and checks nothing against`);
503
741
  out(`any standard. Nothing left this machine: lint opens files and opens no sockets.`);
504
742
  out(`Publish what you choose on your record at ${X}${C}https://trooth.co/dashboard${X}${D}.${X}\n`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "trooth",
3
- "version": "0.4.2",
4
- "description": "The Trooth Network from your terminal. Read any company's public, signed record with no key and no account: identity, products, terms, people, documents, security and AI posture. Also reads what your own repo declares, offline. No score, no verdict.",
3
+ "version": "0.4.4",
4
+ "description": "The Trooth Network from your terminal. Read a company's public, signed record with no key and no account: listing state, dates, live-probe and self-attestation counts, signing key id. Also reads what your own repo declares, offline. No verdict.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "trooth": "bin/trooth.mjs"
@@ -17,7 +17,7 @@
17
17
  "scripts": {
18
18
  "check": "node ./bin/trooth.mjs check",
19
19
  "lint": "node ./bin/trooth.mjs lint",
20
- "test": "node ./bin/trooth.mjs lint tests/fixtures/infra --json > /dev/null && node ./bin/trooth.mjs --version"
20
+ "test": "node ./bin/trooth.mjs lint tests/fixtures/infra --json > /dev/null && node ./bin/trooth.mjs --version && node tests/action.test.mjs && node tests/lint.test.mjs"
21
21
  },
22
22
  "license": "Apache-2.0",
23
23
  "author": "Trooth, LLC",
@@ -36,9 +36,7 @@
36
36
  "vendor-lookup",
37
37
  "due-diligence",
38
38
  "procurement",
39
- "trust-network",
40
39
  "company-directory",
41
- "mcp",
42
40
  "ai-agents",
43
41
  "agent-tools",
44
42
  "signed-records",