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.
- package/README.md +160 -108
- package/bin/trooth.mjs +309 -71
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
+
# No install:
|
|
19
20
|
npx trooth check stripe.com
|
|
20
21
|
|
|
21
|
-
#
|
|
22
|
-
npm
|
|
23
|
-
trooth
|
|
22
|
+
# Or install it:
|
|
23
|
+
npm install -g trooth
|
|
24
|
+
trooth --version
|
|
24
25
|
```
|
|
25
26
|
|
|
26
|
-
|
|
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
|
|
33
|
-
| `trooth lint [path]` | Reads the infrastructure the given directory
|
|
34
|
-
| `trooth --help`
|
|
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
|
-
|
|
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
|
|
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 |
|
|
50
|
-
| 1 |
|
|
51
|
-
| 2 |
|
|
52
|
-
| 3 | Trooth unreachable, non-2xx response, or
|
|
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
|
|
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 //
|
|
64
|
+
Trooth Network // public · signed · read-only //
|
|
66
65
|
Trooth, LLC trooth.co
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
102
|
+
"badge_id": "rw_...",
|
|
94
103
|
"probes": { "passed": 64, "total": 65 },
|
|
95
104
|
"attested": { "passed": 27, "total": 35 },
|
|
96
|
-
"events": [
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
124
|
-
terraform
|
|
138
|
+
tests/fixtures/infra 2 declaration file(s) read
|
|
139
|
+
terraform 1 · kubernetes 1
|
|
125
140
|
|
|
126
141
|
Declared
|
|
127
|
-
Regions and zones
|
|
128
|
-
Storage declarations
|
|
129
|
-
of those declaring encryption
|
|
130
|
-
Logging declarations
|
|
131
|
-
Identity declarations
|
|
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
|
|
148
|
+
Marked public 0
|
|
134
149
|
Inline credential literals 0
|
|
135
150
|
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
reader, not a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
154
|
-
trooth lint --json > trooth-
|
|
182
|
+
# Keep the fact document as a build artifact.
|
|
183
|
+
trooth lint --json > trooth-lint.json
|
|
155
184
|
```
|
|
156
185
|
|
|
157
|
-
##
|
|
186
|
+
## In GitHub Actions
|
|
158
187
|
|
|
159
|
-
The same
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
195
|
-
MCP server are interfaces to it, not separate products.
|
|
239
|
+
## Security
|
|
196
240
|
|
|
197
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
8
|
-
// domain and marketing links, people, documents, security and privacy posture,
|
|
9
|
-
// procurement terms, relationships and sub-processors
|
|
10
|
-
//
|
|
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
|
|
22
|
-
// It does not check anything against a named
|
|
23
|
-
// It does not produce a verdict, a
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
|
244
|
-
out(`\n${D}
|
|
245
|
-
out(`
|
|
246
|
-
out(`
|
|
247
|
-
out(
|
|
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}//
|
|
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(`
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
out(
|
|
269
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
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
|
|
296
|
-
*
|
|
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
|
|
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
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
for (const
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
if (STORAGE_TYPE.test(
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
if (
|
|
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
|
|
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
|
|
499
|
-
out(`that a state was observed without publishing the tree
|
|
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
|
|
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.
|
|
4
|
-
"description": "The Trooth Network from your terminal. Read
|
|
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",
|