trooth 0.4.4 → 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 +105 -39
- package/bin/lib/declarations.mjs +306 -0
- package/bin/lib/hcl.mjs +294 -0
- package/bin/trooth.mjs +393 -348
- 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,57 +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
|
-
}
|
|
172
|
-
|
|
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);
|
|
173
206
|
}
|
|
207
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
|
174
208
|
}
|
|
175
209
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
}
|
|
232
|
+
}
|
|
233
|
+
throw new Upstream(`could not reach the Trooth Network at ${API}: ${lastErr && lastErr.message ? lastErr.message : lastErr}`);
|
|
179
234
|
}
|
|
180
235
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
catch { fail(EXIT.UPSTREAM, `${what} returned a response that is not JSON.`); }
|
|
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 }); }
|
|
186
240
|
}
|
|
187
241
|
|
|
188
242
|
/* --------------------------------------------------------------- check ---- */
|
|
189
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
|
+
*/
|
|
190
253
|
function normalizeDomain(input) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
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 };
|
|
198
269
|
}
|
|
270
|
+
const sameDomain = (a, b) => { const x = normalizeDomain(a); return !!x.domain && x.domain === b; };
|
|
199
271
|
|
|
200
272
|
function fmtDate(iso) {
|
|
201
273
|
if (!iso) return '';
|
|
202
274
|
const d = new Date(iso);
|
|
203
|
-
if (isNaN(d)) return
|
|
275
|
+
if (isNaN(d)) return '';
|
|
204
276
|
return d.toISOString().slice(0, 10);
|
|
205
277
|
}
|
|
206
278
|
|
|
207
279
|
function count(obj) {
|
|
208
280
|
if (!obj || typeof obj !== 'object') return null;
|
|
209
|
-
|
|
210
|
-
|
|
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 };
|
|
211
284
|
}
|
|
212
285
|
|
|
213
|
-
/** 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"). */
|
|
214
287
|
function countLines(rec) {
|
|
215
288
|
const lines = [];
|
|
216
289
|
if (rec.probes) lines.push(`${B}Live probes:${X} ${rec.probes.total} read; ${rec.probes.passed} as expected`);
|
|
@@ -241,110 +314,139 @@ function eventLabel(type) {
|
|
|
241
314
|
return Object.prototype.hasOwnProperty.call(EVENT_LABELS, type) ? EVENT_LABELS[type] : type.replace(/_/g, ' ');
|
|
242
315
|
}
|
|
243
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. */
|
|
244
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}`);
|
|
245
350
|
const events = Array.isArray(v.events)
|
|
246
|
-
? 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 }))
|
|
247
352
|
: [];
|
|
353
|
+
const probes = count(v.probes);
|
|
354
|
+
const state = evidenceState(v, probes);
|
|
248
355
|
const rec = {
|
|
249
356
|
domain,
|
|
250
|
-
listed:
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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,
|
|
256
364
|
attested: count(v.attested),
|
|
257
365
|
events,
|
|
258
|
-
receipt_signature: v.receipt_signature
|
|
259
|
-
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,
|
|
260
369
|
verify_keys: `${API}/public/keys`,
|
|
370
|
+
verify_how: 'https://trooth.co/docs/verifiable-evidence',
|
|
261
371
|
record_url: `https://trooth.co/network/${encodeURIComponent(domain)}`,
|
|
262
372
|
};
|
|
263
|
-
if (v.category) rec.category =
|
|
264
|
-
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;
|
|
265
375
|
return scrub(rec);
|
|
266
376
|
}
|
|
267
377
|
|
|
268
|
-
/**
|
|
269
|
-
*
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
* no record for that domain. It asks for that one record
|
|
278
|
-
* (/directory/api/vendors/<domain>), which returns the same object the list
|
|
279
|
-
* carries for that domain, and does not download every company to find it. */
|
|
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
|
+
*/
|
|
280
387
|
async function readVendor(domain) {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (res.status === 404) {
|
|
285
|
-
const text = await res.text().catch(() => '');
|
|
388
|
+
const r = await getTrooth(`/directory/api/vendors/${encodeURIComponent(domain)}`);
|
|
389
|
+
if (r.status === 404) {
|
|
286
390
|
let body = null;
|
|
287
|
-
try { body = JSON.parse(text); } catch {}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (body && typeof body === 'object' && body.listed === false) return null;
|
|
291
|
-
// FALLBACK TO THE LIST. Any other 404 comes from a deploy of the directory
|
|
292
|
-
// worker that predates the single-record route: it answers an unknown path
|
|
293
|
-
// with a plain-text "Not found". Read the list and search it here, as 0.4.3
|
|
294
|
-
// does, so this release keeps working against a worker that has not been
|
|
295
|
-
// redeployed yet, and against a TROOTH_API that points at an older one.
|
|
296
|
-
// This fallback can go in the first release after api.trooth.co serves the
|
|
297
|
-
// route, that is, once a request for a domain with no record there returns
|
|
298
|
-
// a JSON 404 with `listed: false`.
|
|
299
|
-
return readVendorFromList(domain, what);
|
|
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 });
|
|
300
394
|
}
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
let v;
|
|
304
|
-
try { v = await res.json(); }
|
|
305
|
-
catch { fail(EXIT.UPSTREAM, `${what} returned a response that is not JSON.`); }
|
|
306
|
-
if (!v || typeof v !== 'object' || Array.isArray(v) || normalizeDomain(v.domain) !== domain) {
|
|
307
|
-
fail(EXIT.UPSTREAM, `${what} returned a response that is not the record for ${domain}.`);
|
|
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 });
|
|
308
397
|
}
|
|
309
|
-
return
|
|
398
|
+
return parseJsonBody(r);
|
|
310
399
|
}
|
|
311
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
|
+
|
|
312
408
|
async function check() {
|
|
313
409
|
const { positional } = parseArgs('check');
|
|
314
410
|
if (positional.length > 1) fail(EXIT.USAGE, `check takes one <domain>, got: ${positional.join(' ')}`);
|
|
315
|
-
const
|
|
316
|
-
if (
|
|
411
|
+
const norm = normalizeDomain(positional[0]);
|
|
412
|
+
if (norm.error) fail(EXIT.USAGE, norm.error);
|
|
413
|
+
const domain = norm.domain;
|
|
317
414
|
|
|
318
|
-
|
|
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
|
+
}
|
|
319
423
|
|
|
320
|
-
if (!
|
|
424
|
+
if (!rec) {
|
|
321
425
|
if (asJson) {
|
|
322
|
-
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)}` });
|
|
323
427
|
} else {
|
|
324
428
|
out(`\n${B}${domain}${X} ${D}//${X} ${A}not listed in the Trooth Network's public feed${X}`);
|
|
325
429
|
out(`\n${D}The public feed carries no record for this domain. That says nothing about the`);
|
|
326
430
|
out(`company: a domain that never listed, a listing Trooth has not published, and a`);
|
|
327
431
|
out(`record that was revoked all read this way. A company gets a record by listing at`);
|
|
328
432
|
out(`${X}${C}https://trooth.co/get-started${X}${D}: Trooth reads its public surface and publishes`);
|
|
329
|
-
out(`a
|
|
433
|
+
out(`a dated record that anyone, or any agent, can read.${X}\n`);
|
|
330
434
|
}
|
|
331
435
|
process.exit(EXIT.FINDING);
|
|
332
436
|
}
|
|
333
437
|
|
|
334
|
-
const
|
|
335
|
-
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); }
|
|
336
440
|
|
|
337
441
|
const when = fmtDate(rec.witnessed_at);
|
|
338
442
|
const since = fmtDate(rec.first_published_at);
|
|
339
|
-
out(`\n${J}${B}Trooth Network${X} ${D}// public
|
|
443
|
+
out(`\n${J}${B}Trooth Network${X} ${D}// public record · read-only //${X}`);
|
|
340
444
|
out(`${B}${rec.company_name}${X} ${C}${domain}${X}`);
|
|
341
|
-
out(`Listing state: ${
|
|
342
|
-
(when ? ` ${D}
|
|
445
|
+
out(`Listing state: ${STATE_TEXT[rec.state]}` +
|
|
446
|
+
(when ? ` ${D}reading dated ${when}${X}` : '') +
|
|
343
447
|
(since ? ` ${D}first published ${since}${X}` : ''));
|
|
344
448
|
|
|
345
|
-
// Two counts, never a ratio, in the form the website's record page uses
|
|
346
|
-
// "N/M" cell reads as a bar, and a bar reads as a grade. The JSON fields keep
|
|
347
|
-
// the feed's names (`passed`, `total`).
|
|
449
|
+
// Two counts, never a ratio, in the form the website's record page uses.
|
|
348
450
|
const counts = countLines(rec);
|
|
349
451
|
if (counts.length) {
|
|
350
452
|
out('');
|
|
@@ -362,20 +464,22 @@ async function check() {
|
|
|
362
464
|
const latest = latestEvents(rec.events, 3);
|
|
363
465
|
if (latest.length) {
|
|
364
466
|
out(`\n${B}Latest ledger events, newest first${X}`);
|
|
365
|
-
for (const e of latest) out(` ${J}•${X} ${D}${fmtDate(e.at)}${X} ${eventLabel(e.type)}`);
|
|
467
|
+
for (const e of latest) out(` ${J}•${X} ${D}${fmtDate(e.at) || 'undated'}${X} ${eventLabel(e.type)}`);
|
|
366
468
|
out(`${D} --json carries the whole ledger, with the feed's own wording for each event.${X}`);
|
|
367
469
|
}
|
|
368
470
|
|
|
369
|
-
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.`);
|
|
370
474
|
out(`Full record: ${X}${C}${rec.record_url}${X}${D} · Signing keys: ${rec.verify_keys}${X}\n`);
|
|
371
|
-
process.exit(
|
|
475
|
+
process.exit(code);
|
|
372
476
|
}
|
|
373
477
|
|
|
374
478
|
/* ---------------------------------------------------------------- lint ---- */
|
|
375
479
|
/* WHAT lint IS, AND WHAT IT IS CAREFULLY NOT.
|
|
376
480
|
*
|
|
377
481
|
* It reads the infrastructure a repository DECLARES and reports those
|
|
378
|
-
* declarations as
|
|
482
|
+
* declarations as counts: how many storage resources declare encryption, which
|
|
379
483
|
* regions appear, how many rules declare exposure to the whole internet. It
|
|
380
484
|
* does not judge them. There is no verdict, no threshold, no severity and no
|
|
381
485
|
* rating, and nothing is checked against a named standard or regulation.
|
|
@@ -383,203 +487,78 @@ async function check() {
|
|
|
383
487
|
* public. What the facts mean is the reader's decision.
|
|
384
488
|
*
|
|
385
489
|
* It opens files and opens no sockets. Nothing about the repository leaves the
|
|
386
|
-
* machine.
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
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.
|
|
391
502
|
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
* classified by its top-level `kind`
|
|
400
|
-
* Dockerfile read for regions, open addresses, public markers and
|
|
401
|
-
* credential literals only; it declares no resource types
|
|
402
|
-
* Storage, logging and identity are classified on a unit's resource type or
|
|
403
|
-
* kind, never on the text around it. The human output prints this list in
|
|
404
|
-
* short, so the limit is stated where the counts are. */
|
|
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. */
|
|
405
510
|
|
|
406
511
|
const SKIP_DIRS = new Set([
|
|
407
512
|
'node_modules', '.git', '.terraform', '.next', 'dist', 'build', 'vendor',
|
|
408
513
|
'.venv', 'venv', '__pycache__', '.cache', 'coverage', '.turbo',
|
|
409
514
|
]);
|
|
410
|
-
const MAX_FILES = 5000;
|
|
515
|
+
const MAX_FILES = Number(process.env.TROOTH_LINT_MAX_FILES) > 0 ? Number(process.env.TROOTH_LINT_MAX_FILES) : 5000;
|
|
411
516
|
const MAX_BYTES = 4 * 1024 * 1024;
|
|
517
|
+
const LIST_CAP = 50;
|
|
412
518
|
|
|
413
|
-
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) {
|
|
414
528
|
const found = [];
|
|
415
|
-
const stack = [root];
|
|
416
529
|
let st;
|
|
417
|
-
try { st = statSync(root); } catch { return found; }
|
|
418
|
-
if (st.isFile())
|
|
419
|
-
|
|
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) {
|
|
420
534
|
const dir = stack.pop();
|
|
421
535
|
let entries;
|
|
422
|
-
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 = [];
|
|
423
540
|
for (const e of entries) {
|
|
424
|
-
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; }
|
|
425
542
|
if (!e.isFile()) continue;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
ext === '.yaml' || ext === '.yml' || ext === '.json' ||
|
|
431
|
-
n === 'dockerfile' || n.startsWith('dockerfile.');
|
|
432
|
-
if (keep) found.push(join(dir, e.name));
|
|
433
|
-
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));
|
|
434
547
|
}
|
|
548
|
+
for (let k = subdirs.length - 1; k >= 0; k--) stack.push(subdirs[k]);
|
|
435
549
|
}
|
|
436
550
|
return found;
|
|
437
551
|
}
|
|
438
552
|
|
|
439
|
-
const REGION_RE = /\b(?:region|location|availability_zone|aws_region)\s*[:=]\s*["']([A-Za-z0-9][A-Za-z0-9._-]{2,40})["']/g;
|
|
440
|
-
const RESOURCE_RE = /\bresource\s+"([a-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/g;
|
|
441
|
-
const ENCRYPT_RE = /(?:encrypted|encryption|kms_key|sse_algorithm|server_side_encryption|encrypt_at_rest)/i;
|
|
442
|
-
// Classification runs on the RESOURCE TYPE TOKEN (aws_db_instance, not the body
|
|
443
|
-
// text), matched as a substring. An earlier version wrapped these in \b, which
|
|
444
|
-
// never fires inside a snake_case identifier: \bdynamodb\b cannot match
|
|
445
|
-
// aws_dynamodb_table because the underscore either side is a word character, so
|
|
446
|
-
// every storage resource whose name was not the bare word "bucket" went
|
|
447
|
-
// uncounted. Substring matching on the type is both simpler and correct.
|
|
448
|
-
const STORAGE_TYPE = /(bucket|storage|blob|disk|volume|filestore|_db|database|rds|dynamodb|_sql|redis|memcache|elasticache|efs|fileshare|cosmos|bigtable|spanner)/i;
|
|
449
|
-
const LOGGING_TYPE = /(log|trail|audit|monitor|insight|diagnostic)/i;
|
|
450
|
-
const IDENTITY_TYPE = /(iam|role|policy|service_account|serviceaccount|rbac|identity|access_key|keyring|secret)/i;
|
|
451
|
-
const OPEN_CIDR_RE = /(?:"0\.0\.0\.0\/0"|'0\.0\.0\.0\/0'|"::\/0"|'::\/0')/;
|
|
452
|
-
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/;
|
|
453
|
-
// A literal that looks like a credential sitting in the file. Reported as a
|
|
454
|
-
// count only: no file name, no line, and never the value itself.
|
|
455
|
-
const SECRET_RE = /\b(?:password|secret|api[_-]?key|access[_-]?key|token|private[_-]?key)\s*[:=]\s*["'][^"'${}\n]{8,}["']/i;
|
|
456
|
-
// A storage-matching Terraform type that names a SETTING on a store rather than
|
|
457
|
-
// a store: aws_s3_bucket_server_side_encryption_configuration, a bucket policy,
|
|
458
|
-
// a volume attachment, a subnet group. The substring match above catches these
|
|
459
|
-
// (the fixture's one bucket used to count as two storage declarations), so they
|
|
460
|
-
// are not counted as storage. A setting that declares encryption and references
|
|
461
|
-
// a store credits that store with declaring encryption. Applied to snake_case
|
|
462
|
-
// Terraform types only; Kubernetes kinds are CamelCase and are not settings.
|
|
463
|
-
const STORAGE_SETTING = /_(?:configuration|policy|acl|versioning|notification|public_access_block|ownership_controls|attachment|object|item|logging|iam_member|iam_binding|access_point|mount_target|snapshot|subnet_group|parameter_group|option_group)$/;
|
|
464
|
-
const isStorageSetting = (type) => type.includes('_') && STORAGE_SETTING.test(type);
|
|
465
|
-
// An attribute set to false, null or empty declares nothing: `encrypted = false`
|
|
466
|
-
// and `storage_encrypted: false` must not count as declaring encryption.
|
|
467
|
-
const NEGATIVE_ATTR_RE = /^[^\n:=]*[:=]\s*(?:false|null|"false"|'false'|""|''|\[\]|\{\})\s*,?\s*$/gim;
|
|
468
|
-
const declaresEncryption = (body) => ENCRYPT_RE.test(body.replace(NEGATIVE_ATTR_RE, ''));
|
|
469
|
-
|
|
470
|
-
/** JSON text with `"key":` rewritten as `key:`, so the attribute patterns above
|
|
471
|
-
* (written for `key = "v"` and `key: "v"`) read JSON sources too. Escaped
|
|
472
|
-
* quotes inside string values are never rewritten. */
|
|
473
|
-
function flattenJson(text) {
|
|
474
|
-
return text.replace(/"([A-Za-z_][\w.-]*)"\s*:/g, '$1:');
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/** A parsed JSON value with false, null, empty strings, empty arrays and empty
|
|
478
|
-
* objects removed, so a plan's unset attributes read as absent. */
|
|
479
|
-
function prune(v) {
|
|
480
|
-
if (Array.isArray(v)) { const a = v.map(prune).filter((x) => x !== undefined); return a.length ? a : undefined; }
|
|
481
|
-
if (v && typeof v === 'object') {
|
|
482
|
-
const o = {};
|
|
483
|
-
for (const [k, x] of Object.entries(v)) { const y = prune(x); if (y !== undefined) o[k] = y; }
|
|
484
|
-
return Object.keys(o).length ? o : undefined;
|
|
485
|
-
}
|
|
486
|
-
return v === null || v === false || v === '' ? undefined : v;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
490
|
-
/** A pattern that finds a Terraform address (aws_s3_bucket.logs) in another
|
|
491
|
-
* resource's body, as `aws_s3_bucket.logs.id` or `${aws_s3_bucket.logs.arn}`,
|
|
492
|
-
* and not inside a longer address such as aws_s3_bucket.logs2. */
|
|
493
|
-
const addressRef = (address) => new RegExp(`(?:^|[^\\w.])${escRe(address)}(?![\\w-])`);
|
|
494
|
-
|
|
495
|
-
/** Every resource under `resource` in a .tf.json document, in either of the
|
|
496
|
-
* shapes Terraform's JSON syntax allows (objects, or arrays of objects). */
|
|
497
|
-
function tfJsonResources(doc) {
|
|
498
|
-
const out = [];
|
|
499
|
-
const each = (v, fn) => { if (Array.isArray(v)) v.forEach((x) => each(x, fn)); else if (v && typeof v === 'object') fn(v); };
|
|
500
|
-
each(doc, (top) => each(top.resource, (byType) => {
|
|
501
|
-
for (const [type, byName] of Object.entries(byType)) {
|
|
502
|
-
if (!/^[a-z0-9_]+$/.test(type)) continue;
|
|
503
|
-
each(byName, (names) => { for (const [name, body] of Object.entries(names)) out.push({ type, name, body }); });
|
|
504
|
-
}
|
|
505
|
-
}));
|
|
506
|
-
return out;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** The managed resources a plan (`terraform show -json`) says will exist:
|
|
510
|
-
* planned_values, every module deep, or resource_changes when a plan has no
|
|
511
|
-
* planned_values. Data sources and deletions are not declarations. */
|
|
512
|
-
function planResources(doc) {
|
|
513
|
-
const out = [];
|
|
514
|
-
const walkModule = (m) => {
|
|
515
|
-
if (!m || typeof m !== 'object') return;
|
|
516
|
-
for (const r of Array.isArray(m.resources) ? m.resources : []) {
|
|
517
|
-
if (r && r.mode !== 'data' && typeof r.type === 'string') out.push({ type: r.type, name: String(r.name ?? ''), body: r.values ?? {} });
|
|
518
|
-
}
|
|
519
|
-
for (const c of Array.isArray(m.child_modules) ? m.child_modules : []) walkModule(c);
|
|
520
|
-
};
|
|
521
|
-
if (doc && doc.planned_values && doc.planned_values.root_module) walkModule(doc.planned_values.root_module);
|
|
522
|
-
else {
|
|
523
|
-
for (const rc of Array.isArray(doc && doc.resource_changes) ? doc.resource_changes : []) {
|
|
524
|
-
const after = rc && rc.change ? rc.change.after : null;
|
|
525
|
-
if (rc && rc.mode !== 'data' && typeof rc.type === 'string' && after) out.push({ type: rc.type, name: String(rc.name ?? ''), body: after });
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
return out;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/** Split one declaration file into the units lint classifies. Each unit has a
|
|
532
|
-
* `type` (a resource type or Kubernetes kind, or null when there is none),
|
|
533
|
-
* `refs` (patterns another unit's body would contain to refer to this one),
|
|
534
|
-
* `body` (the text attribute patterns run on) and `typed` (whether the type
|
|
535
|
-
* belongs in the resource type list). */
|
|
536
|
-
function unitsOf(kind, file, text) {
|
|
537
|
-
if (kind === 'terraform' && !basename(file).toLowerCase().endsWith('.tf.json')) {
|
|
538
|
-
return text.split(/\n(?=resource\s+")/).map((b) => {
|
|
539
|
-
const h = b.match(/^resource\s+"([a-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/);
|
|
540
|
-
return h ? { type: h[1], refs: [addressRef(`${h[1]}.${h[2]}`)], body: b, typed: false }
|
|
541
|
-
: { type: null, refs: [], body: b, typed: false };
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
if (kind === 'kubernetes') {
|
|
545
|
-
return text.split(/^---[^\n]*$/m).map((d) => {
|
|
546
|
-
const k = d.match(/^kind\s*:\s*["']?([A-Za-z][A-Za-z0-9]*)/m);
|
|
547
|
-
return { type: k ? k[1] : null, refs: [], body: d, typed: false };
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
if (kind === 'container') return [{ type: null, refs: [], body: text, typed: false }];
|
|
551
|
-
|
|
552
|
-
// .tf.json and plan JSON.
|
|
553
|
-
let doc;
|
|
554
|
-
try { doc = JSON.parse(text); } catch { return [{ type: null, refs: [], body: flattenJson(text), typed: false }]; }
|
|
555
|
-
const asBody = (v) => flattenJson(JSON.stringify(prune(v) ?? {}, null, 1));
|
|
556
|
-
if (kind === 'terraform-plan') {
|
|
557
|
-
return planResources(doc).map((r) => {
|
|
558
|
-
const v = prune(r.body) || {};
|
|
559
|
-
// A plan carries values, not expressions, so a setting names its store
|
|
560
|
-
// by the store's own bucket name or id.
|
|
561
|
-
const refs = ['bucket', 'id'].map((k) => v[k]).filter((x) => typeof x === 'string' && x.length >= 3)
|
|
562
|
-
.map((x) => new RegExp(escRe(JSON.stringify(x))));
|
|
563
|
-
return { type: r.type, refs, body: asBody(r.body), typed: true };
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
const units = tfJsonResources(doc).map((r) => ({ type: r.type, refs: [addressRef(`${r.type}.${r.name}`)], body: asBody(r.body), typed: true }));
|
|
567
|
-
// Everything outside `resource` (providers, variables, locals) is one untyped
|
|
568
|
-
// unit, the way the text before the first resource block is in a .tf file.
|
|
569
|
-
if (doc && typeof doc === 'object' && !Array.isArray(doc)) {
|
|
570
|
-
const rest = { ...doc }; delete rest.resource;
|
|
571
|
-
units.push({ type: null, refs: [], body: asBody(rest), typed: false });
|
|
572
|
-
}
|
|
573
|
-
return units;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
553
|
function classify(file, text) {
|
|
577
554
|
const n = basename(file).toLowerCase();
|
|
555
|
+
if (n.endsWith('.tf.json')) return 'terraform-json';
|
|
578
556
|
if (n.endsWith('.tf')) return 'terraform';
|
|
579
|
-
if (n.endsWith('.tf.json')) return 'terraform';
|
|
580
557
|
if (n === 'dockerfile' || n.startsWith('dockerfile.')) return 'container';
|
|
581
558
|
if (n.endsWith('.yaml') || n.endsWith('.yml')) {
|
|
582
|
-
|
|
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';
|
|
583
562
|
}
|
|
584
563
|
if (n.endsWith('.json')) {
|
|
585
564
|
return /"terraform_version"\s*:/.test(text) &&
|
|
@@ -598,76 +577,119 @@ function canonical(v) {
|
|
|
598
577
|
return JSON.stringify(v === undefined ? null : v);
|
|
599
578
|
}
|
|
600
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
|
+
|
|
601
595
|
function lint() {
|
|
602
|
-
const { positional } = parseArgs('lint');
|
|
596
|
+
const { flags, positional } = parseArgs('lint');
|
|
603
597
|
if (positional.length > 1) fail(EXIT.USAGE, `lint takes one optional [path], got: ${positional.join(' ')}`);
|
|
604
598
|
const target = positional[0] || '.';
|
|
605
599
|
if (!existsSync(target)) fail(EXIT.USAGE, `path not found: ${target}`);
|
|
606
600
|
|
|
607
|
-
const
|
|
601
|
+
const cov = { discovered: 0, excluded_directories: 0, truncated: false, not_applicable: 0, excluded: [], skipped: [], invalid: [], unreadable: [] };
|
|
602
|
+
const files = walk(target, cov);
|
|
608
603
|
const byKind = { terraform: 0, 'terraform-plan': 0, kubernetes: 0, container: 0 };
|
|
609
604
|
const regions = new Set();
|
|
610
605
|
const resourceTypes = new Map();
|
|
611
|
-
const stores = [];
|
|
612
|
-
const encryptingSettings = []; //
|
|
606
|
+
const stores = []; // { address, planIds, state }
|
|
607
|
+
const encryptingSettings = []; // reference text of settings that declare encryption
|
|
613
608
|
let read = 0, logging = 0, identity = 0;
|
|
614
609
|
let openIngress = 0, publicAccess = 0, inlineCredentials = 0;
|
|
615
610
|
|
|
616
611
|
for (const f of files) {
|
|
617
612
|
let text;
|
|
618
613
|
try {
|
|
619
|
-
|
|
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; }
|
|
620
616
|
text = readFileSync(f, 'utf8');
|
|
621
|
-
} catch { continue; }
|
|
617
|
+
} catch (e) { cov.unreadable.push({ path: rel(f), reason: e.code || 'read failed' }); continue; }
|
|
622
618
|
const kind = classify(f, text);
|
|
623
|
-
if (!kind) continue;
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
for (const m of flat.matchAll(REGION_RE)) regions.add(m[1]);
|
|
632
|
-
for (const line of flat.split('\n')) if (SECRET_RE.test(line)) inlineCredentials++;
|
|
633
|
-
|
|
634
|
-
if (kind === 'terraform' && !isJson) {
|
|
635
|
-
for (const m of text.matchAll(RESOURCE_RE)) {
|
|
636
|
-
resourceTypes.set(m[1], (resourceTypes.get(m[1]) || 0) + 1);
|
|
637
|
-
}
|
|
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;
|
|
638
627
|
}
|
|
628
|
+
byKind[kind === 'terraform-json' ? 'terraform' : kind]++;
|
|
629
|
+
read++;
|
|
639
630
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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++;
|
|
644
636
|
const t = u.type;
|
|
645
|
-
if (t
|
|
646
|
-
if (t
|
|
647
|
-
|
|
648
|
-
|
|
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
|
+
}
|
|
649
646
|
}
|
|
650
|
-
if (
|
|
651
|
-
if (
|
|
652
|
-
if (OPEN_CIDR_RE.test(u.body)) openIngress++;
|
|
653
|
-
if (PUBLIC_RE.test(u.body)) publicAccess++;
|
|
647
|
+
if (LOGGING_TYPE.test(t)) logging++;
|
|
648
|
+
if (IDENTITY_TYPE.test(t)) identity++;
|
|
654
649
|
}
|
|
655
650
|
}
|
|
656
651
|
|
|
657
|
-
// A store
|
|
658
|
-
//
|
|
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.
|
|
659
654
|
for (const s of stores) {
|
|
660
|
-
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;
|
|
661
660
|
}
|
|
662
|
-
const
|
|
663
|
-
|
|
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
|
+
};
|
|
664
686
|
|
|
665
|
-
if (read === 0) {
|
|
687
|
+
if (read === 0 && !incomplete) {
|
|
666
688
|
const msg = `no infrastructure declarations found under ${target}.`;
|
|
667
689
|
diag(`${A}nothing to read${X} ${msg}`);
|
|
668
690
|
diag(`${D} lint reads .tf, .tf.json, Kubernetes YAML (apiVersion + kind), terraform plan`);
|
|
669
|
-
diag(` JSON and Dockerfiles. ${files.length} file(s) were
|
|
670
|
-
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 });
|
|
671
693
|
process.exit(EXIT.FINDING);
|
|
672
694
|
}
|
|
673
695
|
|
|
@@ -681,31 +703,43 @@ function lint() {
|
|
|
681
703
|
sources: Object.fromEntries(Object.entries(byKind).filter(([, n]) => n > 0)),
|
|
682
704
|
regions_and_zones_declared: [...regions].sort(),
|
|
683
705
|
resource_types: topTypes,
|
|
684
|
-
storage_declarations:
|
|
685
|
-
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),
|
|
686
712
|
logging_declarations: logging,
|
|
687
713
|
identity_declarations: identity,
|
|
688
714
|
declarations_open_to_any_address: openIngress,
|
|
689
715
|
declarations_marked_public: publicAccess,
|
|
690
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,
|
|
691
720
|
};
|
|
692
721
|
|
|
693
|
-
const digest = createHash('sha256').update(canonical(facts)).digest('hex')
|
|
722
|
+
const digest = `sha256:${createHash('sha256').update(canonical(facts)).digest('hex')}`;
|
|
694
723
|
const doc = {
|
|
695
724
|
tool: 'trooth-lint',
|
|
725
|
+
schema: 'trooth-lint/2',
|
|
696
726
|
cli_version: VERSION,
|
|
697
727
|
observed_at: new Date().toISOString(),
|
|
698
|
-
root: (
|
|
728
|
+
root: rel(target),
|
|
699
729
|
facts,
|
|
700
|
-
|
|
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.',
|
|
701
735
|
note: 'Declared facts only. Read locally; nothing was transmitted. No verdict and no assessment against any standard.',
|
|
702
736
|
};
|
|
703
737
|
|
|
704
|
-
|
|
738
|
+
const code = exitFor();
|
|
739
|
+
if (asJson) { emitJson(doc); process.exit(code); }
|
|
705
740
|
|
|
706
741
|
out(`\n${J}${B}trooth lint${X} ${D}// local · offline · declarations only //${X}`);
|
|
707
742
|
out(`${D}${doc.root} ${read} declaration file(s) read${X}`);
|
|
708
|
-
|
|
709
743
|
const src = Object.entries(facts.sources).map(([k, n]) => `${k} ${n}`).join(' · ');
|
|
710
744
|
if (src) out(`${D}${src}${X}`);
|
|
711
745
|
|
|
@@ -713,7 +747,11 @@ function lint() {
|
|
|
713
747
|
const row = (label, value) => out(` ${label.padEnd(40)} ${value}`);
|
|
714
748
|
row('Regions and zones', facts.regions_and_zones_declared.length ? facts.regions_and_zones_declared.join(', ') : `${D}none declared${X}`);
|
|
715
749
|
row('Storage declarations', `${facts.storage_declarations}`);
|
|
716
|
-
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}`);
|
|
717
755
|
row('Logging declarations', `${facts.logging_declarations}`);
|
|
718
756
|
row('Identity declarations', `${facts.identity_declarations}`);
|
|
719
757
|
row('Open to any address (0.0.0.0/0, ::/0)', `${facts.declarations_open_to_any_address}`);
|
|
@@ -725,22 +763,29 @@ function lint() {
|
|
|
725
763
|
for (const t of topTypes.slice(0, 6)) out(` ${String(t.count).padStart(4)} ${D}${t.type}${X}`);
|
|
726
764
|
}
|
|
727
765
|
|
|
728
|
-
out(`\n${B}
|
|
729
|
-
out(`${D}
|
|
730
|
-
|
|
731
|
-
|
|
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}`);
|
|
732
778
|
|
|
733
779
|
out(`\n${B}How this was read${X}`);
|
|
734
|
-
out(`${D}
|
|
735
|
-
out(`
|
|
736
|
-
out(`
|
|
737
|
-
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}`);
|
|
738
783
|
|
|
739
784
|
out(`\n${D}Counts of what the files declare. Not a judgment: a public load balancer is`);
|
|
740
785
|
out(`supposed to be public. Trooth issues no verdict here and checks nothing against`);
|
|
741
786
|
out(`any standard. Nothing left this machine: lint opens files and opens no sockets.`);
|
|
742
787
|
out(`Publish what you choose on your record at ${X}${C}https://trooth.co/dashboard${X}${D}.${X}\n`);
|
|
743
|
-
process.exit(
|
|
788
|
+
process.exit(code);
|
|
744
789
|
}
|
|
745
790
|
|
|
746
791
|
/* ---------------------------------------------------------------- main ---- */
|