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/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, signed record per company: identity, products and
8
- // demos, domain and marketing links, people, documents, security and privacy posture,
9
- // procurement terms, relationships and sub-processors. Each fact is dated and labeled
10
- // with where it came from: witnessed, public record, attested or declared. This CLI is
11
- // the terminal interface to that record.
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 witnessed record from the public Trooth
15
- // Network. Read-only. No key, no account, nothing sent about you.
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 a canonical digest of them.
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; lint read at least one declaration; help/version)
29
- // 1 finding (domain not listed; lint found nothing to read)
30
- // 2 usage error (missing argument, unknown flag or command, unreadable path)
31
- // 3 network/upstream error (Trooth unreachable, non-2xx, malformed response)
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.4.4';
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 witnessed record on the Trooth Network
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 witnessed record${X}
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 + print a canonical digest${X}
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 finding (not listed / nothing declared) 2 usage error 3 Trooth unreachable
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
- /** One request to the API. Exits 3 when the API cannot be reached; any HTTP
164
- * status comes back to the caller. */
165
- async function requestTrooth(path, init, what) {
166
- try {
167
- return await fetch(`${API}${path}`, {
168
- ...init,
169
- headers: { accept: 'application/json', 'user-agent': `trooth-cli/${VERSION}`, ...(init && init.headers) },
170
- });
171
- } catch (e) {
172
- fail(EXIT.UPSTREAM, `could not reach ${what} at ${API}: ${e && e.message ? e.message : e}`);
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
- async function failHttp(res, what) {
177
- const body = await res.text().catch(() => '');
178
- fail(EXIT.UPSTREAM, `${what} returned HTTP ${res.status}.${body ? ' ' + body.slice(0, 300).replace(/\s+/g, ' ') : ''}`, { http_status: res.status });
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
- async function callTrooth(path, init, what) {
182
- const res = await requestTrooth(path, init, what);
183
- if (!res.ok) await failHttp(res, what);
184
- try { return await res.json(); }
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
- if (!input) return '';
192
- let s = String(input).trim().toLowerCase();
193
- s = s.replace(/^[a-z]+:\/\//, '');
194
- s = s.replace(/\/.*$/, '');
195
- s = s.replace(/^www\./, '');
196
- s = s.replace(/\.$/, '');
197
- return s;
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 String(iso);
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
- if (typeof obj.passed !== 'number' || typeof obj.total !== 'number') return null;
210
- return { passed: obj.passed, total: obj.total };
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; 64 as expected"). */
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: String(e.type), at: e.at, detail: e.detail }))
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: true,
251
- company_name: v.company_name || domain,
252
- witnessed_at: v.passed_at || null,
253
- first_published_at: v.first_published_at || null,
254
- badge_id: v.badge_id || null,
255
- probes: count(v.probes),
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 || null,
259
- authority_key_id: v.authority_key_id || null,
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 = String(v.category);
264
- if (v.description) rec.description = String(v.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
- /** The whole list, searched here. This is how every release up to 0.4.3 read a
269
- * company, and it is now only the fallback in readVendor below. */
270
- async function readVendorFromList(domain, what) {
271
- const data = await callTrooth('/directory/api/vendors', { method: 'GET' }, what);
272
- const vendors = Array.isArray(data && data.vendors) ? data.vendors : [];
273
- return vendors.find((v) => v && normalizeDomain(v.domain) === domain) || null;
274
- }
275
-
276
- /** One company's record from the directory feed, or null when the feed carries
277
- * no record for that domain. It asks for that one record
278
- * (/directory/api/vendors/<domain>), which returns the same object the list
279
- * carries for that domain, and does not download every company to find it. */
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 what = 'the Trooth Network';
282
- const res = await requestTrooth(`/directory/api/vendors/${encodeURIComponent(domain)}`, { method: 'GET' }, what);
283
-
284
- if (res.status === 404) {
285
- const text = await res.text().catch(() => '');
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
- // The route answered and has no record for this domain. It says so with a
289
- // JSON body carrying `listed: false`.
290
- if (body && typeof body === 'object' && body.listed === false) return null;
291
- // FALLBACK TO THE LIST. Any other 404 comes from a deploy of the directory
292
- // worker that predates the single-record route: it answers an unknown path
293
- // with a plain-text "Not found". Read the list and search it here, as 0.4.3
294
- // does, so this release keeps working against a worker that has not been
295
- // redeployed yet, and against a TROOTH_API that points at an older one.
296
- // This fallback can go in the first release after api.trooth.co serves the
297
- // route, that is, once a request for a domain with no record there returns
298
- // a JSON 404 with `listed: false`.
299
- return readVendorFromList(domain, what);
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 (!res.ok) await failHttp(res, what);
302
-
303
- let v;
304
- try { v = await res.json(); }
305
- catch { fail(EXIT.UPSTREAM, `${what} returned a response that is not JSON.`); }
306
- if (!v || typeof v !== 'object' || Array.isArray(v) || normalizeDomain(v.domain) !== domain) {
307
- fail(EXIT.UPSTREAM, `${what} returned a response that is not the record for ${domain}.`);
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 v;
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 domain = normalizeDomain(positional[0]);
316
- if (!domain) fail(EXIT.USAGE, 'missing <domain>. Try: trooth check stripe.com');
411
+ const norm = normalizeDomain(positional[0]);
412
+ if (norm.error) fail(EXIT.USAGE, norm.error);
413
+ const domain = norm.domain;
317
414
 
318
- const vendor = await readVendor(domain);
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 (!vendor) {
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 signed, dated record that anyone, or any agent, can read.${X}\n`);
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 rec = projectRecord(vendor, domain);
335
- if (asJson) { emitJson(rec); process.exit(EXIT.OK); }
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 · signed · read-only //${X}`);
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: ${J}listed and witnessed${X}` +
342
- (when ? ` ${D}last witnessed ${when}${X}` : ` ${D}date not published${X}`) +
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: an
346
- // "N/M" cell reads as a bar, and a bar reads as a grade. The JSON fields keep
347
- // the feed's names (`passed`, `total`).
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}A dated, point-in-time record. Trooth issues no verdict and no single number.`);
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(EXIT.OK);
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 facts: how many storage resources declare encryption, which
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. The digest at the end is a SHA-256 over the `facts` object in
387
- * canonical form (the timestamp, path and CLI version are outside it), so the
388
- * same tree read by the same CLI version always produces the same digest and
389
- * you can record it as evidence that a given state was observed, without
390
- * publishing the tree it came from.
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
- * HOW EACH SOURCE IS READ. It is a pattern reader, not a Terraform evaluator:
393
- * variables, modules and for_each are never resolved.
394
- * .tf regular expressions, split into top-level resource blocks
395
- * .tf.json parsed as JSON; each resource is one unit
396
- * plan JSON parsed as JSON (`terraform show -json`); each planned
397
- * managed resource is one unit
398
- * Kubernetes YAML regular expressions, one unit per YAML document,
399
- * classified by its top-level `kind`
400
- * Dockerfile read for regions, open addresses, public markers and
401
- * credential literals only; it declares no resource types
402
- * Storage, logging and identity are classified on a unit's resource type or
403
- * kind, never on the text around it. The human output prints this list in
404
- * short, so the limit is stated where the counts are. */
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 walk(root) {
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()) return [root];
419
- while (stack.length && found.length < MAX_FILES) {
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 }); } catch { continue; }
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 (!SKIP_DIRS.has(e.name)) stack.push(join(dir, e.name)); continue; }
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
- const n = e.name.toLowerCase();
427
- const ext = extname(n);
428
- const keep =
429
- ext === '.tf' || n.endsWith('.tf.json') ||
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
- return /^\s*apiVersion\s*:/m.test(text) && /^\s*kind\s*:/m.test(text) ? 'kubernetes' : null;
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 files = walk(target);
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 = []; // { refs, encrypted }, one per storage declaration
612
- const encryptingSettings = []; // bodies of storage settings that declare encryption
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
- if (statSync(f).size > MAX_BYTES) continue;
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
- byKind[kind]++;
625
- read++;
626
-
627
- // JSON sources are read through flattenJson so `"region": "x"` and
628
- // `"password": "..."` meet the same patterns as HCL and YAML.
629
- const isJson = extname(f).toLowerCase() === '.json';
630
- const flat = isJson ? flattenJson(text) : text;
631
- for (const m of flat.matchAll(REGION_RE)) regions.add(m[1]);
632
- for (const line of flat.split('\n')) if (SECRET_RE.test(line)) inlineCredentials++;
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
- // Each unit is classified on its own type, so an attribute in one resource
641
- // is never credited to another, and a word in a file's text is never taken
642
- // for a resource type.
643
- for (const u of unitsOf(kind, f, text)) {
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 && u.typed) resourceTypes.set(t, (resourceTypes.get(t) || 0) + 1);
646
- if (t && STORAGE_TYPE.test(t)) {
647
- if (isStorageSetting(t)) { if (declaresEncryption(u.body)) encryptingSettings.push(u.body); }
648
- else stores.push({ refs: u.refs, encrypted: declaresEncryption(u.body) });
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 (t && LOGGING_TYPE.test(t)) logging++;
651
- if (t && IDENTITY_TYPE.test(t)) identity++;
652
- if (OPEN_CIDR_RE.test(u.body)) openIngress++;
653
- if (PUBLIC_RE.test(u.body)) publicAccess++;
647
+ if (LOGGING_TYPE.test(t)) logging++;
648
+ if (IDENTITY_TYPE.test(t)) identity++;
654
649
  }
655
650
  }
656
651
 
657
- // A store declares encryption in its own body, or through a setting resource
658
- // (anywhere in the tree) that declares encryption and refers to it.
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 (!s.encrypted && s.refs.some((re) => encryptingSettings.some((b) => re.test(b)))) s.encrypted = true;
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 storage = stores.length;
663
- const storageEncrypted = stores.filter((s) => s.encrypted).length;
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 opened and none matched.${X}`);
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: storage,
685
- storage_declaring_encryption: storageEncrypted,
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: (() => { const r = relative(process.cwd(), target); return !r ? '.' : r.startsWith('..') ? target : r; })(),
728
+ root: rel(target),
699
729
  facts,
700
- digest: `sha256:${digest}`,
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
- if (asJson) { emitJson(doc); process.exit(EXIT.OK); }
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(' of those declaring encryption', `${facts.storage_declaring_encryption}`);
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}Digest${X} ${C}${doc.digest}${X}`);
729
- out(`${D}A SHA-256 over the facts above, in canonical form, with the timestamp excluded.`);
730
- out(`The same tree read by the same trooth version produces the same digest, so you can`);
731
- out(`record it as evidence that a state was observed without publishing the tree.${X}`);
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} A pattern reader, not a Terraform evaluator: variables and modules are not resolved.`);
735
- out(` .tf by pattern, one resource block at a time. .tf.json and plan JSON parsed, one`);
736
- out(` resource at a time. Kubernetes YAML by pattern, one document at a time, by kind.`);
737
- out(` Dockerfiles for regions, open addresses, public markers and credential literals only.${X}`);
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(EXIT.OK);
788
+ process.exit(code);
744
789
  }
745
790
 
746
791
  /* ---------------------------------------------------------------- main ---- */