trooth 0.2.0 → 0.4.0
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 +147 -37
- package/bin/trooth.mjs +446 -232
- package/package.json +21 -20
package/README.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# trooth
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
The Trooth Network from your terminal.
|
|
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.
|
|
7
10
|
|
|
8
11
|
**`check` reads only public, already-published records. No key, no account.**
|
|
9
|
-
**`
|
|
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.**
|
|
10
14
|
|
|
11
15
|
## Install / run
|
|
12
16
|
|
|
@@ -19,17 +23,39 @@ npm i -g trooth
|
|
|
19
23
|
trooth check trooth.co
|
|
20
24
|
```
|
|
21
25
|
|
|
26
|
+
Requires Node 18+ (uses built-in `fetch`). Zero dependencies.
|
|
27
|
+
|
|
22
28
|
## Commands
|
|
23
29
|
|
|
24
30
|
| Command | What it does |
|
|
25
31
|
| --- | --- |
|
|
26
|
-
| `trooth check <domain>` | Reads a company's
|
|
27
|
-
| `trooth
|
|
28
|
-
| `trooth eu ...` | Pushes EU AI Act evidence (datasets, evals, drift) into Trooth from CI/CD. Needs a session token or company id. |
|
|
29
|
-
| `trooth lint [path]` | Local, read-only IaC drift check. Never transmits your code. |
|
|
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. |
|
|
30
34
|
| `trooth --help` / `--version` | Help / version. |
|
|
31
35
|
|
|
32
|
-
##
|
|
36
|
+
## Flags
|
|
37
|
+
|
|
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}`.
|
|
41
|
+
|
|
42
|
+
Any other flag is a usage error (exit 2) that names the flag and lists the
|
|
43
|
+
known ones.
|
|
44
|
+
|
|
45
|
+
## Exit codes
|
|
46
|
+
|
|
47
|
+
| 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 |
|
|
53
|
+
|
|
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".
|
|
57
|
+
|
|
58
|
+
## `trooth check`
|
|
33
59
|
|
|
34
60
|
```bash
|
|
35
61
|
trooth check trooth.co
|
|
@@ -38,50 +64,134 @@ trooth check trooth.co
|
|
|
38
64
|
```
|
|
39
65
|
Trooth Network // witnessed · public · read-only //
|
|
40
66
|
Trooth, LLC trooth.co
|
|
41
|
-
Standing:
|
|
42
|
-
|
|
43
|
-
By discipline
|
|
44
|
-
Legal ██████████ 20/20
|
|
45
|
-
Security ████████░░ 16/20
|
|
46
|
-
AI governance ██████████ 19/20
|
|
47
|
-
Business █████████░ 18/20
|
|
48
|
-
IP █████████░ 18/20
|
|
67
|
+
Standing: listed and witnessed witnessed 2026-08-30 first published 2026-08-01
|
|
49
68
|
|
|
50
69
|
Live probes 64/65 Attestations 27/35
|
|
51
|
-
|
|
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
|
|
52
73
|
|
|
53
|
-
|
|
74
|
+
Recent witness events
|
|
75
|
+
• 2026-08-30 rewitnessed 64 of 65 live probes re-run
|
|
76
|
+
|
|
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
|
|
54
79
|
```
|
|
55
80
|
|
|
56
81
|
```bash
|
|
57
|
-
# For scripting:
|
|
58
|
-
trooth check
|
|
82
|
+
# For scripting: one JSON document on stdout, exit 1 when a company is not listed.
|
|
83
|
+
trooth check trooth.co --json
|
|
59
84
|
```
|
|
60
85
|
|
|
61
|
-
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"domain": "trooth.co",
|
|
89
|
+
"listed": true,
|
|
90
|
+
"company_name": "Trooth, LLC",
|
|
91
|
+
"witnessed_at": "2026-08-30T00:00:00Z",
|
|
92
|
+
"first_published_at": "2026-08-01T00:00:00Z",
|
|
93
|
+
"badge_id": "bronze_rw_...",
|
|
94
|
+
"probes": { "passed": 64, "total": 65 },
|
|
95
|
+
"attested": { "passed": 27, "total": 35 },
|
|
96
|
+
"events": [ { "type": "rewitnessed", "at": "2026-08-30T00:00:00Z", "detail": "64 of 65 live probes re-run" } ],
|
|
97
|
+
"receipt_signature": "...",
|
|
98
|
+
"authority_key_id": "ed25519-2026-01",
|
|
99
|
+
"verify_keys": "https://api.trooth.co/public/keys",
|
|
100
|
+
"record_url": "https://trooth.co/network/trooth.co"
|
|
101
|
+
}
|
|
102
|
+
```
|
|
62
103
|
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
65
108
|
|
|
66
|
-
##
|
|
109
|
+
## `trooth lint`
|
|
67
110
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
trooth lint ./infra
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
trooth lint // local · offline · declarations only //
|
|
123
|
+
./infra 14 declaration file(s) read
|
|
124
|
+
terraform 11 · kubernetes 3
|
|
125
|
+
|
|
126
|
+
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
|
|
132
|
+
Open to any address (0.0.0.0/0, ::/0) 1
|
|
133
|
+
Marked public 2
|
|
134
|
+
Inline credential literals 0
|
|
135
|
+
|
|
136
|
+
Digest sha256:a963291330bce2ab…
|
|
137
|
+
```
|
|
138
|
+
|
|
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.
|
|
142
|
+
|
|
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.
|
|
146
|
+
|
|
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.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
# In CI: keep the fact document as a build artifact.
|
|
154
|
+
trooth lint --json > trooth-attestation.json
|
|
155
|
+
```
|
|
77
156
|
|
|
78
157
|
## Use it from an AI assistant
|
|
79
158
|
|
|
80
|
-
The same public network powers Trooth's read-only MCP server, so ChatGPT,
|
|
81
|
-
or any MCP client can ask about a company in plain words:
|
|
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:
|
|
82
161
|
|
|
83
162
|
```
|
|
84
163
|
https://api.trooth.co/public/mcp
|
|
85
164
|
```
|
|
86
165
|
|
|
166
|
+
## Notes
|
|
167
|
+
|
|
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.
|
|
177
|
+
|
|
178
|
+
## Changes in 0.4.0
|
|
179
|
+
|
|
180
|
+
Breaking, and deliberately so.
|
|
181
|
+
|
|
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.
|
|
193
|
+
|
|
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.
|
|
196
|
+
|
|
87
197
|
Trooth automates. Trooth never signs for you.
|
package/bin/trooth.mjs
CHANGED
|
@@ -2,97 +2,191 @@
|
|
|
2
2
|
// Trooth CLI. Command name: `trooth`
|
|
3
3
|
// Copyright (c) 2026 Trooth, LLC. All rights reserved.
|
|
4
4
|
//
|
|
5
|
+
// The Trooth Network is one public record per company: identity, products and demos,
|
|
6
|
+
// domain and marketing links, people, documents, security and privacy posture,
|
|
7
|
+
// procurement terms, relationships and sub-processors — each one witnessed, signed
|
|
8
|
+
// and dated. This CLI is the terminal interface to that record.
|
|
9
|
+
//
|
|
5
10
|
// Commands:
|
|
6
|
-
// trooth check <domain>
|
|
7
|
-
//
|
|
8
|
-
// trooth
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// trooth
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// trooth check <domain> Read a company's witnessed record from the public Trooth
|
|
12
|
+
// Network. Read-only. No key, no account, nothing sent about you.
|
|
13
|
+
// trooth lint [path] Read the infrastructure THIS repository declares and print
|
|
14
|
+
// the declared facts plus a canonical digest of them.
|
|
15
|
+
// Fully local. Offline. Your source never leaves the machine.
|
|
16
|
+
// trooth --help Show help. trooth --version Show version.
|
|
17
|
+
//
|
|
18
|
+
// WHAT THIS TOOL DOES NOT DO, ON PURPOSE:
|
|
19
|
+
// It does not score, rate, rank, grade or tier a company or a repository.
|
|
20
|
+
// It does not check anything against a named certification, standard or regulation.
|
|
21
|
+
// It does not produce a verdict, a pass mark or a percentage.
|
|
22
|
+
// It publishes facts and counts, reported apart, and never adds them into one number.
|
|
23
|
+
//
|
|
24
|
+
// Exit codes (stable, for scripts):
|
|
25
|
+
// 0 ok (listed; lint read at least one declaration; help/version)
|
|
26
|
+
// 1 finding (domain not listed; lint found nothing to read)
|
|
27
|
+
// 2 usage error (missing argument, unknown flag or command, unreadable path)
|
|
28
|
+
// 3 network/upstream error (Trooth unreachable, non-2xx, malformed response)
|
|
14
29
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// only your declared plan. Trooth automates. Trooth never signs for you.
|
|
30
|
+
// With --json, stdout carries exactly one JSON document and nothing else. Every
|
|
31
|
+
// diagnostic goes to stderr.
|
|
18
32
|
|
|
19
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
20
|
-
import {
|
|
33
|
+
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
21
35
|
import { createRequire } from 'node:module';
|
|
36
|
+
import { join, relative, extname, basename } from 'node:path';
|
|
22
37
|
|
|
23
38
|
const API = process.env.TROOTH_API || 'https://api.trooth.co';
|
|
24
|
-
const J='\x1b[32m', D='\x1b[2m', B='\x1b[1m', R='\x1b[31m', A='\x1b[33m', C='\x1b[36m', X='\x1b[0m';
|
|
25
39
|
const require = createRequire(import.meta.url);
|
|
26
|
-
let VERSION = '0.
|
|
40
|
+
let VERSION = '0.4.0';
|
|
27
41
|
try { VERSION = require('../package.json').version; } catch {}
|
|
28
42
|
|
|
43
|
+
const EXIT = { OK: 0, FINDING: 1, USAGE: 2, UPSTREAM: 3 };
|
|
44
|
+
|
|
45
|
+
// Colour only when stdout is a TTY and NO_COLOR is unset, so piped output is clean.
|
|
46
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
47
|
+
const c = (code) => (useColor ? code : '');
|
|
48
|
+
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');
|
|
49
|
+
|
|
29
50
|
const argv = process.argv.slice(2);
|
|
30
51
|
const cmd = argv[0];
|
|
52
|
+
const asJson = argv.includes('--json');
|
|
53
|
+
|
|
54
|
+
/* ------------------------------------------------------------- output ---- */
|
|
55
|
+
|
|
56
|
+
function out(s) { process.stdout.write(s + '\n'); }
|
|
57
|
+
function diag(s) { process.stderr.write(s + '\n'); }
|
|
58
|
+
function emitJson(obj) { out(JSON.stringify(obj, null, 2)); }
|
|
59
|
+
|
|
60
|
+
function fail(code, message, extra = {}) {
|
|
61
|
+
diag(`${R}error${X} ${message}`);
|
|
62
|
+
if (asJson) emitJson({ ok: false, error: message, exit: code, ...extra });
|
|
63
|
+
process.exit(code);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Keys that would carry a score, tier, grade, rank, rating or percentage. Nothing
|
|
67
|
+
* under these names is ever printed, in any mode, no matter what an upstream feed
|
|
68
|
+
* sends. `rate` is matched only as a whole key so keys like generatedAt survive. */
|
|
69
|
+
const BANNED_KEY = /score|tier|grade|rank|rating|level|percent|^rate$/i;
|
|
70
|
+
|
|
71
|
+
function scrub(value) {
|
|
72
|
+
if (Array.isArray(value)) return value.map(scrub);
|
|
73
|
+
if (value && typeof value === 'object') {
|
|
74
|
+
const clean = {};
|
|
75
|
+
for (const [k, v] of Object.entries(value)) {
|
|
76
|
+
if (BANNED_KEY.test(k)) continue;
|
|
77
|
+
clean[k] = scrub(v);
|
|
78
|
+
}
|
|
79
|
+
return clean;
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* --------------------------------------------------------------- args ---- */
|
|
85
|
+
|
|
86
|
+
const FLAGS = {
|
|
87
|
+
check: { bool: ['--json'], value: [] },
|
|
88
|
+
lint: { bool: ['--json'], value: [] },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Commands that existed in an earlier release and are gone. Naming them explicitly
|
|
92
|
+
* means an old README, an old CI job or an old blog post gets a sentence that says
|
|
93
|
+
* what happened, instead of "unknown command". */
|
|
94
|
+
const RETIRED = {
|
|
95
|
+
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.',
|
|
96
|
+
eu: 'Trooth does not ingest regulation-specific evidence. Publish evidence on your company record at https://trooth.co/dashboard.',
|
|
97
|
+
preflight: 'Retired. `trooth lint` reads declared infrastructure facts locally instead.',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function parseArgs(command) {
|
|
101
|
+
const spec = FLAGS[command];
|
|
102
|
+
const flags = {};
|
|
103
|
+
const positional = [];
|
|
104
|
+
for (let i = 1; i < argv.length; i++) {
|
|
105
|
+
const a = argv[i];
|
|
106
|
+
if (a === '--') { positional.push(...argv.slice(i + 1)); break; }
|
|
107
|
+
if (a.startsWith('--')) {
|
|
108
|
+
const [name, inlineVal] = a.split('=', 2);
|
|
109
|
+
if (spec.bool.includes(name)) { flags[name] = true; continue; }
|
|
110
|
+
if (spec.value.includes(name)) {
|
|
111
|
+
const v = inlineVal !== undefined ? inlineVal : argv[++i];
|
|
112
|
+
if (v === undefined || v.startsWith('--')) fail(EXIT.USAGE, `${name} needs a value.`);
|
|
113
|
+
flags[name] = v; continue;
|
|
114
|
+
}
|
|
115
|
+
const known = [...spec.bool, ...spec.value];
|
|
116
|
+
fail(EXIT.USAGE, `unknown flag ${name} for \`trooth ${command}\`. ` +
|
|
117
|
+
(known.length ? `Known flags: ${known.join(', ')}.` : `\`trooth ${command}\` takes no flags.`) +
|
|
118
|
+
` Run \`trooth --help\`.`);
|
|
119
|
+
} else if (a.startsWith('-') && a.length > 1) {
|
|
120
|
+
fail(EXIT.USAGE, `unknown flag ${a} for \`trooth ${command}\`. Run \`trooth --help\`.`);
|
|
121
|
+
} else {
|
|
122
|
+
positional.push(a);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { flags, positional };
|
|
126
|
+
}
|
|
31
127
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
128
|
+
/* --------------------------------------------------------------- help ---- */
|
|
129
|
+
|
|
130
|
+
function helpText() {
|
|
131
|
+
return `
|
|
132
|
+
${J}${B}trooth${X} ${D}v${VERSION} · the terminal interface to the Trooth Network${X}
|
|
35
133
|
|
|
36
134
|
${B}Usage${X}
|
|
37
|
-
trooth check <domain> Read a company's witnessed
|
|
38
|
-
trooth
|
|
39
|
-
trooth lint [path] Local read-only IaC drift check
|
|
135
|
+
trooth check <domain> Read a company's witnessed record on the Trooth Network
|
|
136
|
+
trooth lint [path] Read what your infrastructure declares, locally. Offline.
|
|
40
137
|
trooth --help | --version
|
|
41
138
|
|
|
42
139
|
${B}Examples${X}
|
|
43
140
|
trooth check stripe.com ${D}# read a company's witnessed record${X}
|
|
44
|
-
trooth check trooth.co --json ${D}#
|
|
45
|
-
|
|
141
|
+
trooth check trooth.co --json ${D}# one JSON document on stdout, for scripting${X}
|
|
142
|
+
trooth lint ./infra ${D}# read declared facts + print a canonical digest${X}
|
|
143
|
+
trooth lint --json >> attestation.json ${D}# the same facts, for a CI artifact${X}
|
|
46
144
|
|
|
47
145
|
${B}Flags${X}
|
|
48
|
-
--json
|
|
49
|
-
|
|
146
|
+
--json machine-readable JSON on stdout; diagnostics on stderr
|
|
147
|
+
|
|
148
|
+
${B}Exit codes${X}
|
|
149
|
+
0 ok 1 finding (not listed / nothing declared) 2 usage error 3 Trooth unreachable
|
|
150
|
+
|
|
151
|
+
${D}check reads only public, already-published records. No key, no account.
|
|
152
|
+
lint is entirely local: it opens files, and opens no sockets. Your source never leaves.
|
|
153
|
+
Trooth publishes facts and counts, never one number that sums a company up.
|
|
154
|
+
Trooth automates. Trooth never signs for you.${X}
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* -------------------------------------------------------------- fetch ---- */
|
|
50
159
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
160
|
+
async function callTrooth(path, init, what) {
|
|
161
|
+
let res;
|
|
162
|
+
try {
|
|
163
|
+
res = await fetch(`${API}${path}`, {
|
|
164
|
+
...init,
|
|
165
|
+
headers: { accept: 'application/json', 'user-agent': `trooth-cli/${VERSION}`, ...(init && init.headers) },
|
|
166
|
+
});
|
|
167
|
+
} catch (e) {
|
|
168
|
+
fail(EXIT.UPSTREAM, `could not reach ${what} at ${API}: ${e && e.message ? e.message : e}`);
|
|
169
|
+
}
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
const body = await res.text().catch(() => '');
|
|
172
|
+
fail(EXIT.UPSTREAM, `${what} returned HTTP ${res.status}.${body ? ' ' + body.slice(0, 300).replace(/\s+/g, ' ') : ''}`, { http_status: res.status });
|
|
173
|
+
}
|
|
174
|
+
try { return await res.json(); }
|
|
175
|
+
catch { fail(EXIT.UPSTREAM, `${what} returned a response that is not JSON.`); }
|
|
54
176
|
}
|
|
55
177
|
|
|
56
178
|
/* --------------------------------------------------------------- check ---- */
|
|
57
179
|
|
|
58
|
-
/** Normalize a domain the way the Trooth Network does: strip scheme, path,
|
|
59
|
-
* leading www, case, and trailing slash. */
|
|
60
180
|
function normalizeDomain(input) {
|
|
61
181
|
if (!input) return '';
|
|
62
182
|
let s = String(input).trim().toLowerCase();
|
|
63
|
-
s = s.replace(/^[a-z]+:\/\//, '');
|
|
64
|
-
s = s.replace(/\/.*$/, '');
|
|
65
|
-
s = s.replace(/^www\./, '');
|
|
66
|
-
s = s.replace(/\.$/, '');
|
|
183
|
+
s = s.replace(/^[a-z]+:\/\//, '');
|
|
184
|
+
s = s.replace(/\/.*$/, '');
|
|
185
|
+
s = s.replace(/^www\./, '');
|
|
186
|
+
s = s.replace(/\.$/, '');
|
|
67
187
|
return s;
|
|
68
188
|
}
|
|
69
189
|
|
|
70
|
-
const CATEGORY_LABELS = {
|
|
71
|
-
legal: 'Legal',
|
|
72
|
-
security: 'Security',
|
|
73
|
-
ai: 'AI governance',
|
|
74
|
-
business: 'Business',
|
|
75
|
-
ip: 'IP',
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/** Pull a 0..100 score out of the feed's score field (object or number). */
|
|
79
|
-
function scorePct(score) {
|
|
80
|
-
if (score == null) return null;
|
|
81
|
-
if (typeof score === 'number') return Math.round(score);
|
|
82
|
-
if (typeof score === 'object') {
|
|
83
|
-
if (typeof score.rate === 'number') return Math.round(score.rate * 100);
|
|
84
|
-
if (typeof score.passed === 'number' && typeof score.total === 'number' && score.total > 0)
|
|
85
|
-
return Math.round((score.passed / score.total) * 100);
|
|
86
|
-
}
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function bar(passed, total) {
|
|
91
|
-
if (!total) return '';
|
|
92
|
-
const filled = Math.round((passed / total) * 10);
|
|
93
|
-
return '█'.repeat(filled) + '░'.repeat(10 - filled);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
190
|
function fmtDate(iso) {
|
|
97
191
|
if (!iso) return '';
|
|
98
192
|
const d = new Date(iso);
|
|
@@ -100,208 +194,328 @@ function fmtDate(iso) {
|
|
|
100
194
|
return d.toISOString().slice(0, 10);
|
|
101
195
|
}
|
|
102
196
|
|
|
197
|
+
function count(obj) {
|
|
198
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
199
|
+
if (typeof obj.passed !== 'number' || typeof obj.total !== 'number') return null;
|
|
200
|
+
return { passed: obj.passed, total: obj.total };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function projectRecord(v, domain) {
|
|
204
|
+
const events = Array.isArray(v.events)
|
|
205
|
+
? v.events.filter((e) => e && e.type).map((e) => ({ type: String(e.type), at: e.at, detail: e.detail }))
|
|
206
|
+
: [];
|
|
207
|
+
const rec = {
|
|
208
|
+
domain,
|
|
209
|
+
listed: true,
|
|
210
|
+
company_name: v.company_name || domain,
|
|
211
|
+
witnessed_at: v.passed_at || null,
|
|
212
|
+
first_published_at: v.first_published_at || null,
|
|
213
|
+
badge_id: v.badge_id || null,
|
|
214
|
+
probes: count(v.probes),
|
|
215
|
+
attested: count(v.attested),
|
|
216
|
+
events,
|
|
217
|
+
receipt_signature: v.receipt_signature || null,
|
|
218
|
+
authority_key_id: v.authority_key_id || null,
|
|
219
|
+
verify_keys: `${API}/public/keys`,
|
|
220
|
+
record_url: `https://trooth.co/network/${encodeURIComponent(domain)}`,
|
|
221
|
+
};
|
|
222
|
+
if (v.category) rec.category = String(v.category);
|
|
223
|
+
if (v.description) rec.description = String(v.description);
|
|
224
|
+
return scrub(rec);
|
|
225
|
+
}
|
|
226
|
+
|
|
103
227
|
async function check() {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
const domain = normalizeDomain(
|
|
107
|
-
if (!domain)
|
|
108
|
-
console.error(`${R}error${X} missing <domain>. Try: trooth check stripe.com`);
|
|
109
|
-
process.exit(2);
|
|
110
|
-
}
|
|
228
|
+
const { positional } = parseArgs('check');
|
|
229
|
+
if (positional.length > 1) fail(EXIT.USAGE, `check takes one <domain>, got: ${positional.join(' ')}`);
|
|
230
|
+
const domain = normalizeDomain(positional[0]);
|
|
231
|
+
if (!domain) fail(EXIT.USAGE, 'missing <domain>. Try: trooth check stripe.com');
|
|
111
232
|
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
res = await fetch(`${API}/directory/api/vendors`, {
|
|
115
|
-
headers: { accept: 'application/json', 'user-agent': `trooth-cli/${VERSION}` },
|
|
116
|
-
});
|
|
117
|
-
} catch (e) {
|
|
118
|
-
console.error(`${R}error${X} could not reach the Trooth Network at ${API}: ${e.message}`);
|
|
119
|
-
process.exit(1);
|
|
120
|
-
}
|
|
121
|
-
if (!res.ok) {
|
|
122
|
-
console.error(`${R}error${X} the Trooth Network returned HTTP ${res.status}.`);
|
|
123
|
-
process.exit(1);
|
|
124
|
-
}
|
|
125
|
-
let data;
|
|
126
|
-
try { data = await res.json(); } catch { data = {}; }
|
|
233
|
+
const data = await callTrooth('/directory/api/vendors', { method: 'GET' }, 'the Trooth Network');
|
|
127
234
|
const vendors = Array.isArray(data && data.vendors) ? data.vendors : [];
|
|
128
235
|
const vendor = vendors.find((v) => v && normalizeDomain(v.domain) === domain) || null;
|
|
129
236
|
|
|
130
|
-
if (asJson) {
|
|
131
|
-
console.log(JSON.stringify(vendor || { domain, listed: false }, null, 2));
|
|
132
|
-
process.exit(vendor ? 0 : 1);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
237
|
if (!vendor) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const pct = scorePct(vendor.score);
|
|
145
|
-
const name = vendor.company_name || domain;
|
|
146
|
-
const tier = vendor.tier ? vendor.tier[0].toUpperCase() + vendor.tier.slice(1) : 'Listed';
|
|
147
|
-
const when = fmtDate(vendor.passed_at);
|
|
148
|
-
|
|
149
|
-
console.log(`\n${J}${B}Trooth Network${X} ${D}// witnessed · public · read-only //${X}`);
|
|
150
|
-
console.log(`${B}${name}${X} ${C}${domain}${X}`);
|
|
151
|
-
const scoreStr = pct == null ? `${D}not yet scored${X}` : `${B}${pct}/100${X}`;
|
|
152
|
-
console.log(`Standing: ${scoreStr} ${J}${tier}${X}${when ? ` ${D}witnessed ${when}${X}` : ''}`);
|
|
153
|
-
|
|
154
|
-
// Categories (object keyed by discipline).
|
|
155
|
-
const cats = vendor.categories;
|
|
156
|
-
if (cats && typeof cats === 'object' && !Array.isArray(cats)) {
|
|
157
|
-
console.log(`\n${B}By discipline${X}`);
|
|
158
|
-
for (const key of ['legal', 'security', 'ai', 'business', 'ip']) {
|
|
159
|
-
const c = cats[key];
|
|
160
|
-
if (!c || typeof c.total !== 'number') continue;
|
|
161
|
-
const label = (CATEGORY_LABELS[key] || key).padEnd(14);
|
|
162
|
-
const r = c.total > 0 ? c.passed / c.total : 0;
|
|
163
|
-
const col = r >= 0.9 ? J : r >= 0.5 ? A : R;
|
|
164
|
-
console.log(` ${label} ${col}${bar(c.passed, c.total)}${X} ${String(c.passed).padStart(2)}/${c.total}`);
|
|
238
|
+
if (asJson) {
|
|
239
|
+
emitJson({ domain, listed: false, record_url: `https://trooth.co/network/${encodeURIComponent(domain)}` });
|
|
240
|
+
} else {
|
|
241
|
+
out(`\n${B}${domain}${X} ${D}//${X} ${A}not listed on the Trooth Network yet${X}`);
|
|
242
|
+
out(`\n${D}No witnessed record has been published for this domain. That is not a`);
|
|
243
|
+
out(`judgement. It simply has not been witnessed. A company gets a record by`);
|
|
244
|
+
out(`listing at ${X}${C}https://trooth.co/get-started${X}${D}: Trooth reads its public surface and`);
|
|
245
|
+
out(`publishes a signed, dated record that anyone — or any agent — can check.${X}\n`);
|
|
165
246
|
}
|
|
247
|
+
process.exit(EXIT.FINDING);
|
|
166
248
|
}
|
|
167
249
|
|
|
250
|
+
const rec = projectRecord(vendor, domain);
|
|
251
|
+
if (asJson) { emitJson(rec); process.exit(EXIT.OK); }
|
|
252
|
+
|
|
253
|
+
const when = fmtDate(rec.witnessed_at);
|
|
254
|
+
const since = fmtDate(rec.first_published_at);
|
|
255
|
+
out(`\n${J}${B}Trooth Network${X} ${D}// witnessed · public · read-only //${X}`);
|
|
256
|
+
out(`${B}${rec.company_name}${X} ${C}${domain}${X}`);
|
|
257
|
+
out(`Standing: ${J}listed and witnessed${X}` +
|
|
258
|
+
(when ? ` ${D}witnessed ${when}${X}` : ` ${D}date not published${X}`) +
|
|
259
|
+
(since ? ` ${D}first published ${since}${X}` : ''));
|
|
260
|
+
|
|
168
261
|
const line = [];
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
262
|
+
if (rec.probes) line.push(`${B}Live probes${X} ${rec.probes.passed}/${rec.probes.total}`);
|
|
263
|
+
if (rec.attested) line.push(`${B}Attestations${X} ${rec.attested.passed}/${rec.attested.total}`);
|
|
264
|
+
if (line.length) {
|
|
265
|
+
out(`\n${line.join(' ')}`);
|
|
266
|
+
out(`${D}Probes are checks Trooth read for itself. Attestations are the company's own declarations.`);
|
|
267
|
+
out(`They are counts, reported apart on purpose. Trooth never adds them up into one number.${X}`);
|
|
268
|
+
}
|
|
174
269
|
|
|
175
270
|
const ids = [];
|
|
176
|
-
if (
|
|
177
|
-
if (
|
|
178
|
-
if (ids.length)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (!e || !e.type) continue;
|
|
185
|
-
console.log(` ${J}•${X} ${D}${fmtDate(e.at)}${X} ${e.type.replace(/_/g, ' ')}${e.detail ? `${D} ${e.detail}${X}` : ''}`);
|
|
271
|
+
if (rec.badge_id) ids.push(`${D}Badge ${rec.badge_id}${X}`);
|
|
272
|
+
if (rec.authority_key_id) ids.push(`${D}Key ${rec.authority_key_id}${X}`);
|
|
273
|
+
if (ids.length) out(ids.join(' '));
|
|
274
|
+
|
|
275
|
+
if (rec.events.length) {
|
|
276
|
+
out(`\n${B}Recent witness events${X}`);
|
|
277
|
+
for (const e of rec.events.slice(0, 3)) {
|
|
278
|
+
out(` ${J}•${X} ${D}${fmtDate(e.at)}${X} ${e.type.replace(/_/g, ' ')}${e.detail ? `${D} ${e.detail}${X}` : ''}`);
|
|
186
279
|
}
|
|
187
280
|
}
|
|
188
281
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
process.exit(
|
|
282
|
+
out(`\n${D}A witnessed, point-in-time reading of public evidence. Not a certification. Not one number.`);
|
|
283
|
+
out(`Full record: ${X}${C}${rec.record_url}${X}${D} · Signing keys: ${rec.verify_keys}${X}\n`);
|
|
284
|
+
process.exit(EXIT.OK);
|
|
192
285
|
}
|
|
193
286
|
|
|
194
|
-
/* ----------------------------------------------------------------
|
|
287
|
+
/* ---------------------------------------------------------------- lint ---- */
|
|
288
|
+
/* WHAT lint IS, AND WHAT IT IS CAREFULLY NOT.
|
|
289
|
+
*
|
|
290
|
+
* It reads the infrastructure a repository DECLARES and reports those
|
|
291
|
+
* declarations as facts: how many storage resources declare encryption, which
|
|
292
|
+
* regions appear, how many rules declare exposure to the whole internet. It
|
|
293
|
+
* does not judge them. There is no verdict, no pass mark, no severity, no
|
|
294
|
+
* score, and nothing is checked against a named standard or regulation.
|
|
295
|
+
* Declaring public ingress is not a failing; a load balancer is supposed to be
|
|
296
|
+
* public. What the facts mean is the reader's call.
|
|
297
|
+
*
|
|
298
|
+
* It opens files and opens no sockets. Nothing about the repository leaves the
|
|
299
|
+
* machine. The digest at the end is a SHA-256 over the canonical fact document
|
|
300
|
+
* with the timestamp excluded, so the same tree always produces the same digest
|
|
301
|
+
* and you can record it as evidence that a given state was observed, without
|
|
302
|
+
* publishing the tree it came from.
|
|
303
|
+
*
|
|
304
|
+
* It is a declaration reader, not an HCL parser: .tf.json, Kubernetes YAML and
|
|
305
|
+
* terraform plan JSON are parsed properly, and .tf files are read at the
|
|
306
|
+
* attribute level. That limit is stated in the output rather than hidden. */
|
|
307
|
+
|
|
308
|
+
const SKIP_DIRS = new Set([
|
|
309
|
+
'node_modules', '.git', '.terraform', '.next', 'dist', 'build', 'vendor',
|
|
310
|
+
'.venv', 'venv', '__pycache__', '.cache', 'coverage', '.turbo',
|
|
311
|
+
]);
|
|
312
|
+
const MAX_FILES = 5000;
|
|
313
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
314
|
+
|
|
315
|
+
function walk(root) {
|
|
316
|
+
const found = [];
|
|
317
|
+
const stack = [root];
|
|
318
|
+
let st;
|
|
319
|
+
try { st = statSync(root); } catch { return found; }
|
|
320
|
+
if (st.isFile()) return [root];
|
|
321
|
+
while (stack.length && found.length < MAX_FILES) {
|
|
322
|
+
const dir = stack.pop();
|
|
323
|
+
let entries;
|
|
324
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
325
|
+
for (const e of entries) {
|
|
326
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) stack.push(join(dir, e.name)); continue; }
|
|
327
|
+
if (!e.isFile()) continue;
|
|
328
|
+
const n = e.name.toLowerCase();
|
|
329
|
+
const ext = extname(n);
|
|
330
|
+
const keep =
|
|
331
|
+
ext === '.tf' || n.endsWith('.tf.json') ||
|
|
332
|
+
ext === '.yaml' || ext === '.yml' || ext === '.json' ||
|
|
333
|
+
n === 'dockerfile' || n.startsWith('dockerfile.');
|
|
334
|
+
if (keep) found.push(join(dir, e.name));
|
|
335
|
+
if (found.length >= MAX_FILES) break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return found;
|
|
339
|
+
}
|
|
195
340
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
341
|
+
const REGION_RE = /\b(?:region|location|availability_zone|aws_region)\s*[:=]\s*["']([A-Za-z0-9][A-Za-z0-9._-]{2,40})["']/g;
|
|
342
|
+
const RESOURCE_RE = /\bresource\s+"([a-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/g;
|
|
343
|
+
const ENCRYPT_RE = /(?:encrypted|encryption|kms_key|sse_algorithm|server_side_encryption|encrypt_at_rest)/i;
|
|
344
|
+
// Classification runs on the RESOURCE TYPE TOKEN (aws_db_instance, not the body
|
|
345
|
+
// text), matched as a substring. An earlier version wrapped these in \b, which
|
|
346
|
+
// never fires inside a snake_case identifier: \bdynamodb\b cannot match
|
|
347
|
+
// aws_dynamodb_table because the underscore either side is a word character, so
|
|
348
|
+
// every storage resource whose name was not the bare word "bucket" went
|
|
349
|
+
// uncounted. Substring matching on the type is both simpler and correct.
|
|
350
|
+
const STORAGE_TYPE = /(bucket|storage|blob|disk|volume|filestore|_db|database|rds|dynamodb|_sql|redis|memcache|elasticache|efs|fileshare|cosmos|bigtable|spanner)/i;
|
|
351
|
+
const LOGGING_TYPE = /(log|trail|audit|monitor|insight|diagnostic)/i;
|
|
352
|
+
const IDENTITY_TYPE = /(iam|role|policy|service_account|serviceaccount|rbac|identity|access_key|keyring|secret)/i;
|
|
353
|
+
const OPEN_CIDR_RE = /(?:"0\.0\.0\.0\/0"|'0\.0\.0\.0\/0'|"::\/0"|'::\/0')/;
|
|
354
|
+
const PUBLIC_RE = /\b(?:publicly_accessible\s*[:=]\s*true|acl\s*[:=]\s*["']public-read|public_network_access_enabled\s*[:=]\s*true|type\s*:\s*LoadBalancer|type\s*:\s*NodePort)\b/;
|
|
355
|
+
// A literal that looks like a credential sitting in the file. Reported as a
|
|
356
|
+
// count only: no file name, no line, and never the value itself.
|
|
357
|
+
const SECRET_RE = /\b(?:password|secret|api[_-]?key|access[_-]?key|token|private[_-]?key)\s*[:=]\s*["'][^"'${}\n]{8,}["']/i;
|
|
358
|
+
|
|
359
|
+
function classify(file, text) {
|
|
360
|
+
const n = basename(file).toLowerCase();
|
|
361
|
+
if (n.endsWith('.tf')) return 'terraform';
|
|
362
|
+
if (n.endsWith('.tf.json')) return 'terraform';
|
|
363
|
+
if (n === 'dockerfile' || n.startsWith('dockerfile.')) return 'container';
|
|
364
|
+
if (n.endsWith('.yaml') || n.endsWith('.yml')) {
|
|
365
|
+
return /^\s*apiVersion\s*:/m.test(text) && /^\s*kind\s*:/m.test(text) ? 'kubernetes' : null;
|
|
366
|
+
}
|
|
367
|
+
if (n.endsWith('.json')) {
|
|
368
|
+
return /"terraform_version"\s*:/.test(text) &&
|
|
369
|
+
(/"planned_values"\s*:/.test(text) || /"resource_changes"\s*:/.test(text)) ? 'terraform-plan' : null;
|
|
211
370
|
}
|
|
371
|
+
return null;
|
|
212
372
|
}
|
|
213
373
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
374
|
+
/** Canonical JSON: object keys sorted at every depth, so the digest depends on
|
|
375
|
+
* the facts and not on the order a walk happened to produce them. */
|
|
376
|
+
function canonical(v) {
|
|
377
|
+
if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
|
|
378
|
+
if (v && typeof v === 'object') {
|
|
379
|
+
return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
|
|
380
|
+
}
|
|
381
|
+
return JSON.stringify(v === undefined ? null : v);
|
|
382
|
+
}
|
|
219
383
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
384
|
+
function lint() {
|
|
385
|
+
const { positional } = parseArgs('lint');
|
|
386
|
+
if (positional.length > 1) fail(EXIT.USAGE, `lint takes one optional [path], got: ${positional.join(' ')}`);
|
|
387
|
+
const target = positional[0] || '.';
|
|
388
|
+
if (!existsSync(target)) fail(EXIT.USAGE, `path not found: ${target}`);
|
|
389
|
+
|
|
390
|
+
const files = walk(target);
|
|
391
|
+
const byKind = { terraform: 0, 'terraform-plan': 0, kubernetes: 0, container: 0 };
|
|
392
|
+
const regions = new Set();
|
|
393
|
+
const resourceTypes = new Map();
|
|
394
|
+
let read = 0, storage = 0, storageEncrypted = 0, logging = 0, identity = 0;
|
|
395
|
+
let openIngress = 0, publicAccess = 0, inlineCredentials = 0;
|
|
396
|
+
|
|
397
|
+
for (const f of files) {
|
|
398
|
+
let text;
|
|
399
|
+
try {
|
|
400
|
+
if (statSync(f).size > MAX_BYTES) continue;
|
|
401
|
+
text = readFileSync(f, 'utf8');
|
|
402
|
+
} catch { continue; }
|
|
403
|
+
const kind = classify(f, text);
|
|
404
|
+
if (!kind) continue;
|
|
405
|
+
byKind[kind]++;
|
|
406
|
+
read++;
|
|
407
|
+
|
|
408
|
+
for (const m of text.matchAll(REGION_RE)) regions.add(m[1]);
|
|
409
|
+
|
|
410
|
+
if (kind === 'terraform') {
|
|
411
|
+
for (const m of text.matchAll(RESOURCE_RE)) {
|
|
412
|
+
resourceTypes.set(m[1], (resourceTypes.get(m[1]) || 0) + 1);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Terraform is split on top-level resource blocks so an attribute in one
|
|
417
|
+
// resource is never credited to another; every other kind is read whole.
|
|
418
|
+
const blocks = kind === 'terraform' ? text.split(/\n(?=resource\s+")/) : [text];
|
|
419
|
+
for (const b of blocks) {
|
|
420
|
+
const header = b.match(/^resource\s+"([a-z0-9_]+)"/);
|
|
421
|
+
const subject = header ? header[1] : b;
|
|
422
|
+
if (STORAGE_TYPE.test(subject)) { storage++; if (ENCRYPT_RE.test(b)) storageEncrypted++; }
|
|
423
|
+
if (LOGGING_TYPE.test(subject)) logging++;
|
|
424
|
+
if (IDENTITY_TYPE.test(subject)) identity++;
|
|
425
|
+
if (OPEN_CIDR_RE.test(b)) openIngress++;
|
|
426
|
+
if (PUBLIC_RE.test(b)) publicAccess++;
|
|
427
|
+
}
|
|
428
|
+
for (const line of text.split('\n')) if (SECRET_RE.test(line)) inlineCredentials++;
|
|
230
429
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
430
|
+
|
|
431
|
+
if (read === 0) {
|
|
432
|
+
const msg = `no infrastructure declarations found under ${target}.`;
|
|
433
|
+
diag(`${A}nothing to read${X} ${msg}`);
|
|
434
|
+
diag(`${D} lint reads .tf, .tf.json, Kubernetes YAML (apiVersion + kind), terraform plan`);
|
|
435
|
+
diag(` JSON and Dockerfiles. ${files.length} file(s) were opened and none matched.${X}`);
|
|
436
|
+
if (asJson) emitJson({ ok: false, error: msg, exit: EXIT.FINDING, files_opened: files.length });
|
|
437
|
+
process.exit(EXIT.FINDING);
|
|
235
438
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
439
|
+
|
|
440
|
+
const topTypes = [...resourceTypes.entries()]
|
|
441
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
442
|
+
.slice(0, 12)
|
|
443
|
+
.map(([type, n]) => ({ type, count: n }));
|
|
444
|
+
|
|
445
|
+
const facts = {
|
|
446
|
+
declarations_read: read,
|
|
447
|
+
sources: Object.fromEntries(Object.entries(byKind).filter(([, n]) => n > 0)),
|
|
448
|
+
regions_and_zones_declared: [...regions].sort(),
|
|
449
|
+
resource_types: topTypes,
|
|
450
|
+
storage_declarations: storage,
|
|
451
|
+
storage_declaring_encryption: storageEncrypted,
|
|
452
|
+
logging_declarations: logging,
|
|
453
|
+
identity_declarations: identity,
|
|
454
|
+
declarations_open_to_any_address: openIngress,
|
|
455
|
+
declarations_marked_public: publicAccess,
|
|
456
|
+
inline_credential_literals: inlineCredentials,
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const digest = createHash('sha256').update(canonical(facts)).digest('hex');
|
|
460
|
+
const doc = {
|
|
461
|
+
tool: 'trooth-lint',
|
|
462
|
+
cli_version: VERSION,
|
|
463
|
+
observed_at: new Date().toISOString(),
|
|
464
|
+
root: (() => { const r = relative(process.cwd(), target); return !r ? '.' : r.startsWith('..') ? target : r; })(),
|
|
465
|
+
facts,
|
|
466
|
+
digest: `sha256:${digest}`,
|
|
467
|
+
note: 'Declared facts only. Read locally; nothing was transmitted. No score, no verdict, no assessment against any standard.',
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
if (asJson) { emitJson(doc); process.exit(EXIT.OK); }
|
|
471
|
+
|
|
472
|
+
out(`\n${J}${B}trooth lint${X} ${D}// local · offline · declarations only //${X}`);
|
|
473
|
+
out(`${D}${doc.root} ${read} declaration file(s) read${X}`);
|
|
474
|
+
|
|
475
|
+
const src = Object.entries(facts.sources).map(([k, n]) => `${k} ${n}`).join(' · ');
|
|
476
|
+
if (src) out(`${D}${src}${X}`);
|
|
477
|
+
|
|
478
|
+
out(`\n${B}Declared${X}`);
|
|
479
|
+
const row = (label, value) => out(` ${label.padEnd(40)} ${value}`);
|
|
480
|
+
row('Regions and zones', facts.regions_and_zones_declared.length ? facts.regions_and_zones_declared.join(', ') : `${D}none declared${X}`);
|
|
481
|
+
row('Storage declarations', `${facts.storage_declarations}`);
|
|
482
|
+
row(' of those declaring encryption', `${facts.storage_declaring_encryption}`);
|
|
483
|
+
row('Logging declarations', `${facts.logging_declarations}`);
|
|
484
|
+
row('Identity declarations', `${facts.identity_declarations}`);
|
|
485
|
+
row('Open to any address (0.0.0.0/0, ::/0)', `${facts.declarations_open_to_any_address}`);
|
|
486
|
+
row('Marked public', `${facts.declarations_marked_public}`);
|
|
487
|
+
row('Inline credential literals', `${facts.inline_credential_literals}`);
|
|
488
|
+
|
|
489
|
+
if (topTypes.length) {
|
|
490
|
+
out(`\n${B}Most declared resource types${X}`);
|
|
491
|
+
for (const t of topTypes.slice(0, 6)) out(` ${String(t.count).padStart(4)} ${D}${t.type}${X}`);
|
|
258
492
|
}
|
|
259
|
-
console.log(`\n${D}${delta.disclaimer || 'Advisory. Analyzes declared infrastructure intent, not live production state. Trooth never applies changes.'}${X}\n`);
|
|
260
493
|
|
|
261
|
-
|
|
262
|
-
}
|
|
494
|
+
out(`\n${B}Digest${X} ${C}${doc.digest}${X}`);
|
|
495
|
+
out(`${D}A SHA-256 over the facts above, in canonical form, with the timestamp excluded.`);
|
|
496
|
+
out(`The same tree always produces the same digest, so you can record it as evidence`);
|
|
497
|
+
out(`that a state was observed without publishing the tree it came from.${X}`);
|
|
263
498
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; };
|
|
270
|
-
const token = flag('--token') || process.env.TROOTH_TOKEN || '';
|
|
271
|
-
const company = flag('--company') || process.env.TROOTH_COMPANY || '';
|
|
272
|
-
if (!token && !company) { console.error(`${R}error${X} provide --token <session> or --company <id> (or TROOTH_TOKEN / TROOTH_COMPANY env).`); process.exit(2); }
|
|
273
|
-
const load = (f) => { if (!f) return undefined; try { return JSON.parse(readFileSync(f, 'utf8')); } catch (e) { console.error(`${R}error${X} cannot read ${f}: ${e.message}`); process.exit(2); } };
|
|
274
|
-
const body = { datasets: load(flag('--datasets')), evals: load(flag('--evals')), drift: load(flag('--drift')) };
|
|
275
|
-
if (!body.datasets && !body.evals && !body.drift) { console.error(`${R}error${X} provide at least one of --datasets/--evals/--drift <file.json>`); process.exit(2); }
|
|
276
|
-
const headers = { 'content-type': 'application/json', 'user-agent': `trooth-cli/${VERSION}` };
|
|
277
|
-
if (token) headers['authorization'] = `Bearer ${token}`;
|
|
278
|
-
if (company) headers['x-trooth-company'] = company;
|
|
279
|
-
let res;
|
|
280
|
-
try { res = await fetch(`${API}/v1/eu/ingest`, { method: 'POST', headers, body: JSON.stringify(body) }); }
|
|
281
|
-
catch (e) { console.error(`${R}error${X} could not reach Trooth at ${API}: ${e.message}`); process.exit(1); }
|
|
282
|
-
if (!res.ok) { console.error(`${R}error${X} HTTP ${res.status}. ${(await res.text().catch(()=> '')).slice(0,300)}`); process.exit(1); }
|
|
283
|
-
const out = await res.json();
|
|
284
|
-
console.log(`\n${J}${B}Trooth EU AI Act${X} ${D}// evidence ingested //${X}`);
|
|
285
|
-
console.log(`datasets ${B}${out.ingested?.datasets ?? 0}${X} · evals ${B}${out.ingested?.evals ?? 0}${X} · drift ${B}${out.ingested?.drift ?? 0}${X}`);
|
|
286
|
-
console.log(`\n${D}${out.disclaimer || 'Advisory. Trooth automates, computes, and documents; it never signs.'}${X}\n`);
|
|
287
|
-
process.exit(0);
|
|
499
|
+
out(`\n${D}Counts of what the files declare. Not a judgement: a public load balancer is`);
|
|
500
|
+
out(`supposed to be public. Trooth issues no verdict here and checks nothing against`);
|
|
501
|
+
out(`any standard. Nothing left this machine: lint opens files and opens no sockets.`);
|
|
502
|
+
out(`Publish what you choose on your record at ${X}${C}https://trooth.co/dashboard${X}${D}.${X}\n`);
|
|
503
|
+
process.exit(EXIT.OK);
|
|
288
504
|
}
|
|
289
505
|
|
|
290
|
-
|
|
291
|
-
// Local, read-only .tf drift check. Reuses the published @trooth/os linter if present,
|
|
292
|
-
// otherwise runs a built-in minimal ruleset. Never transmits your code.
|
|
293
|
-
const path = argv[1] && !argv[1].startsWith('--') ? argv[1] : '.';
|
|
294
|
-
const { spawnSync } = await import('node:child_process');
|
|
295
|
-
const r = spawnSync('npx', ['--yes', '@trooth/os', path], { stdio: 'inherit' });
|
|
296
|
-
process.exit(r.status ?? 0);
|
|
297
|
-
}
|
|
506
|
+
/* ---------------------------------------------------------------- main ---- */
|
|
298
507
|
|
|
299
508
|
(async () => {
|
|
300
|
-
if (cmd === '--version' || cmd === '-v') {
|
|
301
|
-
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
509
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') { out(VERSION); return; }
|
|
510
|
+
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { out(helpText()); return; }
|
|
302
511
|
if (cmd === 'check') return check();
|
|
303
|
-
if (cmd === 'scan') return scan();
|
|
304
|
-
if (cmd === 'eu') return eu();
|
|
305
512
|
if (cmd === 'lint') return lint();
|
|
306
|
-
|
|
307
|
-
})
|
|
513
|
+
if (Object.prototype.hasOwnProperty.call(RETIRED, cmd)) {
|
|
514
|
+
fail(EXIT.USAGE, `\`trooth ${cmd}\` is retired. ${RETIRED[cmd]} Run \`trooth --help\`.`);
|
|
515
|
+
}
|
|
516
|
+
if (cmd.startsWith('-')) fail(EXIT.USAGE, `unknown flag ${cmd}. Run \`trooth --help\`.`);
|
|
517
|
+
diag(helpText());
|
|
518
|
+
fail(EXIT.USAGE, `unknown command: ${cmd}. Commands: check, lint.`);
|
|
519
|
+
})().catch((e) => {
|
|
520
|
+
fail(EXIT.UPSTREAM, `unexpected failure: ${e && e.message ? e.message : e}`);
|
|
521
|
+
});
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trooth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Trooth
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "The Trooth Network from your terminal. Read any company's public, signed record \u2014 identity, products, commercial terms, people, documents, security and privacy posture, AI practices, procurement terms \u2014 with no key and no account. Also reads the infrastructure your own repository declares, locally and offline. No score, no rating, no verdict.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin":
|
|
6
|
+
"bin": {
|
|
7
|
+
"trooth": "bin/trooth.mjs"
|
|
8
|
+
},
|
|
7
9
|
"files": [
|
|
8
10
|
"bin/",
|
|
9
11
|
"README.md"
|
|
@@ -13,32 +15,31 @@
|
|
|
13
15
|
},
|
|
14
16
|
"scripts": {
|
|
15
17
|
"check": "node ./bin/trooth.mjs check",
|
|
16
|
-
"
|
|
18
|
+
"lint": "node ./bin/trooth.mjs lint"
|
|
17
19
|
},
|
|
18
20
|
"license": "MIT",
|
|
19
21
|
"author": "Trooth, LLC",
|
|
20
22
|
"homepage": "https://trooth.co",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"url": "https://github.com/trooth/trooth-cli.git"
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://trooth.co/contact"
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
27
|
+
"trooth",
|
|
28
|
+
"trooth-network",
|
|
29
|
+
"company-lookup",
|
|
30
|
+
"vendor-lookup",
|
|
30
31
|
"due-diligence",
|
|
32
|
+
"procurement",
|
|
33
|
+
"trust-network",
|
|
34
|
+
"company-directory",
|
|
31
35
|
"mcp",
|
|
32
36
|
"ai-agents",
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"hipaa",
|
|
38
|
-
"nist-ai-rmf",
|
|
39
|
-
"eu-ai-act",
|
|
37
|
+
"agent-tools",
|
|
38
|
+
"signed-records",
|
|
39
|
+
"provenance",
|
|
40
|
+
"infrastructure-as-code",
|
|
40
41
|
"terraform",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
42
|
+
"kubernetes",
|
|
43
|
+
"cli"
|
|
43
44
|
]
|
|
44
45
|
}
|