trooth 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.3';
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,50 +179,111 @@ Trooth signs what it witnessed. It never signs on a company's behalf.${X}
160
179
 
161
180
  /* -------------------------------------------------------------- fetch ---- */
162
181
 
163
- async function callTrooth(path, init, what) {
164
- let res;
165
- try {
166
- res = await fetch(`${API}${path}`, {
167
- ...init,
168
- headers: { accept: 'application/json', 'user-agent': `trooth-cli/${VERSION}`, ...(init && init.headers) },
169
- });
170
- } catch (e) {
171
- 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);
172
206
  }
173
- if (!res.ok) {
174
- const body = await res.text().catch(() => '');
175
- fail(EXIT.UPSTREAM, `${what} returned HTTP ${res.status}.${body ? ' ' + body.slice(0, 300).replace(/\s+/g, ' ') : ''}`, { http_status: res.status });
207
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
208
+ }
209
+
210
+ /** GET one path. Returns { status, contentType, text }. Throws Upstream when the
211
+ * API cannot be reached, answers too slowly, or sends too much. */
212
+ async function getTrooth(path) {
213
+ let lastErr;
214
+ for (let attempt = 1; attempt <= 2; attempt++) {
215
+ try {
216
+ const res = await fetch(`${API}${path}`, {
217
+ method: 'GET',
218
+ redirect: 'error',
219
+ signal: AbortSignal.timeout(TIMEOUT_MS),
220
+ headers: { accept: 'application/json', 'user-agent': `trooth-cli/${VERSION}` },
221
+ });
222
+ if (RETRYABLE.has(res.status) && attempt === 1) { try { await res.body?.cancel(); } catch {} await new Promise((r) => setTimeout(r, 500)); continue; }
223
+ const text = await readBounded(res);
224
+ return { status: res.status, contentType: (res.headers.get('content-type') || '').toLowerCase(), text };
225
+ } catch (e) {
226
+ if (e instanceof Upstream) throw e;
227
+ lastErr = e;
228
+ const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError');
229
+ if (timedOut) throw new Upstream(`the Trooth Network at ${API} did not answer within ${TIMEOUT_MS} ms`);
230
+ if (attempt === 1) { await new Promise((r) => setTimeout(r, 500)); continue; }
231
+ }
176
232
  }
177
- try { return await res.json(); }
178
- catch { fail(EXIT.UPSTREAM, `${what} returned a response that is not JSON.`); }
233
+ throw new Upstream(`could not reach the Trooth Network at ${API}: ${lastErr && lastErr.message ? lastErr.message : lastErr}`);
234
+ }
235
+
236
+ function parseJsonBody(r) {
237
+ if (!/\bjson\b/.test(r.contentType)) throw new Upstream(`the Trooth Network answered HTTP ${r.status} with ${r.contentType || 'no content type'}, not JSON`, { http_status: r.status });
238
+ try { return JSON.parse(r.text); }
239
+ catch { throw new Upstream(`the Trooth Network answered HTTP ${r.status} with a body that is not valid JSON`, { http_status: r.status }); }
179
240
  }
180
241
 
181
242
  /* --------------------------------------------------------------- check ---- */
182
243
 
244
+ /**
245
+ * The domain a user typed, as the one form the Trooth Network keys records by.
246
+ * Accepts a bare domain or a URL. Parsed with the WHATWG URL parser, so case,
247
+ * a trailing dot, the default port (80 or 443), a path, a query and an
248
+ * internationalized name (to its ASCII form) all normalize the same way, and a
249
+ * leading "www." is dropped. Returns { domain } or { error } for input that is
250
+ * not one domain: credentials in a URL, a non-default port, an IP address, a
251
+ * scheme other than http or https, or a name with no dot.
252
+ */
183
253
  function normalizeDomain(input) {
184
- if (!input) return '';
185
- let s = String(input).trim().toLowerCase();
186
- s = s.replace(/^[a-z]+:\/\//, '');
187
- s = s.replace(/\/.*$/, '');
188
- s = s.replace(/^www\./, '');
189
- s = s.replace(/\.$/, '');
190
- 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 };
191
269
  }
270
+ const sameDomain = (a, b) => { const x = normalizeDomain(a); return !!x.domain && x.domain === b; };
192
271
 
193
272
  function fmtDate(iso) {
194
273
  if (!iso) return '';
195
274
  const d = new Date(iso);
196
- if (isNaN(d)) return String(iso);
275
+ if (isNaN(d)) return '';
197
276
  return d.toISOString().slice(0, 10);
198
277
  }
199
278
 
200
279
  function count(obj) {
201
280
  if (!obj || typeof obj !== 'object') return null;
202
- if (typeof obj.passed !== 'number' || typeof obj.total !== 'number') return null;
203
- 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 };
204
284
  }
205
285
 
206
- /** 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"). */
207
287
  function countLines(rec) {
208
288
  const lines = [];
209
289
  if (rec.probes) lines.push(`${B}Live probes:${X} ${rec.probes.total} read; ${rec.probes.passed} as expected`);
@@ -234,68 +314,139 @@ function eventLabel(type) {
234
314
  return Object.prototype.hasOwnProperty.call(EVENT_LABELS, type) ? EVENT_LABELS[type] : type.replace(/_/g, ' ');
235
315
  }
236
316
 
317
+ /**
318
+ * THE EVIDENCE STATE, decided from the record's own fields and never from a
319
+ * matching name. A record that is listed is not therefore witnessed.
320
+ * listed_witnessed a dated reading with at least one live probe read
321
+ * listed_not_witnessed the record says so, or its reading read no probe
322
+ * listed_evidence_unknown listed, with no reading this CLI can interpret
323
+ * revoked the record says it was revoked or withdrawn
324
+ * An explicit state in the record (`standing` or `evidence_state`) wins over
325
+ * anything inferred from counts, and can only lower the state, never raise it.
326
+ */
327
+ const STATE = Object.freeze({
328
+ NOT_LISTED: 'not_listed',
329
+ WITNESSED: 'listed_witnessed',
330
+ NOT_WITNESSED: 'listed_not_witnessed',
331
+ UNKNOWN: 'listed_evidence_unknown',
332
+ REVOKED: 'revoked',
333
+ });
334
+ function evidenceState(v, probes) {
335
+ const explicit = String(v.evidence_state ?? v.standing ?? v.state ?? '').toLowerCase();
336
+ if (/revoked|withdrawn/.test(explicit)) return STATE.REVOKED;
337
+ if (/not[_ -]?witnessed|unwitnessed|none/.test(explicit)) return STATE.NOT_WITNESSED;
338
+ const dated = !!fmtDate(v.passed_at);
339
+ if (probes && probes.total === 0) return STATE.NOT_WITNESSED;
340
+ if (probes && probes.total > 0 && dated) return STATE.WITNESSED;
341
+ return STATE.UNKNOWN;
342
+ }
343
+
344
+ /** A record, checked field by field. Throws Upstream when the body is not a
345
+ * directory record for this domain. */
237
346
  function projectRecord(v, domain) {
347
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Upstream('the Trooth Network answered with something that is not a record');
348
+ if (typeof v.error === 'string' && !v.domain) throw new Upstream(`the Trooth Network answered with an error: ${v.error.slice(0, 200)}`);
349
+ if (typeof v.domain !== 'string' || !sameDomain(v.domain, domain)) throw new Upstream(`the Trooth Network answered with a record that is not the record for ${domain}`);
238
350
  const events = Array.isArray(v.events)
239
- ? v.events.filter((e) => e && e.type).map((e) => ({ type: 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 }))
240
352
  : [];
353
+ const probes = count(v.probes);
354
+ const state = evidenceState(v, probes);
241
355
  const rec = {
242
356
  domain,
243
- listed: true,
244
- company_name: v.company_name || domain,
245
- witnessed_at: v.passed_at || null,
246
- first_published_at: v.first_published_at || null,
247
- badge_id: v.badge_id || null,
248
- 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,
249
364
  attested: count(v.attested),
250
365
  events,
251
- receipt_signature: v.receipt_signature || null,
252
- 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,
253
369
  verify_keys: `${API}/public/keys`,
370
+ verify_how: 'https://trooth.co/docs/verifiable-evidence',
254
371
  record_url: `https://trooth.co/network/${encodeURIComponent(domain)}`,
255
372
  };
256
- if (v.category) rec.category = String(v.category);
257
- 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;
258
375
  return scrub(rec);
259
376
  }
260
377
 
378
+ /**
379
+ * One company's record, from /directory/api/vendors/<domain>. That route
380
+ * answers a domain it does not carry with a JSON 404 whose body says
381
+ * `listed: false`; that is the only answer this CLI reads as "not listed".
382
+ * Any other 404, any other status and any body that is not the record is a
383
+ * service or contract error (exit 3), never a statement about the company.
384
+ * 0.4.4 fell back to downloading the whole list on a plain-text 404; the
385
+ * route has been served since 2026-09-26, and the fallback is gone.
386
+ */
387
+ async function readVendor(domain) {
388
+ const r = await getTrooth(`/directory/api/vendors/${encodeURIComponent(domain)}`);
389
+ if (r.status === 404) {
390
+ let body = null;
391
+ try { body = JSON.parse(r.text); } catch {}
392
+ if (body && typeof body === 'object' && body.listed === false && /\bjson\b/.test(r.contentType)) return null;
393
+ throw new Upstream('the Trooth Network answered 404 without saying whether the domain is listed; this is a service error, not an answer about the company', { http_status: 404 });
394
+ }
395
+ if (r.status < 200 || r.status > 299) {
396
+ throw new Upstream(`the Trooth Network answered HTTP ${r.status}.${r.text ? ' ' + r.text.slice(0, 200).replace(/\s+/g, ' ') : ''}`, { http_status: r.status });
397
+ }
398
+ return parseJsonBody(r);
399
+ }
400
+
401
+ const STATE_TEXT = {
402
+ [STATE.WITNESSED]: `${J}listed; Trooth witnessed a reading${X}`,
403
+ [STATE.NOT_WITNESSED]: `${A}listed; no reading witnessed${X}`,
404
+ [STATE.UNKNOWN]: `${A}listed; the reading could not be read from this record${X}`,
405
+ [STATE.REVOKED]: `${A}revoked${X}`,
406
+ };
407
+
261
408
  async function check() {
262
409
  const { positional } = parseArgs('check');
263
410
  if (positional.length > 1) fail(EXIT.USAGE, `check takes one <domain>, got: ${positional.join(' ')}`);
264
- const domain = normalizeDomain(positional[0]);
265
- 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;
266
414
 
267
- const data = await callTrooth('/directory/api/vendors', { method: 'GET' }, 'the Trooth Network');
268
- const vendors = Array.isArray(data && data.vendors) ? data.vendors : [];
269
- const vendor = vendors.find((v) => v && normalizeDomain(v.domain) === domain) || null;
415
+ let vendor, rec;
416
+ try {
417
+ vendor = await readVendor(domain);
418
+ rec = vendor ? projectRecord(vendor, domain) : null;
419
+ } catch (e) {
420
+ if (e instanceof Upstream) fail(EXIT.UPSTREAM, e.message, { state: 'service_error', ...e.extra });
421
+ throw e;
422
+ }
270
423
 
271
- if (!vendor) {
424
+ if (!rec) {
272
425
  if (asJson) {
273
- emitJson({ domain, listed: false, record_url: `https://trooth.co/network/${encodeURIComponent(domain)}` });
426
+ emitJson({ domain, listed: false, state: STATE.NOT_LISTED, record_url: `https://trooth.co/network/${encodeURIComponent(domain)}` });
274
427
  } else {
275
428
  out(`\n${B}${domain}${X} ${D}//${X} ${A}not listed in the Trooth Network's public feed${X}`);
276
429
  out(`\n${D}The public feed carries no record for this domain. That says nothing about the`);
277
430
  out(`company: a domain that never listed, a listing Trooth has not published, and a`);
278
431
  out(`record that was revoked all read this way. A company gets a record by listing at`);
279
432
  out(`${X}${C}https://trooth.co/get-started${X}${D}: Trooth reads its public surface and publishes`);
280
- out(`a 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`);
281
434
  }
282
435
  process.exit(EXIT.FINDING);
283
436
  }
284
437
 
285
- const rec = projectRecord(vendor, domain);
286
- 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); }
287
440
 
288
441
  const when = fmtDate(rec.witnessed_at);
289
442
  const since = fmtDate(rec.first_published_at);
290
- out(`\n${J}${B}Trooth Network${X} ${D}// public · signed · read-only //${X}`);
443
+ out(`\n${J}${B}Trooth Network${X} ${D}// public record · read-only //${X}`);
291
444
  out(`${B}${rec.company_name}${X} ${C}${domain}${X}`);
292
- out(`Listing state: ${J}listed and witnessed${X}` +
293
- (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}` : '') +
294
447
  (since ? ` ${D}first published ${since}${X}` : ''));
295
448
 
296
- // Two counts, never a ratio, in the form the website's record page uses: an
297
- // "N/M" cell reads as a bar, and a bar reads as a grade. The JSON fields keep
298
- // the feed's names (`passed`, `total`).
449
+ // Two counts, never a ratio, in the form the website's record page uses.
299
450
  const counts = countLines(rec);
300
451
  if (counts.length) {
301
452
  out('');
@@ -313,20 +464,22 @@ async function check() {
313
464
  const latest = latestEvents(rec.events, 3);
314
465
  if (latest.length) {
315
466
  out(`\n${B}Latest ledger events, newest first${X}`);
316
- for (const e of latest) out(` ${J}•${X} ${D}${fmtDate(e.at)}${X} ${eventLabel(e.type)}`);
467
+ for (const e of latest) out(` ${J}•${X} ${D}${fmtDate(e.at) || 'undated'}${X} ${eventLabel(e.type)}`);
317
468
  out(`${D} --json carries the whole ledger, with the feed's own wording for each event.${X}`);
318
469
  }
319
470
 
320
- out(`\n${D}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.`);
321
474
  out(`Full record: ${X}${C}${rec.record_url}${X}${D} · Signing keys: ${rec.verify_keys}${X}\n`);
322
- process.exit(EXIT.OK);
475
+ process.exit(code);
323
476
  }
324
477
 
325
478
  /* ---------------------------------------------------------------- lint ---- */
326
479
  /* WHAT lint IS, AND WHAT IT IS CAREFULLY NOT.
327
480
  *
328
481
  * It reads the infrastructure a repository DECLARES and reports those
329
- * declarations as facts: how many storage resources declare encryption, which
482
+ * declarations as counts: how many storage resources declare encryption, which
330
483
  * regions appear, how many rules declare exposure to the whole internet. It
331
484
  * does not judge them. There is no verdict, no threshold, no severity and no
332
485
  * rating, and nothing is checked against a named standard or regulation.
@@ -334,203 +487,78 @@ async function check() {
334
487
  * public. What the facts mean is the reader's decision.
335
488
  *
336
489
  * It opens files and opens no sockets. Nothing about the repository leaves the
337
- * machine. The digest at the end is a SHA-256 over the `facts` object in
338
- * canonical form (the timestamp, path and CLI version are outside it), so the
339
- * same tree read by the same CLI version always produces the same digest and
340
- * you can record it as evidence that a given state was observed, without
341
- * 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.
342
502
  *
343
- * HOW EACH SOURCE IS READ. It is a pattern reader, not a Terraform evaluator:
344
- * variables, modules and for_each are never resolved.
345
- * .tf regular expressions, split into top-level resource blocks
346
- * .tf.json parsed as JSON; each resource is one unit
347
- * plan JSON parsed as JSON (`terraform show -json`); each planned
348
- * managed resource is one unit
349
- * Kubernetes YAML regular expressions, one unit per YAML document,
350
- * classified by its top-level `kind`
351
- * Dockerfile read for regions, open addresses, public markers and
352
- * credential literals only; it declares no resource types
353
- * Storage, logging and identity are classified on a unit's resource type or
354
- * kind, never on the text around it. The human output prints this list in
355
- * short, so the limit is stated where the counts are. */
503
+ * COMPLETENESS. Every file the walk selects ends in exactly one bucket: read,
504
+ * not applicable (a JSON or YAML file that is not a plan or a manifest),
505
+ * excluded by a stated rule (a templated manifest), skipped (over the size
506
+ * limit), invalid (did not parse) or unreadable (a permission or I/O error).
507
+ * The walk itself can be truncated at MAX_FILES. A read with anything skipped,
508
+ * invalid, unreadable or truncated is INCOMPLETE and exits 4 unless the caller
509
+ * passes --allow-incomplete. */
356
510
 
357
511
  const SKIP_DIRS = new Set([
358
512
  'node_modules', '.git', '.terraform', '.next', 'dist', 'build', 'vendor',
359
513
  '.venv', 'venv', '__pycache__', '.cache', 'coverage', '.turbo',
360
514
  ]);
361
- const MAX_FILES = 5000;
515
+ const MAX_FILES = Number(process.env.TROOTH_LINT_MAX_FILES) > 0 ? Number(process.env.TROOTH_LINT_MAX_FILES) : 5000;
362
516
  const MAX_BYTES = 4 * 1024 * 1024;
517
+ const LIST_CAP = 50;
363
518
 
364
- function 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) {
365
528
  const found = [];
366
- const stack = [root];
367
529
  let st;
368
- try { st = statSync(root); } catch { return found; }
369
- if (st.isFile()) return [root];
370
- 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) {
371
534
  const dir = stack.pop();
372
535
  let entries;
373
- 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 = [];
374
540
  for (const e of entries) {
375
- 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; }
376
542
  if (!e.isFile()) continue;
377
- const n = e.name.toLowerCase();
378
- const ext = extname(n);
379
- const keep =
380
- ext === '.tf' || n.endsWith('.tf.json') ||
381
- ext === '.yaml' || ext === '.yml' || ext === '.json' ||
382
- n === 'dockerfile' || n.startsWith('dockerfile.');
383
- if (keep) found.push(join(dir, e.name));
384
- if (found.length >= MAX_FILES) break;
543
+ cov.discovered++;
544
+ if (!selectedName(e.name.toLowerCase())) continue;
545
+ if (found.length >= MAX_FILES) { cov.truncated = true; return found; }
546
+ found.push(join(dir, e.name));
385
547
  }
548
+ for (let k = subdirs.length - 1; k >= 0; k--) stack.push(subdirs[k]);
386
549
  }
387
550
  return found;
388
551
  }
389
552
 
390
- const REGION_RE = /\b(?:region|location|availability_zone|aws_region)\s*[:=]\s*["']([A-Za-z0-9][A-Za-z0-9._-]{2,40})["']/g;
391
- const RESOURCE_RE = /\bresource\s+"([a-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/g;
392
- const ENCRYPT_RE = /(?:encrypted|encryption|kms_key|sse_algorithm|server_side_encryption|encrypt_at_rest)/i;
393
- // Classification runs on the RESOURCE TYPE TOKEN (aws_db_instance, not the body
394
- // text), matched as a substring. An earlier version wrapped these in \b, which
395
- // never fires inside a snake_case identifier: \bdynamodb\b cannot match
396
- // aws_dynamodb_table because the underscore either side is a word character, so
397
- // every storage resource whose name was not the bare word "bucket" went
398
- // uncounted. Substring matching on the type is both simpler and correct.
399
- const STORAGE_TYPE = /(bucket|storage|blob|disk|volume|filestore|_db|database|rds|dynamodb|_sql|redis|memcache|elasticache|efs|fileshare|cosmos|bigtable|spanner)/i;
400
- const LOGGING_TYPE = /(log|trail|audit|monitor|insight|diagnostic)/i;
401
- const IDENTITY_TYPE = /(iam|role|policy|service_account|serviceaccount|rbac|identity|access_key|keyring|secret)/i;
402
- const OPEN_CIDR_RE = /(?:"0\.0\.0\.0\/0"|'0\.0\.0\.0\/0'|"::\/0"|'::\/0')/;
403
- const PUBLIC_RE = /\b(?:publicly_accessible\s*[:=]\s*true|acl\s*[:=]\s*["']public-read|public_network_access_enabled\s*[:=]\s*true|type\s*:\s*LoadBalancer|type\s*:\s*NodePort)\b/;
404
- // A literal that looks like a credential sitting in the file. Reported as a
405
- // count only: no file name, no line, and never the value itself.
406
- const SECRET_RE = /\b(?:password|secret|api[_-]?key|access[_-]?key|token|private[_-]?key)\s*[:=]\s*["'][^"'${}\n]{8,}["']/i;
407
- // A storage-matching Terraform type that names a SETTING on a store rather than
408
- // a store: aws_s3_bucket_server_side_encryption_configuration, a bucket policy,
409
- // a volume attachment, a subnet group. The substring match above catches these
410
- // (the fixture's one bucket used to count as two storage declarations), so they
411
- // are not counted as storage. A setting that declares encryption and references
412
- // a store credits that store with declaring encryption. Applied to snake_case
413
- // Terraform types only; Kubernetes kinds are CamelCase and are not settings.
414
- const STORAGE_SETTING = /_(?:configuration|policy|acl|versioning|notification|public_access_block|ownership_controls|attachment|object|item|logging|iam_member|iam_binding|access_point|mount_target|snapshot|subnet_group|parameter_group|option_group)$/;
415
- const isStorageSetting = (type) => type.includes('_') && STORAGE_SETTING.test(type);
416
- // An attribute set to false, null or empty declares nothing: `encrypted = false`
417
- // and `storage_encrypted: false` must not count as declaring encryption.
418
- const NEGATIVE_ATTR_RE = /^[^\n:=]*[:=]\s*(?:false|null|"false"|'false'|""|''|\[\]|\{\})\s*,?\s*$/gim;
419
- const declaresEncryption = (body) => ENCRYPT_RE.test(body.replace(NEGATIVE_ATTR_RE, ''));
420
-
421
- /** JSON text with `"key":` rewritten as `key:`, so the attribute patterns above
422
- * (written for `key = "v"` and `key: "v"`) read JSON sources too. Escaped
423
- * quotes inside string values are never rewritten. */
424
- function flattenJson(text) {
425
- return text.replace(/"([A-Za-z_][\w.-]*)"\s*:/g, '$1:');
426
- }
427
-
428
- /** A parsed JSON value with false, null, empty strings, empty arrays and empty
429
- * objects removed, so a plan's unset attributes read as absent. */
430
- function prune(v) {
431
- if (Array.isArray(v)) { const a = v.map(prune).filter((x) => x !== undefined); return a.length ? a : undefined; }
432
- if (v && typeof v === 'object') {
433
- const o = {};
434
- for (const [k, x] of Object.entries(v)) { const y = prune(x); if (y !== undefined) o[k] = y; }
435
- return Object.keys(o).length ? o : undefined;
436
- }
437
- return v === null || v === false || v === '' ? undefined : v;
438
- }
439
-
440
- const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
441
- /** A pattern that finds a Terraform address (aws_s3_bucket.logs) in another
442
- * resource's body, as `aws_s3_bucket.logs.id` or `${aws_s3_bucket.logs.arn}`,
443
- * and not inside a longer address such as aws_s3_bucket.logs2. */
444
- const addressRef = (address) => new RegExp(`(?:^|[^\\w.])${escRe(address)}(?![\\w-])`);
445
-
446
- /** Every resource under `resource` in a .tf.json document, in either of the
447
- * shapes Terraform's JSON syntax allows (objects, or arrays of objects). */
448
- function tfJsonResources(doc) {
449
- const out = [];
450
- const each = (v, fn) => { if (Array.isArray(v)) v.forEach((x) => each(x, fn)); else if (v && typeof v === 'object') fn(v); };
451
- each(doc, (top) => each(top.resource, (byType) => {
452
- for (const [type, byName] of Object.entries(byType)) {
453
- if (!/^[a-z0-9_]+$/.test(type)) continue;
454
- each(byName, (names) => { for (const [name, body] of Object.entries(names)) out.push({ type, name, body }); });
455
- }
456
- }));
457
- return out;
458
- }
459
-
460
- /** The managed resources a plan (`terraform show -json`) says will exist:
461
- * planned_values, every module deep, or resource_changes when a plan has no
462
- * planned_values. Data sources and deletions are not declarations. */
463
- function planResources(doc) {
464
- const out = [];
465
- const walkModule = (m) => {
466
- if (!m || typeof m !== 'object') return;
467
- for (const r of Array.isArray(m.resources) ? m.resources : []) {
468
- if (r && r.mode !== 'data' && typeof r.type === 'string') out.push({ type: r.type, name: String(r.name ?? ''), body: r.values ?? {} });
469
- }
470
- for (const c of Array.isArray(m.child_modules) ? m.child_modules : []) walkModule(c);
471
- };
472
- if (doc && doc.planned_values && doc.planned_values.root_module) walkModule(doc.planned_values.root_module);
473
- else {
474
- for (const rc of Array.isArray(doc && doc.resource_changes) ? doc.resource_changes : []) {
475
- const after = rc && rc.change ? rc.change.after : null;
476
- if (rc && rc.mode !== 'data' && typeof rc.type === 'string' && after) out.push({ type: rc.type, name: String(rc.name ?? ''), body: after });
477
- }
478
- }
479
- return out;
480
- }
481
-
482
- /** Split one declaration file into the units lint classifies. Each unit has a
483
- * `type` (a resource type or Kubernetes kind, or null when there is none),
484
- * `refs` (patterns another unit's body would contain to refer to this one),
485
- * `body` (the text attribute patterns run on) and `typed` (whether the type
486
- * belongs in the resource type list). */
487
- function unitsOf(kind, file, text) {
488
- if (kind === 'terraform' && !basename(file).toLowerCase().endsWith('.tf.json')) {
489
- return text.split(/\n(?=resource\s+")/).map((b) => {
490
- const h = b.match(/^resource\s+"([a-z0-9_]+)"\s+"([A-Za-z0-9_-]+)"/);
491
- return h ? { type: h[1], refs: [addressRef(`${h[1]}.${h[2]}`)], body: b, typed: false }
492
- : { type: null, refs: [], body: b, typed: false };
493
- });
494
- }
495
- if (kind === 'kubernetes') {
496
- return text.split(/^---[^\n]*$/m).map((d) => {
497
- const k = d.match(/^kind\s*:\s*["']?([A-Za-z][A-Za-z0-9]*)/m);
498
- return { type: k ? k[1] : null, refs: [], body: d, typed: false };
499
- });
500
- }
501
- if (kind === 'container') return [{ type: null, refs: [], body: text, typed: false }];
502
-
503
- // .tf.json and plan JSON.
504
- let doc;
505
- try { doc = JSON.parse(text); } catch { return [{ type: null, refs: [], body: flattenJson(text), typed: false }]; }
506
- const asBody = (v) => flattenJson(JSON.stringify(prune(v) ?? {}, null, 1));
507
- if (kind === 'terraform-plan') {
508
- return planResources(doc).map((r) => {
509
- const v = prune(r.body) || {};
510
- // A plan carries values, not expressions, so a setting names its store
511
- // by the store's own bucket name or id.
512
- const refs = ['bucket', 'id'].map((k) => v[k]).filter((x) => typeof x === 'string' && x.length >= 3)
513
- .map((x) => new RegExp(escRe(JSON.stringify(x))));
514
- return { type: r.type, refs, body: asBody(r.body), typed: true };
515
- });
516
- }
517
- const units = tfJsonResources(doc).map((r) => ({ type: r.type, refs: [addressRef(`${r.type}.${r.name}`)], body: asBody(r.body), typed: true }));
518
- // Everything outside `resource` (providers, variables, locals) is one untyped
519
- // unit, the way the text before the first resource block is in a .tf file.
520
- if (doc && typeof doc === 'object' && !Array.isArray(doc)) {
521
- const rest = { ...doc }; delete rest.resource;
522
- units.push({ type: null, refs: [], body: asBody(rest), typed: false });
523
- }
524
- return units;
525
- }
526
-
527
553
  function classify(file, text) {
528
554
  const n = basename(file).toLowerCase();
555
+ if (n.endsWith('.tf.json')) return 'terraform-json';
529
556
  if (n.endsWith('.tf')) return 'terraform';
530
- if (n.endsWith('.tf.json')) return 'terraform';
531
557
  if (n === 'dockerfile' || n.startsWith('dockerfile.')) return 'container';
532
558
  if (n.endsWith('.yaml') || n.endsWith('.yml')) {
533
- 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';
534
562
  }
535
563
  if (n.endsWith('.json')) {
536
564
  return /"terraform_version"\s*:/.test(text) &&
@@ -549,76 +577,119 @@ function canonical(v) {
549
577
  return JSON.stringify(v === undefined ? null : v);
550
578
  }
551
579
 
580
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
581
+ /** A Terraform address (aws_s3_bucket.logs) inside another resource's values,
582
+ * and not inside a longer address such as aws_s3_bucket.logs2. */
583
+ const addressRef = (address) => new RegExp(`(?:^|[^\\w.])${escRe(address)}(?![\\w-])`);
584
+
585
+ // Classification runs on the resource type or kind, never on the text around it.
586
+ const STORAGE_TYPE = /(bucket|storage|blob|disk|volume|filestore|_db|database|rds|dynamodb|_sql|redis|memcache|elasticache|efs|fileshare|cosmos|bigtable|spanner)/i;
587
+ const LOGGING_TYPE = /(log|trail|audit|monitor|insight|diagnostic)/i;
588
+ const IDENTITY_TYPE = /(iam|role|policy|service_account|serviceaccount|rbac|identity|access_key|keyring|secret)/i;
589
+ // A storage-matching Terraform type that names a SETTING on a store rather than
590
+ // a store. A setting that declares encryption and refers to a store credits it.
591
+ const STORAGE_SETTING = /_(?:configuration|policy|acl|versioning|notification|public_access_block|ownership_controls|attachment|object|item|logging|iam_member|iam_binding|access_point|mount_target|snapshot|subnet_group|parameter_group|option_group)$/;
592
+ const isStorageSetting = (type) => type.includes('_') && STORAGE_SETTING.test(type);
593
+ const rel = (p) => { const r = relative(process.cwd(), p); return !r ? '.' : r.startsWith('..') ? p : r; };
594
+
552
595
  function lint() {
553
- const { positional } = parseArgs('lint');
596
+ const { flags, positional } = parseArgs('lint');
554
597
  if (positional.length > 1) fail(EXIT.USAGE, `lint takes one optional [path], got: ${positional.join(' ')}`);
555
598
  const target = positional[0] || '.';
556
599
  if (!existsSync(target)) fail(EXIT.USAGE, `path not found: ${target}`);
557
600
 
558
- const 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);
559
603
  const byKind = { terraform: 0, 'terraform-plan': 0, kubernetes: 0, container: 0 };
560
604
  const regions = new Set();
561
605
  const resourceTypes = new Map();
562
- const stores = []; // { refs, encrypted }, one per storage declaration
563
- 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
564
608
  let read = 0, logging = 0, identity = 0;
565
609
  let openIngress = 0, publicAccess = 0, inlineCredentials = 0;
566
610
 
567
611
  for (const f of files) {
568
612
  let text;
569
613
  try {
570
- 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; }
571
616
  text = readFileSync(f, 'utf8');
572
- } catch { continue; }
617
+ } catch (e) { cov.unreadable.push({ path: rel(f), reason: e.code || 'read failed' }); continue; }
573
618
  const kind = classify(f, text);
574
- if (!kind) continue;
575
- byKind[kind]++;
576
- read++;
577
-
578
- // JSON sources are read through flattenJson so `"region": "x"` and
579
- // `"password": "..."` meet the same patterns as HCL and YAML.
580
- const isJson = extname(f).toLowerCase() === '.json';
581
- const flat = isJson ? flattenJson(text) : text;
582
- for (const m of flat.matchAll(REGION_RE)) regions.add(m[1]);
583
- for (const line of flat.split('\n')) if (SECRET_RE.test(line)) inlineCredentials++;
584
-
585
- if (kind === 'terraform' && !isJson) {
586
- for (const m of text.matchAll(RESOURCE_RE)) {
587
- resourceTypes.set(m[1], (resourceTypes.get(m[1]) || 0) + 1);
588
- }
619
+ if (!kind) { cov.not_applicable++; continue; }
620
+ if (kind === 'templated') { cov.excluded.push({ path: rel(f), reason: 'a templated manifest ({{ }}); render it first to read it' }); continue; }
621
+
622
+ let units;
623
+ try { units = unitsOf(kind, text); }
624
+ catch (e) {
625
+ if (e instanceof InvalidDeclaration) { cov.invalid.push({ path: rel(f), reason: e.message.slice(0, 200) }); continue; }
626
+ throw e;
589
627
  }
628
+ byKind[kind === 'terraform-json' ? 'terraform' : kind]++;
629
+ read++;
590
630
 
591
- // Each unit is classified on its own type, so an attribute in one resource
592
- // is never credited to another, and a word in a file's text is never taken
593
- // for a resource type.
594
- 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++;
595
636
  const t = u.type;
596
- if (t && u.typed) resourceTypes.set(t, (resourceTypes.get(t) || 0) + 1);
597
- if (t && STORAGE_TYPE.test(t)) {
598
- if (isStorageSetting(t)) { if (declaresEncryption(u.body)) encryptingSettings.push(u.body); }
599
- 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
+ }
600
646
  }
601
- if (t && LOGGING_TYPE.test(t)) logging++;
602
- if (t && IDENTITY_TYPE.test(t)) identity++;
603
- if (OPEN_CIDR_RE.test(u.body)) openIngress++;
604
- if (PUBLIC_RE.test(u.body)) publicAccess++;
647
+ if (LOGGING_TYPE.test(t)) logging++;
648
+ if (IDENTITY_TYPE.test(t)) identity++;
605
649
  }
606
650
  }
607
651
 
608
- // A store declares encryption in its own body, or through a setting resource
609
- // (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.
610
654
  for (const s of stores) {
611
- 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;
612
660
  }
613
- const storage = stores.length;
614
- 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
+ };
615
686
 
616
- if (read === 0) {
687
+ if (read === 0 && !incomplete) {
617
688
  const msg = `no infrastructure declarations found under ${target}.`;
618
689
  diag(`${A}nothing to read${X} ${msg}`);
619
690
  diag(`${D} lint reads .tf, .tf.json, Kubernetes YAML (apiVersion + kind), terraform plan`);
620
- diag(` JSON and Dockerfiles. ${files.length} file(s) were opened and none matched.${X}`);
621
- if (asJson) emitJson({ ok: false, error: msg, exit: EXIT.FINDING, files_opened: files.length });
691
+ diag(` JSON and Dockerfiles. ${files.length} file(s) were selected and none held a declaration.${X}`);
692
+ if (asJson) emitJson({ ok: false, error: msg, exit: EXIT.FINDING, root: rel(target), files_opened: files.length, coverage, coverage_details: details });
622
693
  process.exit(EXIT.FINDING);
623
694
  }
624
695
 
@@ -632,31 +703,43 @@ function lint() {
632
703
  sources: Object.fromEntries(Object.entries(byKind).filter(([, n]) => n > 0)),
633
704
  regions_and_zones_declared: [...regions].sort(),
634
705
  resource_types: topTypes,
635
- storage_declarations: storage,
636
- 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),
637
712
  logging_declarations: logging,
638
713
  identity_declarations: identity,
639
714
  declarations_open_to_any_address: openIngress,
640
715
  declarations_marked_public: publicAccess,
641
716
  inline_credential_literals: inlineCredentials,
717
+ completeness: coverage.completeness,
718
+ files_selected: files.length,
719
+ files_not_read: files.length - read - cov.not_applicable - cov.excluded.length,
642
720
  };
643
721
 
644
- const digest = createHash('sha256').update(canonical(facts)).digest('hex');
722
+ const digest = `sha256:${createHash('sha256').update(canonical(facts)).digest('hex')}`;
645
723
  const doc = {
646
724
  tool: 'trooth-lint',
725
+ schema: 'trooth-lint/2',
647
726
  cli_version: VERSION,
648
727
  observed_at: new Date().toISOString(),
649
- root: (() => { const r = relative(process.cwd(), target); return !r ? '.' : r.startsWith('..') ? target : r; })(),
728
+ root: rel(target),
650
729
  facts,
651
- 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.',
652
735
  note: 'Declared facts only. Read locally; nothing was transmitted. No verdict and no assessment against any standard.',
653
736
  };
654
737
 
655
- if (asJson) { emitJson(doc); process.exit(EXIT.OK); }
738
+ const code = exitFor();
739
+ if (asJson) { emitJson(doc); process.exit(code); }
656
740
 
657
741
  out(`\n${J}${B}trooth lint${X} ${D}// local · offline · declarations only //${X}`);
658
742
  out(`${D}${doc.root} ${read} declaration file(s) read${X}`);
659
-
660
743
  const src = Object.entries(facts.sources).map(([k, n]) => `${k} ${n}`).join(' · ');
661
744
  if (src) out(`${D}${src}${X}`);
662
745
 
@@ -664,7 +747,11 @@ function lint() {
664
747
  const row = (label, value) => out(` ${label.padEnd(40)} ${value}`);
665
748
  row('Regions and zones', facts.regions_and_zones_declared.length ? facts.regions_and_zones_declared.join(', ') : `${D}none declared${X}`);
666
749
  row('Storage declarations', `${facts.storage_declarations}`);
667
- row(' 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}`);
668
755
  row('Logging declarations', `${facts.logging_declarations}`);
669
756
  row('Identity declarations', `${facts.identity_declarations}`);
670
757
  row('Open to any address (0.0.0.0/0, ::/0)', `${facts.declarations_open_to_any_address}`);
@@ -676,22 +763,29 @@ function lint() {
676
763
  for (const t of topTypes.slice(0, 6)) out(` ${String(t.count).padStart(4)} ${D}${t.type}${X}`);
677
764
  }
678
765
 
679
- out(`\n${B}Digest${X} ${C}${doc.digest}${X}`);
680
- out(`${D}A SHA-256 over the facts above, in canonical form, with the timestamp excluded.`);
681
- out(`The same tree read by the same trooth version produces the same digest, so you can`);
682
- 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}`);
683
778
 
684
779
  out(`\n${B}How this was read${X}`);
685
- out(`${D} A pattern reader, not a Terraform evaluator: variables and modules are not resolved.`);
686
- out(` .tf by pattern, one resource block at a time. .tf.json and plan JSON parsed, one`);
687
- out(` resource at a time. Kubernetes YAML by pattern, one document at a time, by kind.`);
688
- out(` Dockerfiles for regions, open addresses, public markers and credential literals only.${X}`);
780
+ out(`${D} Every file is parsed; a file that does not parse is reported as invalid, not read.`);
781
+ out(` Comments count for nothing. Nothing is evaluated: a setting that depends on a variable,`);
782
+ out(` a local, a module or a function is reported as unresolved. Dockerfiles: ENV and ARG only.${X}`);
689
783
 
690
784
  out(`\n${D}Counts of what the files declare. Not a judgment: a public load balancer is`);
691
785
  out(`supposed to be public. Trooth issues no verdict here and checks nothing against`);
692
786
  out(`any standard. Nothing left this machine: lint opens files and opens no sockets.`);
693
787
  out(`Publish what you choose on your record at ${X}${C}https://trooth.co/dashboard${X}${D}.${X}\n`);
694
- process.exit(EXIT.OK);
788
+ process.exit(code);
695
789
  }
696
790
 
697
791
  /* ---------------------------------------------------------------- main ---- */