trooth 0.4.3 → 0.5.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 +143 -56
- package/bin/lib/declarations.mjs +306 -0
- package/bin/lib/hcl.mjs +294 -0
- package/bin/trooth.mjs +402 -308
- package/npm-shrinkwrap.json +37 -0
- package/package.json +9 -5
package/bin/trooth.mjs
CHANGED
|
@@ -4,17 +4,21 @@
|
|
|
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
|
|
8
|
-
//
|
|
9
|
-
// procurement terms, relationships and sub-processors. Each fact is
|
|
10
|
-
//
|
|
11
|
-
// the
|
|
7
|
+
// The Trooth Network is one public record per company: identity, products and demos,
|
|
8
|
+
// domain and marketing links, people, documents, security and privacy posture,
|
|
9
|
+
// procurement terms, relationships and sub-processors. Each fact is labeled with where
|
|
10
|
+
// it came from: witnessed, public record, attested or declared. Trooth signs one
|
|
11
|
+
// object, the witness statement for a reading it took; the rest of the profile is not
|
|
12
|
+
// signed. This CLI is the terminal interface to that record.
|
|
12
13
|
//
|
|
13
14
|
// Commands:
|
|
14
|
-
// trooth check <domain> Read a company's
|
|
15
|
-
//
|
|
15
|
+
// trooth check <domain> Read a company's record from the public Trooth Network.
|
|
16
|
+
// Read-only. No key, no account. It sends one request to
|
|
17
|
+
// api.trooth.co with the domain you ask about in the URL,
|
|
18
|
+
// plus what every HTTPS request carries: your IP address and
|
|
19
|
+
// a user agent naming this CLI and its version.
|
|
16
20
|
// trooth lint [path] Read the infrastructure THIS repository declares and print
|
|
17
|
-
// the declared facts plus
|
|
21
|
+
// the declared facts plus an aggregate digest of them.
|
|
18
22
|
// Fully local. Offline. Your source never leaves the machine.
|
|
19
23
|
// trooth --help Show help. trooth --version Show version.
|
|
20
24
|
//
|
|
@@ -24,26 +28,36 @@
|
|
|
24
28
|
// It does not produce a verdict, a threshold result or a percentage.
|
|
25
29
|
// It publishes facts and counts, reported apart, and never adds them into one number.
|
|
26
30
|
//
|
|
27
|
-
// Exit codes (stable, for scripts):
|
|
28
|
-
// 0 ok (listed
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
31
|
+
// Exit codes (stable, for scripts; 4 and 5 are new in 0.5.0):
|
|
32
|
+
// 0 ok (check: listed, and Trooth witnessed a reading;
|
|
33
|
+
// lint: a complete read of at least one declaration)
|
|
34
|
+
// 1 finding (check: not listed, or revoked; lint: nothing to read)
|
|
35
|
+
// 2 usage error (missing argument, unknown flag or command, bad domain,
|
|
36
|
+
// path not found)
|
|
37
|
+
// 3 service or contract error (Trooth unreachable or too slow, a non-2xx other than
|
|
38
|
+
// the documented not-listed 404, a body that is not JSON
|
|
39
|
+
// or not the record asked for). Never an answer about a
|
|
40
|
+
// company.
|
|
41
|
+
// 4 incomplete read (lint: a file was skipped, invalid or unreadable, or the
|
|
42
|
+
// walk was truncated; --allow-incomplete exits 0 instead)
|
|
43
|
+
// 5 listed, not witnessed (check: the record is listed, but it carries no reading
|
|
44
|
+
// this CLI can confirm was witnessed)
|
|
32
45
|
//
|
|
33
46
|
// With --json, stdout carries exactly one JSON document and nothing else. Every
|
|
34
47
|
// diagnostic goes to stderr.
|
|
35
48
|
|
|
36
49
|
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
50
|
+
import { unitsOf, InvalidDeclaration, ENCRYPTION, encryptionState, regionsIn, credentialLiterals, opensToAnyAddress, markedPublic, referenceText } from './lib/declarations.mjs';
|
|
37
51
|
import { createHash } from 'node:crypto';
|
|
38
52
|
import { createRequire } from 'node:module';
|
|
39
53
|
import { join, relative, extname, basename } from 'node:path';
|
|
40
54
|
|
|
41
55
|
const API = process.env.TROOTH_API || 'https://api.trooth.co';
|
|
42
56
|
const require = createRequire(import.meta.url);
|
|
43
|
-
let VERSION = '0.
|
|
57
|
+
let VERSION = '0.5.0';
|
|
44
58
|
try { VERSION = require('../package.json').version; } catch {}
|
|
45
59
|
|
|
46
|
-
const EXIT = { OK: 0, FINDING: 1, USAGE: 2, UPSTREAM: 3 };
|
|
60
|
+
const EXIT = { OK: 0, FINDING: 1, USAGE: 2, UPSTREAM: 3, INCOMPLETE: 4, NOT_WITNESSED: 5 };
|
|
47
61
|
|
|
48
62
|
// Color only when stdout is a TTY and NO_COLOR is unset, so piped output is clean.
|
|
49
63
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
@@ -88,7 +102,7 @@ function scrub(value) {
|
|
|
88
102
|
|
|
89
103
|
const FLAGS = {
|
|
90
104
|
check: { bool: ['--json'], value: [] },
|
|
91
|
-
lint: { bool: ['--json'], value: [] },
|
|
105
|
+
lint: { bool: ['--json', '--allow-incomplete'], value: [] },
|
|
92
106
|
};
|
|
93
107
|
|
|
94
108
|
/** Commands that existed in an earlier release and are gone. Naming them explicitly
|
|
@@ -135,23 +149,28 @@ function helpText() {
|
|
|
135
149
|
${J}${B}trooth${X} ${D}v${VERSION} · the terminal interface to the Trooth Network${X}
|
|
136
150
|
|
|
137
151
|
${B}Usage${X}
|
|
138
|
-
trooth check <domain> Read a company's
|
|
152
|
+
trooth check <domain> Read a company's record on the Trooth Network
|
|
139
153
|
trooth lint [path] Read what your infrastructure declares, locally. Offline.
|
|
140
154
|
trooth --help | --version
|
|
141
155
|
|
|
142
156
|
${B}Examples${X}
|
|
143
|
-
trooth check stripe.com ${D}# read a company's
|
|
157
|
+
trooth check stripe.com ${D}# read a company's record${X}
|
|
144
158
|
trooth check trooth.co --json ${D}# one JSON document on stdout, for scripting${X}
|
|
145
|
-
trooth lint ./infra ${D}# read declared facts
|
|
159
|
+
trooth lint ./infra ${D}# read declared facts, with a coverage report${X}
|
|
146
160
|
trooth lint --json > trooth-lint.json ${D}# the same facts as one JSON document, for a CI artifact${X}
|
|
147
161
|
|
|
148
162
|
${B}Flags${X}
|
|
149
163
|
--json machine-readable JSON on stdout; diagnostics on stderr
|
|
164
|
+
--allow-incomplete lint: exit 0 even when a file was skipped, invalid or unreadable
|
|
150
165
|
|
|
151
166
|
${B}Exit codes${X}
|
|
152
|
-
0 ok 1
|
|
167
|
+
0 ok 1 not listed, or nothing declared 2 usage error 3 service or contract error
|
|
168
|
+
4 lint read incomplete 5 listed, but no witnessed reading in the record
|
|
153
169
|
|
|
154
|
-
${D}check reads only public, already-published records. No key, no account.
|
|
170
|
+
${D}check reads only public, already-published records. No key, no account. It sends
|
|
171
|
+
the domain you ask about to api.trooth.co in the request URL, with your IP address
|
|
172
|
+
and a user agent naming this CLI; see https://trooth.co/privacy for what is kept.
|
|
173
|
+
It does not check the record's signature.
|
|
155
174
|
lint is entirely local: it opens files, and opens no sockets. Your source never leaves.
|
|
156
175
|
Trooth publishes facts and counts, never one number that sums a company up.
|
|
157
176
|
Trooth signs what it witnessed. It never signs on a company's behalf.${X}
|
|
@@ -160,50 +179,111 @@ Trooth signs what it witnessed. It never signs on a company's behalf.${X}
|
|
|
160
179
|
|
|
161
180
|
/* -------------------------------------------------------------- fetch ---- */
|
|
162
181
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
// Every request is bounded: a deadline, a maximum body size, a JSON content
|
|
183
|
+
// type, and at most one retry, only for a connection failure or a 502, 503 or
|
|
184
|
+
// 504. Nothing here turns a failure into an answer about a company.
|
|
185
|
+
const TIMEOUT_MS = Math.max(1000, Number(process.env.TROOTH_TIMEOUT_MS) || 15000);
|
|
186
|
+
const MAX_BODY = 1024 * 1024;
|
|
187
|
+
const RETRYABLE = new Set([502, 503, 504]);
|
|
188
|
+
|
|
189
|
+
class Upstream extends Error {
|
|
190
|
+
constructor(message, extra = {}) { super(message); this.extra = extra; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function readBounded(res) {
|
|
194
|
+
const len = Number(res.headers.get('content-length'));
|
|
195
|
+
if (Number.isFinite(len) && len > MAX_BODY) throw new Upstream(`the response is ${len} bytes, over the ${MAX_BODY}-byte limit`);
|
|
196
|
+
if (!res.body) return '';
|
|
197
|
+
const reader = res.body.getReader();
|
|
198
|
+
const chunks = [];
|
|
199
|
+
let total = 0;
|
|
200
|
+
for (;;) {
|
|
201
|
+
const { done, value } = await reader.read();
|
|
202
|
+
if (done) break;
|
|
203
|
+
total += value.byteLength;
|
|
204
|
+
if (total > MAX_BODY) { try { await reader.cancel(); } catch {} throw new Upstream(`the response passed the ${MAX_BODY}-byte limit`); }
|
|
205
|
+
chunks.push(value);
|
|
172
206
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
207
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** GET one path. Returns { status, contentType, text }. Throws Upstream when the
|
|
211
|
+
* API cannot be reached, answers too slowly, or sends too much. */
|
|
212
|
+
async function getTrooth(path) {
|
|
213
|
+
let lastErr;
|
|
214
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
215
|
+
try {
|
|
216
|
+
const res = await fetch(`${API}${path}`, {
|
|
217
|
+
method: 'GET',
|
|
218
|
+
redirect: 'error',
|
|
219
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
220
|
+
headers: { accept: 'application/json', 'user-agent': `trooth-cli/${VERSION}` },
|
|
221
|
+
});
|
|
222
|
+
if (RETRYABLE.has(res.status) && attempt === 1) { try { await res.body?.cancel(); } catch {} await new Promise((r) => setTimeout(r, 500)); continue; }
|
|
223
|
+
const text = await readBounded(res);
|
|
224
|
+
return { status: res.status, contentType: (res.headers.get('content-type') || '').toLowerCase(), text };
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (e instanceof Upstream) throw e;
|
|
227
|
+
lastErr = e;
|
|
228
|
+
const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError');
|
|
229
|
+
if (timedOut) throw new Upstream(`the Trooth Network at ${API} did not answer within ${TIMEOUT_MS} ms`);
|
|
230
|
+
if (attempt === 1) { await new Promise((r) => setTimeout(r, 500)); continue; }
|
|
231
|
+
}
|
|
176
232
|
}
|
|
177
|
-
|
|
178
|
-
|
|
233
|
+
throw new Upstream(`could not reach the Trooth Network at ${API}: ${lastErr && lastErr.message ? lastErr.message : lastErr}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseJsonBody(r) {
|
|
237
|
+
if (!/\bjson\b/.test(r.contentType)) throw new Upstream(`the Trooth Network answered HTTP ${r.status} with ${r.contentType || 'no content type'}, not JSON`, { http_status: r.status });
|
|
238
|
+
try { return JSON.parse(r.text); }
|
|
239
|
+
catch { throw new Upstream(`the Trooth Network answered HTTP ${r.status} with a body that is not valid JSON`, { http_status: r.status }); }
|
|
179
240
|
}
|
|
180
241
|
|
|
181
242
|
/* --------------------------------------------------------------- check ---- */
|
|
182
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The domain a user typed, as the one form the Trooth Network keys records by.
|
|
246
|
+
* Accepts a bare domain or a URL. Parsed with the WHATWG URL parser, so case,
|
|
247
|
+
* a trailing dot, the default port (80 or 443), a path, a query and an
|
|
248
|
+
* internationalized name (to its ASCII form) all normalize the same way, and a
|
|
249
|
+
* leading "www." is dropped. Returns { domain } or { error } for input that is
|
|
250
|
+
* not one domain: credentials in a URL, a non-default port, an IP address, a
|
|
251
|
+
* scheme other than http or https, or a name with no dot.
|
|
252
|
+
*/
|
|
183
253
|
function normalizeDomain(input) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return
|
|
254
|
+
const s = String(input || '').trim();
|
|
255
|
+
if (!s) return { error: 'missing <domain>. Try: trooth check stripe.com' };
|
|
256
|
+
let u;
|
|
257
|
+
try { u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`); }
|
|
258
|
+
catch { return { error: `not a domain or URL: ${s}` }; }
|
|
259
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return { error: `not a web address: ${s}` };
|
|
260
|
+
if (u.username || u.password) return { error: 'a URL with a user name or password is not accepted; pass the domain alone' };
|
|
261
|
+
if (u.port) return { error: `port ${u.port} is not a default port; pass the domain alone` };
|
|
262
|
+
let host = u.hostname.toLowerCase().replace(/\.$/, '');
|
|
263
|
+
if (/^\[.*\]$/.test(host) || /^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return { error: 'an IP address is not a company domain' };
|
|
264
|
+
host = host.replace(/^www\./, '');
|
|
265
|
+
if (!host.includes('.') || !/^[a-z0-9.-]+$/.test(host) || host.split('.').some((l) => !l || l.length > 63 || l.startsWith('-') || l.endsWith('-'))) {
|
|
266
|
+
return { error: `not a domain: ${s}` };
|
|
267
|
+
}
|
|
268
|
+
return { domain: host };
|
|
191
269
|
}
|
|
270
|
+
const sameDomain = (a, b) => { const x = normalizeDomain(a); return !!x.domain && x.domain === b; };
|
|
192
271
|
|
|
193
272
|
function fmtDate(iso) {
|
|
194
273
|
if (!iso) return '';
|
|
195
274
|
const d = new Date(iso);
|
|
196
|
-
if (isNaN(d)) return
|
|
275
|
+
if (isNaN(d)) return '';
|
|
197
276
|
return d.toISOString().slice(0, 10);
|
|
198
277
|
}
|
|
199
278
|
|
|
200
279
|
function count(obj) {
|
|
201
280
|
if (!obj || typeof obj !== 'object') return null;
|
|
202
|
-
|
|
203
|
-
|
|
281
|
+
const { passed, total } = obj;
|
|
282
|
+
if (!Number.isInteger(passed) || !Number.isInteger(total) || passed < 0 || total < 0 || passed > total) return null;
|
|
283
|
+
return { passed, total };
|
|
204
284
|
}
|
|
205
285
|
|
|
206
|
-
/** The two count lines, in the website's form ("65 read;
|
|
286
|
+
/** The two count lines, in the website's form ("65 read; 63 as expected"). */
|
|
207
287
|
function countLines(rec) {
|
|
208
288
|
const lines = [];
|
|
209
289
|
if (rec.probes) lines.push(`${B}Live probes:${X} ${rec.probes.total} read; ${rec.probes.passed} as expected`);
|
|
@@ -234,68 +314,139 @@ function eventLabel(type) {
|
|
|
234
314
|
return Object.prototype.hasOwnProperty.call(EVENT_LABELS, type) ? EVENT_LABELS[type] : type.replace(/_/g, ' ');
|
|
235
315
|
}
|
|
236
316
|
|
|
317
|
+
/**
|
|
318
|
+
* THE EVIDENCE STATE, decided from the record's own fields and never from a
|
|
319
|
+
* matching name. A record that is listed is not therefore witnessed.
|
|
320
|
+
* listed_witnessed a dated reading with at least one live probe read
|
|
321
|
+
* listed_not_witnessed the record says so, or its reading read no probe
|
|
322
|
+
* listed_evidence_unknown listed, with no reading this CLI can interpret
|
|
323
|
+
* revoked the record says it was revoked or withdrawn
|
|
324
|
+
* An explicit state in the record (`standing` or `evidence_state`) wins over
|
|
325
|
+
* anything inferred from counts, and can only lower the state, never raise it.
|
|
326
|
+
*/
|
|
327
|
+
const STATE = Object.freeze({
|
|
328
|
+
NOT_LISTED: 'not_listed',
|
|
329
|
+
WITNESSED: 'listed_witnessed',
|
|
330
|
+
NOT_WITNESSED: 'listed_not_witnessed',
|
|
331
|
+
UNKNOWN: 'listed_evidence_unknown',
|
|
332
|
+
REVOKED: 'revoked',
|
|
333
|
+
});
|
|
334
|
+
function evidenceState(v, probes) {
|
|
335
|
+
const explicit = String(v.evidence_state ?? v.standing ?? v.state ?? '').toLowerCase();
|
|
336
|
+
if (/revoked|withdrawn/.test(explicit)) return STATE.REVOKED;
|
|
337
|
+
if (/not[_ -]?witnessed|unwitnessed|none/.test(explicit)) return STATE.NOT_WITNESSED;
|
|
338
|
+
const dated = !!fmtDate(v.passed_at);
|
|
339
|
+
if (probes && probes.total === 0) return STATE.NOT_WITNESSED;
|
|
340
|
+
if (probes && probes.total > 0 && dated) return STATE.WITNESSED;
|
|
341
|
+
return STATE.UNKNOWN;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** A record, checked field by field. Throws Upstream when the body is not a
|
|
345
|
+
* directory record for this domain. */
|
|
237
346
|
function projectRecord(v, domain) {
|
|
347
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Upstream('the Trooth Network answered with something that is not a record');
|
|
348
|
+
if (typeof v.error === 'string' && !v.domain) throw new Upstream(`the Trooth Network answered with an error: ${v.error.slice(0, 200)}`);
|
|
349
|
+
if (typeof v.domain !== 'string' || !sameDomain(v.domain, domain)) throw new Upstream(`the Trooth Network answered with a record that is not the record for ${domain}`);
|
|
238
350
|
const events = Array.isArray(v.events)
|
|
239
|
-
? v.events.filter((e) => e && e.type).map((e) => ({ type:
|
|
351
|
+
? v.events.filter((e) => e && typeof e.type === 'string').map((e) => ({ type: e.type, at: e.at, detail: e.detail }))
|
|
240
352
|
: [];
|
|
353
|
+
const probes = count(v.probes);
|
|
354
|
+
const state = evidenceState(v, probes);
|
|
241
355
|
const rec = {
|
|
242
356
|
domain,
|
|
243
|
-
listed:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
357
|
+
listed: state !== STATE.REVOKED,
|
|
358
|
+
state,
|
|
359
|
+
company_name: typeof v.company_name === 'string' && v.company_name ? v.company_name : domain,
|
|
360
|
+
witnessed_at: state === STATE.WITNESSED ? v.passed_at : null,
|
|
361
|
+
first_published_at: fmtDate(v.first_published_at) ? v.first_published_at : null,
|
|
362
|
+
badge_id: typeof v.badge_id === 'string' ? v.badge_id : null,
|
|
363
|
+
probes,
|
|
249
364
|
attested: count(v.attested),
|
|
250
365
|
events,
|
|
251
|
-
receipt_signature: v.receipt_signature
|
|
252
|
-
authority_key_id: v.authority_key_id
|
|
366
|
+
receipt_signature: typeof v.receipt_signature === 'string' ? v.receipt_signature : null,
|
|
367
|
+
authority_key_id: typeof v.authority_key_id === 'string' ? v.authority_key_id : null,
|
|
368
|
+
signature_checked: false,
|
|
253
369
|
verify_keys: `${API}/public/keys`,
|
|
370
|
+
verify_how: 'https://trooth.co/docs/verifiable-evidence',
|
|
254
371
|
record_url: `https://trooth.co/network/${encodeURIComponent(domain)}`,
|
|
255
372
|
};
|
|
256
|
-
if (v.category) rec.category =
|
|
257
|
-
if (v.description) rec.description =
|
|
373
|
+
if (typeof v.category === 'string') rec.category = v.category;
|
|
374
|
+
if (typeof v.description === 'string') rec.description = v.description;
|
|
258
375
|
return scrub(rec);
|
|
259
376
|
}
|
|
260
377
|
|
|
378
|
+
/**
|
|
379
|
+
* One company's record, from /directory/api/vendors/<domain>. That route
|
|
380
|
+
* answers a domain it does not carry with a JSON 404 whose body says
|
|
381
|
+
* `listed: false`; that is the only answer this CLI reads as "not listed".
|
|
382
|
+
* Any other 404, any other status and any body that is not the record is a
|
|
383
|
+
* service or contract error (exit 3), never a statement about the company.
|
|
384
|
+
* 0.4.4 fell back to downloading the whole list on a plain-text 404; the
|
|
385
|
+
* route has been served since 2026-09-26, and the fallback is gone.
|
|
386
|
+
*/
|
|
387
|
+
async function readVendor(domain) {
|
|
388
|
+
const r = await getTrooth(`/directory/api/vendors/${encodeURIComponent(domain)}`);
|
|
389
|
+
if (r.status === 404) {
|
|
390
|
+
let body = null;
|
|
391
|
+
try { body = JSON.parse(r.text); } catch {}
|
|
392
|
+
if (body && typeof body === 'object' && body.listed === false && /\bjson\b/.test(r.contentType)) return null;
|
|
393
|
+
throw new Upstream('the Trooth Network answered 404 without saying whether the domain is listed; this is a service error, not an answer about the company', { http_status: 404 });
|
|
394
|
+
}
|
|
395
|
+
if (r.status < 200 || r.status > 299) {
|
|
396
|
+
throw new Upstream(`the Trooth Network answered HTTP ${r.status}.${r.text ? ' ' + r.text.slice(0, 200).replace(/\s+/g, ' ') : ''}`, { http_status: r.status });
|
|
397
|
+
}
|
|
398
|
+
return parseJsonBody(r);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const STATE_TEXT = {
|
|
402
|
+
[STATE.WITNESSED]: `${J}listed; Trooth witnessed a reading${X}`,
|
|
403
|
+
[STATE.NOT_WITNESSED]: `${A}listed; no reading witnessed${X}`,
|
|
404
|
+
[STATE.UNKNOWN]: `${A}listed; the reading could not be read from this record${X}`,
|
|
405
|
+
[STATE.REVOKED]: `${A}revoked${X}`,
|
|
406
|
+
};
|
|
407
|
+
|
|
261
408
|
async function check() {
|
|
262
409
|
const { positional } = parseArgs('check');
|
|
263
410
|
if (positional.length > 1) fail(EXIT.USAGE, `check takes one <domain>, got: ${positional.join(' ')}`);
|
|
264
|
-
const
|
|
265
|
-
if (
|
|
411
|
+
const norm = normalizeDomain(positional[0]);
|
|
412
|
+
if (norm.error) fail(EXIT.USAGE, norm.error);
|
|
413
|
+
const domain = norm.domain;
|
|
266
414
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
415
|
+
let vendor, rec;
|
|
416
|
+
try {
|
|
417
|
+
vendor = await readVendor(domain);
|
|
418
|
+
rec = vendor ? projectRecord(vendor, domain) : null;
|
|
419
|
+
} catch (e) {
|
|
420
|
+
if (e instanceof Upstream) fail(EXIT.UPSTREAM, e.message, { state: 'service_error', ...e.extra });
|
|
421
|
+
throw e;
|
|
422
|
+
}
|
|
270
423
|
|
|
271
|
-
if (!
|
|
424
|
+
if (!rec) {
|
|
272
425
|
if (asJson) {
|
|
273
|
-
emitJson({ domain, listed: false, record_url: `https://trooth.co/network/${encodeURIComponent(domain)}` });
|
|
426
|
+
emitJson({ domain, listed: false, state: STATE.NOT_LISTED, record_url: `https://trooth.co/network/${encodeURIComponent(domain)}` });
|
|
274
427
|
} else {
|
|
275
428
|
out(`\n${B}${domain}${X} ${D}//${X} ${A}not listed in the Trooth Network's public feed${X}`);
|
|
276
429
|
out(`\n${D}The public feed carries no record for this domain. That says nothing about the`);
|
|
277
430
|
out(`company: a domain that never listed, a listing Trooth has not published, and a`);
|
|
278
431
|
out(`record that was revoked all read this way. A company gets a record by listing at`);
|
|
279
432
|
out(`${X}${C}https://trooth.co/get-started${X}${D}: Trooth reads its public surface and publishes`);
|
|
280
|
-
out(`a
|
|
433
|
+
out(`a dated record that anyone, or any agent, can read.${X}\n`);
|
|
281
434
|
}
|
|
282
435
|
process.exit(EXIT.FINDING);
|
|
283
436
|
}
|
|
284
437
|
|
|
285
|
-
const
|
|
286
|
-
if (asJson) { emitJson(rec); process.exit(
|
|
438
|
+
const code = rec.state === STATE.WITNESSED ? EXIT.OK : rec.state === STATE.REVOKED ? EXIT.FINDING : EXIT.NOT_WITNESSED;
|
|
439
|
+
if (asJson) { emitJson(rec); process.exit(code); }
|
|
287
440
|
|
|
288
441
|
const when = fmtDate(rec.witnessed_at);
|
|
289
442
|
const since = fmtDate(rec.first_published_at);
|
|
290
|
-
out(`\n${J}${B}Trooth Network${X} ${D}// public
|
|
443
|
+
out(`\n${J}${B}Trooth Network${X} ${D}// public record · read-only //${X}`);
|
|
291
444
|
out(`${B}${rec.company_name}${X} ${C}${domain}${X}`);
|
|
292
|
-
out(`Listing state: ${
|
|
293
|
-
(when ? ` ${D}
|
|
445
|
+
out(`Listing state: ${STATE_TEXT[rec.state]}` +
|
|
446
|
+
(when ? ` ${D}reading dated ${when}${X}` : '') +
|
|
294
447
|
(since ? ` ${D}first published ${since}${X}` : ''));
|
|
295
448
|
|
|
296
|
-
// Two counts, never a ratio, in the form the website's record page uses
|
|
297
|
-
// "N/M" cell reads as a bar, and a bar reads as a grade. The JSON fields keep
|
|
298
|
-
// the feed's names (`passed`, `total`).
|
|
449
|
+
// Two counts, never a ratio, in the form the website's record page uses.
|
|
299
450
|
const counts = countLines(rec);
|
|
300
451
|
if (counts.length) {
|
|
301
452
|
out('');
|
|
@@ -313,20 +464,22 @@ async function check() {
|
|
|
313
464
|
const latest = latestEvents(rec.events, 3);
|
|
314
465
|
if (latest.length) {
|
|
315
466
|
out(`\n${B}Latest ledger events, newest first${X}`);
|
|
316
|
-
for (const e of latest) out(` ${J}•${X} ${D}${fmtDate(e.at)}${X} ${eventLabel(e.type)}`);
|
|
467
|
+
for (const e of latest) out(` ${J}•${X} ${D}${fmtDate(e.at) || 'undated'}${X} ${eventLabel(e.type)}`);
|
|
317
468
|
out(`${D} --json carries the whole ledger, with the feed's own wording for each event.${X}`);
|
|
318
469
|
}
|
|
319
470
|
|
|
320
|
-
out(`\n${D}
|
|
471
|
+
out(`\n${D}This command did not check the record's signature. The signature covers the`);
|
|
472
|
+
out(`reading, not every fact on the company's profile. To check it yourself:${X} ${C}${rec.verify_how}${X}`);
|
|
473
|
+
out(`${D}A dated, point-in-time record. Trooth issues no verdict and no single number.`);
|
|
321
474
|
out(`Full record: ${X}${C}${rec.record_url}${X}${D} · Signing keys: ${rec.verify_keys}${X}\n`);
|
|
322
|
-
process.exit(
|
|
475
|
+
process.exit(code);
|
|
323
476
|
}
|
|
324
477
|
|
|
325
478
|
/* ---------------------------------------------------------------- lint ---- */
|
|
326
479
|
/* WHAT lint IS, AND WHAT IT IS CAREFULLY NOT.
|
|
327
480
|
*
|
|
328
481
|
* It reads the infrastructure a repository DECLARES and reports those
|
|
329
|
-
* declarations as
|
|
482
|
+
* declarations as counts: how many storage resources declare encryption, which
|
|
330
483
|
* regions appear, how many rules declare exposure to the whole internet. It
|
|
331
484
|
* does not judge them. There is no verdict, no threshold, no severity and no
|
|
332
485
|
* rating, and nothing is checked against a named standard or regulation.
|
|
@@ -334,203 +487,78 @@ async function check() {
|
|
|
334
487
|
* public. What the facts mean is the reader's decision.
|
|
335
488
|
*
|
|
336
489
|
* It opens files and opens no sockets. Nothing about the repository leaves the
|
|
337
|
-
* machine.
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
490
|
+
* machine.
|
|
491
|
+
*
|
|
492
|
+
* HOW EACH SOURCE IS READ. Every format is PARSED, and a file that does not
|
|
493
|
+
* parse is reported as invalid, never as read:
|
|
494
|
+
* .tf HCL native syntax, by ./lib/hcl.mjs (comments dropped)
|
|
495
|
+
* .tf.json JSON; each resource is one unit
|
|
496
|
+
* plan JSON JSON (`terraform show -json`); each planned managed
|
|
497
|
+
* resource is one unit
|
|
498
|
+
* Kubernetes YAML YAML, by the `yaml` package; one unit per document
|
|
499
|
+
* Dockerfile ENV and ARG settings only
|
|
500
|
+
* Nothing is evaluated: variables, locals, modules and functions are not
|
|
501
|
+
* resolved, and a setting that depends on one is reported as UNRESOLVED.
|
|
342
502
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* classified by its top-level `kind`
|
|
351
|
-
* Dockerfile read for regions, open addresses, public markers and
|
|
352
|
-
* credential literals only; it declares no resource types
|
|
353
|
-
* Storage, logging and identity are classified on a unit's resource type or
|
|
354
|
-
* kind, never on the text around it. The human output prints this list in
|
|
355
|
-
* short, so the limit is stated where the counts are. */
|
|
503
|
+
* COMPLETENESS. Every file the walk selects ends in exactly one bucket: read,
|
|
504
|
+
* not applicable (a JSON or YAML file that is not a plan or a manifest),
|
|
505
|
+
* excluded by a stated rule (a templated manifest), skipped (over the size
|
|
506
|
+
* limit), invalid (did not parse) or unreadable (a permission or I/O error).
|
|
507
|
+
* The walk itself can be truncated at MAX_FILES. A read with anything skipped,
|
|
508
|
+
* invalid, unreadable or truncated is INCOMPLETE and exits 4 unless the caller
|
|
509
|
+
* passes --allow-incomplete. */
|
|
356
510
|
|
|
357
511
|
const SKIP_DIRS = new Set([
|
|
358
512
|
'node_modules', '.git', '.terraform', '.next', 'dist', 'build', 'vendor',
|
|
359
513
|
'.venv', 'venv', '__pycache__', '.cache', 'coverage', '.turbo',
|
|
360
514
|
]);
|
|
361
|
-
const MAX_FILES = 5000;
|
|
515
|
+
const MAX_FILES = Number(process.env.TROOTH_LINT_MAX_FILES) > 0 ? Number(process.env.TROOTH_LINT_MAX_FILES) : 5000;
|
|
362
516
|
const MAX_BYTES = 4 * 1024 * 1024;
|
|
517
|
+
const LIST_CAP = 50;
|
|
363
518
|
|
|
364
|
-
function
|
|
519
|
+
function selectedName(n) {
|
|
520
|
+
const ext = extname(n);
|
|
521
|
+
return ext === '.tf' || n.endsWith('.tf.json') || ext === '.yaml' || ext === '.yml' || ext === '.json' ||
|
|
522
|
+
n === 'dockerfile' || n.startsWith('dockerfile.');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Depth first, entries sorted by name, so two runs over one tree visit files
|
|
526
|
+
* in the same order. */
|
|
527
|
+
function walk(root, cov) {
|
|
365
528
|
const found = [];
|
|
366
|
-
const stack = [root];
|
|
367
529
|
let st;
|
|
368
|
-
try { st = statSync(root); } catch { return found; }
|
|
369
|
-
if (st.isFile())
|
|
370
|
-
|
|
530
|
+
try { st = statSync(root); } catch (e) { cov.unreadable.push({ path: root, reason: e.code || 'stat failed' }); return found; }
|
|
531
|
+
if (st.isFile()) { cov.discovered++; if (selectedName(basename(root).toLowerCase())) found.push(root); else cov.not_applicable++; return found; }
|
|
532
|
+
const stack = [root];
|
|
533
|
+
while (stack.length) {
|
|
371
534
|
const dir = stack.pop();
|
|
372
535
|
let entries;
|
|
373
|
-
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
536
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
537
|
+
catch (e) { cov.unreadable.push({ path: dir, reason: e.code || 'directory could not be listed' }); continue; }
|
|
538
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
539
|
+
const subdirs = [];
|
|
374
540
|
for (const e of entries) {
|
|
375
|
-
if (e.isDirectory()) { if (
|
|
541
|
+
if (e.isDirectory()) { if (SKIP_DIRS.has(e.name)) cov.excluded_directories++; else subdirs.push(join(dir, e.name)); continue; }
|
|
376
542
|
if (!e.isFile()) continue;
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
ext === '.yaml' || ext === '.yml' || ext === '.json' ||
|
|
382
|
-
n === 'dockerfile' || n.startsWith('dockerfile.');
|
|
383
|
-
if (keep) found.push(join(dir, e.name));
|
|
384
|
-
if (found.length >= MAX_FILES) break;
|
|
543
|
+
cov.discovered++;
|
|
544
|
+
if (!selectedName(e.name.toLowerCase())) continue;
|
|
545
|
+
if (found.length >= MAX_FILES) { cov.truncated = true; return found; }
|
|
546
|
+
found.push(join(dir, e.name));
|
|
385
547
|
}
|
|
548
|
+
for (let k = subdirs.length - 1; k >= 0; k--) stack.push(subdirs[k]);
|
|
386
549
|
}
|
|
387
550
|
return found;
|
|
388
551
|
}
|
|
389
552
|
|
|
390
|
-
const REGION_RE = /\b(?:region|location|availability_zone|aws_region)\s*[:=]\s*["']([A-Za-z0-9][A-Za-z0-9._-]{2,40})["']/g;
|
|
391
|
-
const RESOURCE_RE = /\bresource\s+"([a-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/g;
|
|
392
|
-
const ENCRYPT_RE = /(?:encrypted|encryption|kms_key|sse_algorithm|server_side_encryption|encrypt_at_rest)/i;
|
|
393
|
-
// Classification runs on the RESOURCE TYPE TOKEN (aws_db_instance, not the body
|
|
394
|
-
// text), matched as a substring. An earlier version wrapped these in \b, which
|
|
395
|
-
// never fires inside a snake_case identifier: \bdynamodb\b cannot match
|
|
396
|
-
// aws_dynamodb_table because the underscore either side is a word character, so
|
|
397
|
-
// every storage resource whose name was not the bare word "bucket" went
|
|
398
|
-
// uncounted. Substring matching on the type is both simpler and correct.
|
|
399
|
-
const STORAGE_TYPE = /(bucket|storage|blob|disk|volume|filestore|_db|database|rds|dynamodb|_sql|redis|memcache|elasticache|efs|fileshare|cosmos|bigtable|spanner)/i;
|
|
400
|
-
const LOGGING_TYPE = /(log|trail|audit|monitor|insight|diagnostic)/i;
|
|
401
|
-
const IDENTITY_TYPE = /(iam|role|policy|service_account|serviceaccount|rbac|identity|access_key|keyring|secret)/i;
|
|
402
|
-
const OPEN_CIDR_RE = /(?:"0\.0\.0\.0\/0"|'0\.0\.0\.0\/0'|"::\/0"|'::\/0')/;
|
|
403
|
-
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/;
|
|
404
|
-
// A literal that looks like a credential sitting in the file. Reported as a
|
|
405
|
-
// count only: no file name, no line, and never the value itself.
|
|
406
|
-
const SECRET_RE = /\b(?:password|secret|api[_-]?key|access[_-]?key|token|private[_-]?key)\s*[:=]\s*["'][^"'${}\n]{8,}["']/i;
|
|
407
|
-
// A storage-matching Terraform type that names a SETTING on a store rather than
|
|
408
|
-
// a store: aws_s3_bucket_server_side_encryption_configuration, a bucket policy,
|
|
409
|
-
// a volume attachment, a subnet group. The substring match above catches these
|
|
410
|
-
// (the fixture's one bucket used to count as two storage declarations), so they
|
|
411
|
-
// are not counted as storage. A setting that declares encryption and references
|
|
412
|
-
// a store credits that store with declaring encryption. Applied to snake_case
|
|
413
|
-
// Terraform types only; Kubernetes kinds are CamelCase and are not settings.
|
|
414
|
-
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)$/;
|
|
415
|
-
const isStorageSetting = (type) => type.includes('_') && STORAGE_SETTING.test(type);
|
|
416
|
-
// An attribute set to false, null or empty declares nothing: `encrypted = false`
|
|
417
|
-
// and `storage_encrypted: false` must not count as declaring encryption.
|
|
418
|
-
const NEGATIVE_ATTR_RE = /^[^\n:=]*[:=]\s*(?:false|null|"false"|'false'|""|''|\[\]|\{\})\s*,?\s*$/gim;
|
|
419
|
-
const declaresEncryption = (body) => ENCRYPT_RE.test(body.replace(NEGATIVE_ATTR_RE, ''));
|
|
420
|
-
|
|
421
|
-
/** JSON text with `"key":` rewritten as `key:`, so the attribute patterns above
|
|
422
|
-
* (written for `key = "v"` and `key: "v"`) read JSON sources too. Escaped
|
|
423
|
-
* quotes inside string values are never rewritten. */
|
|
424
|
-
function flattenJson(text) {
|
|
425
|
-
return text.replace(/"([A-Za-z_][\w.-]*)"\s*:/g, '$1:');
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/** A parsed JSON value with false, null, empty strings, empty arrays and empty
|
|
429
|
-
* objects removed, so a plan's unset attributes read as absent. */
|
|
430
|
-
function prune(v) {
|
|
431
|
-
if (Array.isArray(v)) { const a = v.map(prune).filter((x) => x !== undefined); return a.length ? a : undefined; }
|
|
432
|
-
if (v && typeof v === 'object') {
|
|
433
|
-
const o = {};
|
|
434
|
-
for (const [k, x] of Object.entries(v)) { const y = prune(x); if (y !== undefined) o[k] = y; }
|
|
435
|
-
return Object.keys(o).length ? o : undefined;
|
|
436
|
-
}
|
|
437
|
-
return v === null || v === false || v === '' ? undefined : v;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
441
|
-
/** A pattern that finds a Terraform address (aws_s3_bucket.logs) in another
|
|
442
|
-
* resource's body, as `aws_s3_bucket.logs.id` or `${aws_s3_bucket.logs.arn}`,
|
|
443
|
-
* and not inside a longer address such as aws_s3_bucket.logs2. */
|
|
444
|
-
const addressRef = (address) => new RegExp(`(?:^|[^\\w.])${escRe(address)}(?![\\w-])`);
|
|
445
|
-
|
|
446
|
-
/** Every resource under `resource` in a .tf.json document, in either of the
|
|
447
|
-
* shapes Terraform's JSON syntax allows (objects, or arrays of objects). */
|
|
448
|
-
function tfJsonResources(doc) {
|
|
449
|
-
const out = [];
|
|
450
|
-
const each = (v, fn) => { if (Array.isArray(v)) v.forEach((x) => each(x, fn)); else if (v && typeof v === 'object') fn(v); };
|
|
451
|
-
each(doc, (top) => each(top.resource, (byType) => {
|
|
452
|
-
for (const [type, byName] of Object.entries(byType)) {
|
|
453
|
-
if (!/^[a-z0-9_]+$/.test(type)) continue;
|
|
454
|
-
each(byName, (names) => { for (const [name, body] of Object.entries(names)) out.push({ type, name, body }); });
|
|
455
|
-
}
|
|
456
|
-
}));
|
|
457
|
-
return out;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** The managed resources a plan (`terraform show -json`) says will exist:
|
|
461
|
-
* planned_values, every module deep, or resource_changes when a plan has no
|
|
462
|
-
* planned_values. Data sources and deletions are not declarations. */
|
|
463
|
-
function planResources(doc) {
|
|
464
|
-
const out = [];
|
|
465
|
-
const walkModule = (m) => {
|
|
466
|
-
if (!m || typeof m !== 'object') return;
|
|
467
|
-
for (const r of Array.isArray(m.resources) ? m.resources : []) {
|
|
468
|
-
if (r && r.mode !== 'data' && typeof r.type === 'string') out.push({ type: r.type, name: String(r.name ?? ''), body: r.values ?? {} });
|
|
469
|
-
}
|
|
470
|
-
for (const c of Array.isArray(m.child_modules) ? m.child_modules : []) walkModule(c);
|
|
471
|
-
};
|
|
472
|
-
if (doc && doc.planned_values && doc.planned_values.root_module) walkModule(doc.planned_values.root_module);
|
|
473
|
-
else {
|
|
474
|
-
for (const rc of Array.isArray(doc && doc.resource_changes) ? doc.resource_changes : []) {
|
|
475
|
-
const after = rc && rc.change ? rc.change.after : null;
|
|
476
|
-
if (rc && rc.mode !== 'data' && typeof rc.type === 'string' && after) out.push({ type: rc.type, name: String(rc.name ?? ''), body: after });
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
return out;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/** Split one declaration file into the units lint classifies. Each unit has a
|
|
483
|
-
* `type` (a resource type or Kubernetes kind, or null when there is none),
|
|
484
|
-
* `refs` (patterns another unit's body would contain to refer to this one),
|
|
485
|
-
* `body` (the text attribute patterns run on) and `typed` (whether the type
|
|
486
|
-
* belongs in the resource type list). */
|
|
487
|
-
function unitsOf(kind, file, text) {
|
|
488
|
-
if (kind === 'terraform' && !basename(file).toLowerCase().endsWith('.tf.json')) {
|
|
489
|
-
return text.split(/\n(?=resource\s+")/).map((b) => {
|
|
490
|
-
const h = b.match(/^resource\s+"([a-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/);
|
|
491
|
-
return h ? { type: h[1], refs: [addressRef(`${h[1]}.${h[2]}`)], body: b, typed: false }
|
|
492
|
-
: { type: null, refs: [], body: b, typed: false };
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
if (kind === 'kubernetes') {
|
|
496
|
-
return text.split(/^---[^\n]*$/m).map((d) => {
|
|
497
|
-
const k = d.match(/^kind\s*:\s*["']?([A-Za-z][A-Za-z0-9]*)/m);
|
|
498
|
-
return { type: k ? k[1] : null, refs: [], body: d, typed: false };
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
if (kind === 'container') return [{ type: null, refs: [], body: text, typed: false }];
|
|
502
|
-
|
|
503
|
-
// .tf.json and plan JSON.
|
|
504
|
-
let doc;
|
|
505
|
-
try { doc = JSON.parse(text); } catch { return [{ type: null, refs: [], body: flattenJson(text), typed: false }]; }
|
|
506
|
-
const asBody = (v) => flattenJson(JSON.stringify(prune(v) ?? {}, null, 1));
|
|
507
|
-
if (kind === 'terraform-plan') {
|
|
508
|
-
return planResources(doc).map((r) => {
|
|
509
|
-
const v = prune(r.body) || {};
|
|
510
|
-
// A plan carries values, not expressions, so a setting names its store
|
|
511
|
-
// by the store's own bucket name or id.
|
|
512
|
-
const refs = ['bucket', 'id'].map((k) => v[k]).filter((x) => typeof x === 'string' && x.length >= 3)
|
|
513
|
-
.map((x) => new RegExp(escRe(JSON.stringify(x))));
|
|
514
|
-
return { type: r.type, refs, body: asBody(r.body), typed: true };
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
const units = tfJsonResources(doc).map((r) => ({ type: r.type, refs: [addressRef(`${r.type}.${r.name}`)], body: asBody(r.body), typed: true }));
|
|
518
|
-
// Everything outside `resource` (providers, variables, locals) is one untyped
|
|
519
|
-
// unit, the way the text before the first resource block is in a .tf file.
|
|
520
|
-
if (doc && typeof doc === 'object' && !Array.isArray(doc)) {
|
|
521
|
-
const rest = { ...doc }; delete rest.resource;
|
|
522
|
-
units.push({ type: null, refs: [], body: asBody(rest), typed: false });
|
|
523
|
-
}
|
|
524
|
-
return units;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
553
|
function classify(file, text) {
|
|
528
554
|
const n = basename(file).toLowerCase();
|
|
555
|
+
if (n.endsWith('.tf.json')) return 'terraform-json';
|
|
529
556
|
if (n.endsWith('.tf')) return 'terraform';
|
|
530
|
-
if (n.endsWith('.tf.json')) return 'terraform';
|
|
531
557
|
if (n === 'dockerfile' || n.startsWith('dockerfile.')) return 'container';
|
|
532
558
|
if (n.endsWith('.yaml') || n.endsWith('.yml')) {
|
|
533
|
-
|
|
559
|
+
if (!(/^\s*apiVersion\s*:/m.test(text) && /^\s*kind\s*:/m.test(text))) return null;
|
|
560
|
+
if (/\{\{[\s\S]*?\}\}/.test(text)) return 'templated';
|
|
561
|
+
return 'kubernetes';
|
|
534
562
|
}
|
|
535
563
|
if (n.endsWith('.json')) {
|
|
536
564
|
return /"terraform_version"\s*:/.test(text) &&
|
|
@@ -549,76 +577,119 @@ function canonical(v) {
|
|
|
549
577
|
return JSON.stringify(v === undefined ? null : v);
|
|
550
578
|
}
|
|
551
579
|
|
|
580
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
581
|
+
/** A Terraform address (aws_s3_bucket.logs) inside another resource's values,
|
|
582
|
+
* and not inside a longer address such as aws_s3_bucket.logs2. */
|
|
583
|
+
const addressRef = (address) => new RegExp(`(?:^|[^\\w.])${escRe(address)}(?![\\w-])`);
|
|
584
|
+
|
|
585
|
+
// Classification runs on the resource type or kind, never on the text around it.
|
|
586
|
+
const STORAGE_TYPE = /(bucket|storage|blob|disk|volume|filestore|_db|database|rds|dynamodb|_sql|redis|memcache|elasticache|efs|fileshare|cosmos|bigtable|spanner)/i;
|
|
587
|
+
const LOGGING_TYPE = /(log|trail|audit|monitor|insight|diagnostic)/i;
|
|
588
|
+
const IDENTITY_TYPE = /(iam|role|policy|service_account|serviceaccount|rbac|identity|access_key|keyring|secret)/i;
|
|
589
|
+
// A storage-matching Terraform type that names a SETTING on a store rather than
|
|
590
|
+
// a store. A setting that declares encryption and refers to a store credits it.
|
|
591
|
+
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)$/;
|
|
592
|
+
const isStorageSetting = (type) => type.includes('_') && STORAGE_SETTING.test(type);
|
|
593
|
+
const rel = (p) => { const r = relative(process.cwd(), p); return !r ? '.' : r.startsWith('..') ? p : r; };
|
|
594
|
+
|
|
552
595
|
function lint() {
|
|
553
|
-
const { positional } = parseArgs('lint');
|
|
596
|
+
const { flags, positional } = parseArgs('lint');
|
|
554
597
|
if (positional.length > 1) fail(EXIT.USAGE, `lint takes one optional [path], got: ${positional.join(' ')}`);
|
|
555
598
|
const target = positional[0] || '.';
|
|
556
599
|
if (!existsSync(target)) fail(EXIT.USAGE, `path not found: ${target}`);
|
|
557
600
|
|
|
558
|
-
const
|
|
601
|
+
const cov = { discovered: 0, excluded_directories: 0, truncated: false, not_applicable: 0, excluded: [], skipped: [], invalid: [], unreadable: [] };
|
|
602
|
+
const files = walk(target, cov);
|
|
559
603
|
const byKind = { terraform: 0, 'terraform-plan': 0, kubernetes: 0, container: 0 };
|
|
560
604
|
const regions = new Set();
|
|
561
605
|
const resourceTypes = new Map();
|
|
562
|
-
const stores = [];
|
|
563
|
-
const encryptingSettings = []; //
|
|
606
|
+
const stores = []; // { address, planIds, state }
|
|
607
|
+
const encryptingSettings = []; // reference text of settings that declare encryption
|
|
564
608
|
let read = 0, logging = 0, identity = 0;
|
|
565
609
|
let openIngress = 0, publicAccess = 0, inlineCredentials = 0;
|
|
566
610
|
|
|
567
611
|
for (const f of files) {
|
|
568
612
|
let text;
|
|
569
613
|
try {
|
|
570
|
-
|
|
614
|
+
const size = statSync(f).size;
|
|
615
|
+
if (size > MAX_BYTES) { cov.skipped.push({ path: rel(f), reason: `larger than ${MAX_BYTES} bytes (${size})` }); continue; }
|
|
571
616
|
text = readFileSync(f, 'utf8');
|
|
572
|
-
} catch { continue; }
|
|
617
|
+
} catch (e) { cov.unreadable.push({ path: rel(f), reason: e.code || 'read failed' }); continue; }
|
|
573
618
|
const kind = classify(f, text);
|
|
574
|
-
if (!kind) continue;
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
for (const m of flat.matchAll(REGION_RE)) regions.add(m[1]);
|
|
583
|
-
for (const line of flat.split('\n')) if (SECRET_RE.test(line)) inlineCredentials++;
|
|
584
|
-
|
|
585
|
-
if (kind === 'terraform' && !isJson) {
|
|
586
|
-
for (const m of text.matchAll(RESOURCE_RE)) {
|
|
587
|
-
resourceTypes.set(m[1], (resourceTypes.get(m[1]) || 0) + 1);
|
|
588
|
-
}
|
|
619
|
+
if (!kind) { cov.not_applicable++; continue; }
|
|
620
|
+
if (kind === 'templated') { cov.excluded.push({ path: rel(f), reason: 'a templated manifest ({{ }}); render it first to read it' }); continue; }
|
|
621
|
+
|
|
622
|
+
let units;
|
|
623
|
+
try { units = unitsOf(kind, text); }
|
|
624
|
+
catch (e) {
|
|
625
|
+
if (e instanceof InvalidDeclaration) { cov.invalid.push({ path: rel(f), reason: e.message.slice(0, 200) }); continue; }
|
|
626
|
+
throw e;
|
|
589
627
|
}
|
|
628
|
+
byKind[kind === 'terraform-json' ? 'terraform' : kind]++;
|
|
629
|
+
read++;
|
|
590
630
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
631
|
+
for (const u of units) {
|
|
632
|
+
for (const r of regionsIn(u.tree)) regions.add(r);
|
|
633
|
+
inlineCredentials += credentialLiterals(u.tree);
|
|
634
|
+
if (opensToAnyAddress(u.tree)) openIngress++;
|
|
635
|
+
if (markedPublic(u.tree)) publicAccess++;
|
|
595
636
|
const t = u.type;
|
|
596
|
-
if (t
|
|
597
|
-
if (t
|
|
598
|
-
|
|
599
|
-
|
|
637
|
+
if (!t) continue;
|
|
638
|
+
if (u.typed) resourceTypes.set(t, (resourceTypes.get(t) || 0) + 1);
|
|
639
|
+
if (STORAGE_TYPE.test(t)) {
|
|
640
|
+
const state = encryptionState(u.tree);
|
|
641
|
+
if (isStorageSetting(t)) { if (state === ENCRYPTION.TRUE) encryptingSettings.push(referenceText(u.tree)); }
|
|
642
|
+
else {
|
|
643
|
+
const planIds = u.plan ? ['bucket', 'id'].map((k) => u.tree && u.tree[k]).filter((x) => typeof x === 'string' && x.length >= 3) : [];
|
|
644
|
+
stores.push({ address: u.address, planIds, state });
|
|
645
|
+
}
|
|
600
646
|
}
|
|
601
|
-
if (
|
|
602
|
-
if (
|
|
603
|
-
if (OPEN_CIDR_RE.test(u.body)) openIngress++;
|
|
604
|
-
if (PUBLIC_RE.test(u.body)) publicAccess++;
|
|
647
|
+
if (LOGGING_TYPE.test(t)) logging++;
|
|
648
|
+
if (IDENTITY_TYPE.test(t)) identity++;
|
|
605
649
|
}
|
|
606
650
|
}
|
|
607
651
|
|
|
608
|
-
// A store
|
|
609
|
-
//
|
|
652
|
+
// A store with nothing declared in its own body is credited by a setting
|
|
653
|
+
// resource, anywhere in the tree, that declares encryption and refers to it.
|
|
610
654
|
for (const s of stores) {
|
|
611
|
-
if (
|
|
655
|
+
if (s.state !== ENCRYPTION.ABSENT) continue;
|
|
656
|
+
const refs = [];
|
|
657
|
+
if (s.address) refs.push(addressRef(s.address));
|
|
658
|
+
for (const id of s.planIds) refs.push(new RegExp(`(?:^|\\n)${escRe(id)}(?:$|\\n)`));
|
|
659
|
+
if (refs.some((re) => encryptingSettings.some((b) => re.test(b)))) s.state = ENCRYPTION.TRUE;
|
|
612
660
|
}
|
|
613
|
-
const
|
|
614
|
-
|
|
661
|
+
const byState = (st) => stores.filter((s) => s.state === st).length;
|
|
662
|
+
|
|
663
|
+
const incomplete = cov.truncated || cov.skipped.length > 0 || cov.invalid.length > 0 || cov.unreadable.length > 0;
|
|
664
|
+
const coverage = {
|
|
665
|
+
completeness: incomplete ? 'incomplete' : 'complete',
|
|
666
|
+
traversal: 'depth first, entries sorted by name',
|
|
667
|
+
files_discovered: cov.discovered,
|
|
668
|
+
files_selected: files.length,
|
|
669
|
+
files_read: read,
|
|
670
|
+
files_not_applicable: cov.not_applicable,
|
|
671
|
+
files_excluded: cov.excluded.length,
|
|
672
|
+
files_skipped: cov.skipped.length,
|
|
673
|
+
files_invalid: cov.invalid.length,
|
|
674
|
+
files_unreadable: cov.unreadable.length,
|
|
675
|
+
directories_excluded: cov.excluded_directories,
|
|
676
|
+
traversal_truncated: cov.truncated,
|
|
677
|
+
limits: { max_files: MAX_FILES, max_bytes_per_file: MAX_BYTES },
|
|
678
|
+
};
|
|
679
|
+
const listed = (arr) => ({ entries: arr.slice(0, LIST_CAP), truncated: arr.length > LIST_CAP });
|
|
680
|
+
const details = { excluded: listed(cov.excluded), skipped: listed(cov.skipped), invalid: listed(cov.invalid), unreadable: listed(cov.unreadable) };
|
|
681
|
+
|
|
682
|
+
const exitFor = () => {
|
|
683
|
+
if (incomplete && !flags['--allow-incomplete']) return EXIT.INCOMPLETE;
|
|
684
|
+
return read === 0 ? EXIT.FINDING : EXIT.OK;
|
|
685
|
+
};
|
|
615
686
|
|
|
616
|
-
if (read === 0) {
|
|
687
|
+
if (read === 0 && !incomplete) {
|
|
617
688
|
const msg = `no infrastructure declarations found under ${target}.`;
|
|
618
689
|
diag(`${A}nothing to read${X} ${msg}`);
|
|
619
690
|
diag(`${D} lint reads .tf, .tf.json, Kubernetes YAML (apiVersion + kind), terraform plan`);
|
|
620
|
-
diag(` JSON and Dockerfiles. ${files.length} file(s) were
|
|
621
|
-
if (asJson) emitJson({ ok: false, error: msg, exit: EXIT.FINDING, files_opened: files.length });
|
|
691
|
+
diag(` JSON and Dockerfiles. ${files.length} file(s) were selected and none held a declaration.${X}`);
|
|
692
|
+
if (asJson) emitJson({ ok: false, error: msg, exit: EXIT.FINDING, root: rel(target), files_opened: files.length, coverage, coverage_details: details });
|
|
622
693
|
process.exit(EXIT.FINDING);
|
|
623
694
|
}
|
|
624
695
|
|
|
@@ -632,31 +703,43 @@ function lint() {
|
|
|
632
703
|
sources: Object.fromEntries(Object.entries(byKind).filter(([, n]) => n > 0)),
|
|
633
704
|
regions_and_zones_declared: [...regions].sort(),
|
|
634
705
|
resource_types: topTypes,
|
|
635
|
-
storage_declarations:
|
|
636
|
-
storage_declaring_encryption:
|
|
706
|
+
storage_declarations: stores.length,
|
|
707
|
+
storage_declaring_encryption: byState(ENCRYPTION.TRUE),
|
|
708
|
+
storage_declaring_encryption_off: byState(ENCRYPTION.FALSE),
|
|
709
|
+
storage_encryption_not_declared: byState(ENCRYPTION.ABSENT),
|
|
710
|
+
storage_encryption_unresolved: byState(ENCRYPTION.UNRESOLVED),
|
|
711
|
+
storage_encryption_unsupported: byState(ENCRYPTION.UNSUPPORTED),
|
|
637
712
|
logging_declarations: logging,
|
|
638
713
|
identity_declarations: identity,
|
|
639
714
|
declarations_open_to_any_address: openIngress,
|
|
640
715
|
declarations_marked_public: publicAccess,
|
|
641
716
|
inline_credential_literals: inlineCredentials,
|
|
717
|
+
completeness: coverage.completeness,
|
|
718
|
+
files_selected: files.length,
|
|
719
|
+
files_not_read: files.length - read - cov.not_applicable - cov.excluded.length,
|
|
642
720
|
};
|
|
643
721
|
|
|
644
|
-
const digest = createHash('sha256').update(canonical(facts)).digest('hex')
|
|
722
|
+
const digest = `sha256:${createHash('sha256').update(canonical(facts)).digest('hex')}`;
|
|
645
723
|
const doc = {
|
|
646
724
|
tool: 'trooth-lint',
|
|
725
|
+
schema: 'trooth-lint/2',
|
|
647
726
|
cli_version: VERSION,
|
|
648
727
|
observed_at: new Date().toISOString(),
|
|
649
|
-
root: (
|
|
728
|
+
root: rel(target),
|
|
650
729
|
facts,
|
|
651
|
-
|
|
730
|
+
coverage,
|
|
731
|
+
coverage_details: details,
|
|
732
|
+
facts_digest: digest,
|
|
733
|
+
digest,
|
|
734
|
+
digest_scope: 'A SHA-256 over the facts object only, in canonical form. It is an aggregate: two different trees with the same counts share it. It does not identify file contents, a repository, a commit or a deployment. `digest` is the same value under its 0.4 name and will be removed in 0.6.',
|
|
652
735
|
note: 'Declared facts only. Read locally; nothing was transmitted. No verdict and no assessment against any standard.',
|
|
653
736
|
};
|
|
654
737
|
|
|
655
|
-
|
|
738
|
+
const code = exitFor();
|
|
739
|
+
if (asJson) { emitJson(doc); process.exit(code); }
|
|
656
740
|
|
|
657
741
|
out(`\n${J}${B}trooth lint${X} ${D}// local · offline · declarations only //${X}`);
|
|
658
742
|
out(`${D}${doc.root} ${read} declaration file(s) read${X}`);
|
|
659
|
-
|
|
660
743
|
const src = Object.entries(facts.sources).map(([k, n]) => `${k} ${n}`).join(' · ');
|
|
661
744
|
if (src) out(`${D}${src}${X}`);
|
|
662
745
|
|
|
@@ -664,7 +747,11 @@ function lint() {
|
|
|
664
747
|
const row = (label, value) => out(` ${label.padEnd(40)} ${value}`);
|
|
665
748
|
row('Regions and zones', facts.regions_and_zones_declared.length ? facts.regions_and_zones_declared.join(', ') : `${D}none declared${X}`);
|
|
666
749
|
row('Storage declarations', `${facts.storage_declarations}`);
|
|
667
|
-
row('
|
|
750
|
+
row(' declaring encryption', `${facts.storage_declaring_encryption}`);
|
|
751
|
+
row(' declaring encryption off', `${facts.storage_declaring_encryption_off}`);
|
|
752
|
+
row(' declaring nothing about encryption', `${facts.storage_encryption_not_declared}`);
|
|
753
|
+
row(' set by an unresolved expression', `${facts.storage_encryption_unresolved}`);
|
|
754
|
+
if (facts.storage_encryption_unsupported) row(' set to a value lint does not read', `${facts.storage_encryption_unsupported}`);
|
|
668
755
|
row('Logging declarations', `${facts.logging_declarations}`);
|
|
669
756
|
row('Identity declarations', `${facts.identity_declarations}`);
|
|
670
757
|
row('Open to any address (0.0.0.0/0, ::/0)', `${facts.declarations_open_to_any_address}`);
|
|
@@ -676,22 +763,29 @@ function lint() {
|
|
|
676
763
|
for (const t of topTypes.slice(0, 6)) out(` ${String(t.count).padStart(4)} ${D}${t.type}${X}`);
|
|
677
764
|
}
|
|
678
765
|
|
|
679
|
-
out(`\n${B}
|
|
680
|
-
out(`${D}
|
|
681
|
-
|
|
682
|
-
|
|
766
|
+
out(`\n${B}Coverage${X} ${incomplete ? `${A}incomplete${X}` : `${J}complete${X}`}`);
|
|
767
|
+
out(`${D} ${coverage.files_selected} selected: ${read} read, ${coverage.files_not_applicable} not declarations, ${coverage.files_excluded} excluded, ${coverage.files_skipped} skipped, ${coverage.files_invalid} invalid, ${coverage.files_unreadable} unreadable${cov.truncated ? `; the walk stopped at ${MAX_FILES} files` : ''}.${X}`);
|
|
768
|
+
for (const [label, arr] of [['skipped', cov.skipped], ['invalid', cov.invalid], ['unreadable', cov.unreadable], ['excluded', cov.excluded]]) {
|
|
769
|
+
for (const e of arr.slice(0, 5)) out(`${D} ${label}: ${e.path} (${e.reason})${X}`);
|
|
770
|
+
if (arr.length > 5) out(`${D} and ${arr.length - 5} more ${label}; --json lists up to ${LIST_CAP}.${X}`);
|
|
771
|
+
}
|
|
772
|
+
if (incomplete) out(`${D} Exit code 4 says the read was incomplete. --allow-incomplete reports the same and exits 0.${X}`);
|
|
773
|
+
|
|
774
|
+
out(`\n${B}Facts digest${X} ${C}${doc.facts_digest}${X}`);
|
|
775
|
+
out(`${D}A SHA-256 over the counts above, in canonical form. It is an aggregate: two different`);
|
|
776
|
+
out(`trees with the same counts share it. It does not identify your files, your repository`);
|
|
777
|
+
out(`or a deployment.${X}`);
|
|
683
778
|
|
|
684
779
|
out(`\n${B}How this was read${X}`);
|
|
685
|
-
out(`${D}
|
|
686
|
-
out(`
|
|
687
|
-
out(`
|
|
688
|
-
out(` Dockerfiles for regions, open addresses, public markers and credential literals only.${X}`);
|
|
780
|
+
out(`${D} Every file is parsed; a file that does not parse is reported as invalid, not read.`);
|
|
781
|
+
out(` Comments count for nothing. Nothing is evaluated: a setting that depends on a variable,`);
|
|
782
|
+
out(` a local, a module or a function is reported as unresolved. Dockerfiles: ENV and ARG only.${X}`);
|
|
689
783
|
|
|
690
784
|
out(`\n${D}Counts of what the files declare. Not a judgment: a public load balancer is`);
|
|
691
785
|
out(`supposed to be public. Trooth issues no verdict here and checks nothing against`);
|
|
692
786
|
out(`any standard. Nothing left this machine: lint opens files and opens no sockets.`);
|
|
693
787
|
out(`Publish what you choose on your record at ${X}${C}https://trooth.co/dashboard${X}${D}.${X}\n`);
|
|
694
|
-
process.exit(
|
|
788
|
+
process.exit(code);
|
|
695
789
|
}
|
|
696
790
|
|
|
697
791
|
/* ---------------------------------------------------------------- main ---- */
|