@thejoseki/clawform 2.3.0 → 2.3.2

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.
Files changed (48) hide show
  1. package/README.md +141 -120
  2. package/bin/clawform.js +161 -151
  3. package/package.json +65 -65
  4. package/src/aws/catalog.js +116 -116
  5. package/src/aws/changeset.js +231 -231
  6. package/src/aws/drift.js +59 -59
  7. package/src/aws/exports.js +213 -213
  8. package/src/aws/inventory.js +156 -156
  9. package/src/commands/activate.js +34 -2
  10. package/src/commands/ci-init.js +393 -212
  11. package/src/commands/cost-report.js +163 -163
  12. package/src/commands/deactivate.js +9 -2
  13. package/src/commands/deploy.js +413 -413
  14. package/src/commands/diff.js +39 -39
  15. package/src/commands/drift.js +35 -35
  16. package/src/commands/init-plugin.js +6 -6
  17. package/src/commands/init.js +360 -360
  18. package/src/commands/rollback.js +126 -126
  19. package/src/commands/status.js +147 -147
  20. package/src/commands/sync.js +50 -50
  21. package/src/commands/update.js +24 -0
  22. package/src/commands/validate.js +349 -349
  23. package/src/hooks/block-dangerous.js +62 -62
  24. package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
  25. package/src/hooks/cfn-lint-after-edit.js +81 -81
  26. package/src/hooks/check-catalog-fresh-eval.js +63 -63
  27. package/src/hooks/check-catalog-fresh.js +33 -33
  28. package/src/hooks/session-context-eval.js +81 -81
  29. package/src/hooks/session-context.js +41 -41
  30. package/src/hooks/subagent-context-eval.js +44 -44
  31. package/src/hooks/subagent-context.js +48 -48
  32. package/src/lib/audit-synthesis.js +24 -24
  33. package/src/lib/aws-client.js +15 -15
  34. package/src/lib/cfn-yaml.js +92 -92
  35. package/src/lib/config.js +76 -76
  36. package/src/lib/constants.js +85 -85
  37. package/src/lib/defaults.js +16 -16
  38. package/src/lib/install-workflow.js +28 -28
  39. package/src/lib/kit-source.js +43 -5
  40. package/src/lib/license-client.js +84 -84
  41. package/src/lib/license-config.js +162 -162
  42. package/src/lib/license-store.js +43 -8
  43. package/src/lib/license.js +15 -2
  44. package/src/lib/lint-overrides.js +226 -226
  45. package/src/lib/logger.js +16 -16
  46. package/src/lib/project-config.js +145 -145
  47. package/src/lib/providers/polar.js +215 -215
  48. package/src/lib/session-state.js +91 -91
@@ -31,7 +31,11 @@ export const LICENSE_VERSION = 1;
31
31
  function licenseDir(home) { return join(home, LICENSE_DIR); }
32
32
  function licensePath(home) { return join(licenseDir(home), LICENSE_FILENAME); }
33
33
 
34
- function unlicensed() { return { v: LICENSE_VERSION, status: 'unlicensed' }; }
34
+ // `reason` lets a caller tell an ABSENT record (never activated) from a STALE
35
+ // one (an older Clawform wrote it; this version can't verify its signature) so
36
+ // the CLI can say "refresh it" instead of "your key is gone". It carries no
37
+ // grant — status is still 'unlicensed'.
38
+ function unlicensed(reason = 'absent') { return { v: LICENSE_VERSION, status: 'unlicensed', reason }; }
35
39
 
36
40
  // Fields that a signature covers. Fixed order, so the digest depends on the
37
41
  // content and not on the order the caller happened to build the object in.
@@ -66,19 +70,50 @@ export function readLicense({ homedir = osHomedir, readFileSync = fsRead } = {})
66
70
  try {
67
71
  raw = readFileSync(licensePath(homedir()), 'utf8');
68
72
  } catch {
69
- return unlicensed();
73
+ return unlicensed('absent');
70
74
  }
71
75
  try {
72
76
  const parsed = JSON.parse(raw);
73
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return unlicensed();
74
- if (parsed.v !== LICENSE_VERSION || typeof parsed.status !== 'string') return unlicensed();
77
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return unlicensed('corrupt');
78
+ if (parsed.v !== LICENSE_VERSION || typeof parsed.status !== 'string') {
79
+ // A parseable record that still LOOKS like a licence but was written under
80
+ // a different schema version is STALE, not junk — the caller can offer a
81
+ // cheap refresh rather than "your key is gone".
82
+ return (parsed.status === 'active' && parsed.key) ? unlicensed('stale') : unlicensed('corrupt');
83
+ }
75
84
  // An unsigned or altered 'active' record is not a licence — it is exactly
76
- // the forgery the signature exists to catch. 'unlicensed' claims nothing,
77
- // so it needs no proof.
78
- if (parsed.status === 'active' && !signatureValid(parsed)) return unlicensed();
85
+ // the forgery the signature exists to catch. But a record we WROTE whose
86
+ // signed field-set later changed (e.g. a new field was added to the schema)
87
+ // also fails here; both surface as 'stale' so re-activation, not accusation,
88
+ // is offered. 'unlicensed' claims nothing, so this grants nothing.
89
+ if (parsed.status === 'active' && !signatureValid(parsed)) return unlicensed('stale');
79
90
  return parsed;
80
91
  } catch {
81
- return unlicensed();
92
+ return unlicensed('corrupt');
93
+ }
94
+ }
95
+
96
+ // Parse the record WITHOUT verifying the signature. RECOVERY ONLY: callers use
97
+ // it to recover `instanceId`/`key` so an activation can be re-adopted or an
98
+ // orphaned slot released. It grants NOTHING — an 'active' status here is not
99
+ // proof of a licence (only a server-side `validate` is), and the worst a forged
100
+ // record achieves through this path is deactivating an activation, never a free
101
+ // entitlement. That asymmetry is why bypassing the signature is safe here and
102
+ // never in `readLicense`.
103
+ export function readLicenseRaw({ homedir = osHomedir, readFileSync = fsRead } = {}) {
104
+ let raw;
105
+ try {
106
+ raw = readFileSync(licensePath(homedir()), 'utf8');
107
+ } catch {
108
+ return unlicensed('absent');
109
+ }
110
+ try {
111
+ const parsed = JSON.parse(raw);
112
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return unlicensed('corrupt');
113
+ if (parsed.status === 'active' && parsed.key) return parsed;
114
+ return unlicensed('absent');
115
+ } catch {
116
+ return unlicensed('corrupt');
82
117
  }
83
118
  }
84
119
 
@@ -67,12 +67,25 @@ export async function assertLicensed({
67
67
 
68
68
  const record = readLicense();
69
69
  if (record.status !== 'active' || !record.key) {
70
+ if (record.reason === 'stale') {
71
+ // The record is on disk and looks like a licence, but an older Clawform
72
+ // wrote it and this version can't verify it. Saying "needs a license"
73
+ // reads as "my key is gone" — the exact panic that filed this bug. Point
74
+ // at the cheap fix: activate re-adopts the existing slot when it is still
75
+ // valid, so no extra seat is spent.
76
+ throw new Error(
77
+ `'clawform ${command}' can't run: this machine has a Clawform license record an older version ` +
78
+ `wrote, which this version cannot read. Run: clawform activate <key> — it re-adopts this machine's ` +
79
+ `existing activation when that is still valid, so it will not use another seat. ` +
80
+ `(Your key is in your trial or purchase email.)`,
81
+ );
82
+ }
70
83
  throw new Error(
71
84
  // The one message every npm installer without a key will read — the only
72
85
  // audience the product reliably has. It leads with the door that costs
73
86
  // nothing, then the one for a key already bought.
74
87
  `'clawform ${command}' needs an activated license. ` +
75
- 'New here? There is a free 14-day trial — full kit, no card — at https://clawform.thejoseki.com. ' +
88
+ 'New here? There is a free 14-day trial — full kit, no card — at https://clawform.thejoseki.com/?utm_source=cli. ' +
76
89
  'Already have a key (trial or purchase email)? Run: clawform activate <key>',
77
90
  );
78
91
  }
@@ -96,7 +109,7 @@ export async function assertLicensed({
96
109
  if (Number.isFinite(endsAt) && now() >= endsAt) {
97
110
  throw new Error(
98
111
  `Your Clawform trial ended on ${record.expiresAt.slice(0, 10)}, so 'clawform ${command}' `
99
- + 'is no longer available. Buy a seat at https://clawform.thejoseki.com and run '
112
+ + 'is no longer available. Buy a seat at https://clawform.thejoseki.com/?utm_source=cli and run '
100
113
  + '`clawform activate <key>`. Note that `clawform deploy` and `clawform rollback` keep '
101
114
  + 'working either way — an expired licence never strands a stack.',
102
115
  );
@@ -1,226 +1,226 @@
1
- // Lint-override loader + cfn-guard SuppressedRules wrapper.
2
- //
3
- // Reads rules/lint-overrides.md (the single, audited allowlist), turns its
4
- // Markdown table into structured overrides, partitions expired rows, injects
5
- // the active ones into a CFN template's Metadata.guard.SuppressedRules so
6
- // cfn-guard skips them, and detects in-template suppressions that lack an
7
- // overrides-file row (anti-rot — "suppression without justification").
8
- //
9
- // Functions are pure where possible: every time-sensitive call (filterExpired)
10
- // takes the current Date as an argument so tests are deterministic. Callers
11
- // (src/commands/validate.js) supply `new Date()` at the call site.
12
- //
13
- // YAML round-trip uses the shared CFN short-tag schema in src/lib/cfn-yaml.js.
14
- // iam-audit / graph.js still carry local copies — they COULD import src/lib via
15
- // the same upward-probe graph.js already uses for project-config.js; folding
16
- // them onto cfn-yaml.js is a worthwhile follow-up (the copies have drifted
17
- // before: graph.js was missing `!Transform` until 1.2.0). The schema is
18
- // construct-only, so `!Sub "x"` round-trips to `Fn::Sub: x` — both forms are
19
- // valid CloudFormation and cfn-guard accepts either.
20
-
21
- import { readFileSync, existsSync } from 'node:fs';
22
- import yaml from 'js-yaml';
23
- import { CFN_SCHEMA } from './cfn-yaml.js';
24
-
25
- // --- loadOverrides ---------------------------------------------------------
26
- // Parse the Markdown table in rules/lint-overrides.md and return an array of
27
- // { ruleId, scopeGlob, reason, expires, owner }
28
- //
29
- // Header-name driven: we normalize `rule-id` → `ruleId`, `scope` → `scopeGlob`.
30
- // The doc's `tool` column is read but discarded (cfn-guard SuppressedRules
31
- // don't distinguish source). `owner` is optional and defaults to null when the
32
- // column is absent or empty.
33
- //
34
- // Placeholder rows (empty rule-id, or markdown-italic markers like `_none yet_`)
35
- // are skipped — the bundled doc ships with one such row by design.
36
- //
37
- // Missing file: returns [] (a fresh consumer with no overrides is a valid
38
- // state, mirroring the legacy_prefixes contract).
39
- export function loadOverrides(mdPath) {
40
- if (!existsSync(mdPath)) return [];
41
- const text = readFileSync(mdPath, 'utf8');
42
- return parseOverridesMarkdown(text);
43
- }
44
-
45
- export function parseOverridesMarkdown(text) {
46
- const lines = text.split(/\r?\n/);
47
- // Find the first table whose header includes a "rule-id" column.
48
- const out = [];
49
- let i = 0;
50
- while (i < lines.length) {
51
- const line = lines[i];
52
- if (isTableRow(line)) {
53
- const header = splitRow(line);
54
- const norm = header.map(normalizeHeader);
55
- const idIdx = norm.indexOf('ruleId');
56
- if (idIdx === -1) { i++; continue; }
57
- // Next line should be the separator (|---|---|...). Skip it.
58
- const sep = lines[i + 1];
59
- if (!sep || !/^\s*\|?\s*:?-{2,}/.test(sep.trim())) { i++; continue; }
60
- i += 2;
61
- while (i < lines.length && isTableRow(lines[i])) {
62
- const cells = splitRow(lines[i]);
63
- const row = {};
64
- for (let c = 0; c < norm.length; c++) row[norm[c]] = (cells[c] ?? '').trim();
65
- const entry = rowToOverride(row);
66
- if (entry) out.push(entry);
67
- i++;
68
- }
69
- return out;
70
- }
71
- i++;
72
- }
73
- return out;
74
- }
75
-
76
- function isTableRow(line) {
77
- return typeof line === 'string' && line.trim().startsWith('|') && line.trim().endsWith('|');
78
- }
79
-
80
- function splitRow(line) {
81
- // Strip leading/trailing pipes then split on remaining pipes.
82
- const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
83
- return trimmed.split('|').map((s) => s.trim());
84
- }
85
-
86
- function normalizeHeader(h) {
87
- const k = h.trim().toLowerCase();
88
- if (k === 'rule-id' || k === 'rule id' || k === 'ruleid') return 'ruleId';
89
- if (k === 'scope' || k === 'scope-glob' || k === 'scopeglob') return 'scopeGlob';
90
- return k;
91
- }
92
-
93
- function rowToOverride(row) {
94
- const ruleId = row.ruleId;
95
- if (!ruleId) return null;
96
- // Skip markdown placeholder markers like `_none yet_`, `_pending_`, `n/a`.
97
- if (/^_.*_$/.test(ruleId) || /^n\/a$/i.test(ruleId)) return null;
98
- const scopeGlob = row.scopeGlob || '*';
99
- const reason = row.reason || '';
100
- const owner = row.owner ? row.owner : null;
101
- const expires = parseExpires(row.expires);
102
- return { ruleId, scopeGlob, reason, expires, owner };
103
- }
104
-
105
- function parseExpires(raw) {
106
- if (!raw) return null;
107
- const v = raw.trim();
108
- if (!v || /^_.*_$/.test(v) || /^n\/a$/i.test(v) || v === '-') return null;
109
- const d = new Date(v);
110
- if (Number.isNaN(d.getTime())) return null;
111
- return d;
112
- }
113
-
114
- // --- filterExpired ---------------------------------------------------------
115
- // Pure partition. `now` is REQUIRED — callers pass `new Date()` in production
116
- // and a fixed Date instance in tests. Entries with expires: null never expire.
117
- export function filterExpired(overrides, now) {
118
- if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
119
- throw new TypeError('filterExpired(overrides, now): `now` must be a valid Date instance');
120
- }
121
- const active = [];
122
- const expired = [];
123
- for (const o of overrides) {
124
- if (o.expires && o.expires.getTime() < now.getTime()) expired.push(o);
125
- else active.push(o);
126
- }
127
- return { active, expired };
128
- }
129
-
130
- // --- glob matching for logical IDs ----------------------------------------
131
- // Tiny `*` → `.*` translator; `?` → `.`. `global` and `*` both mean match-all.
132
- // We intentionally don't pull a glob lib — overrides scope is a single token
133
- // against a CFN logical ID (alphanumeric), so the surface is small.
134
- export function matchesGlob(glob, value) {
135
- if (!glob || glob === '*' || glob.toLowerCase() === 'global') return true;
136
- const re = globToRegex(glob);
137
- return re.test(value);
138
- }
139
-
140
- function globToRegex(glob) {
141
- let out = '^';
142
- for (const ch of glob) {
143
- if (ch === '*') out += '.*';
144
- else if (ch === '?') out += '.';
145
- else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
146
- }
147
- out += '$';
148
- return new RegExp(out);
149
- }
150
-
151
- // --- injectSuppressions ----------------------------------------------------
152
- // For each resource in the template, append (deduped) any overrides whose
153
- // scopeGlob matches the logical ID to Metadata.guard.SuppressedRules. Does
154
- // NOT clobber pre-existing inline entries — a resource may legitimately carry
155
- // one (and detectUnauthorizedSuppressions decides if that's allowed).
156
- //
157
- // Returns the modified YAML string (long-form intrinsics — see schema note
158
- // at top of file).
159
- export function injectSuppressions(templateYaml, activeOverrides) {
160
- let doc;
161
- try {
162
- doc = yaml.load(templateYaml, { schema: CFN_SCHEMA });
163
- } catch {
164
- // Unknown short tag (e.g. `!Length`, language-extension tags we don't
165
- // have a construct for), or any other YAML parse error. Returning the
166
- // input unchanged preserves the contract "injection is best-effort, never
167
- // a deploy-gate crash" — the real linters (cfn-lint / cfn-nag / cfn-guard)
168
- // still run against the original file and will surface the parse problem
169
- // with their own diagnostics.
170
- return templateYaml;
171
- }
172
- if (!doc || typeof doc !== 'object' || !doc.Resources || typeof doc.Resources !== 'object') {
173
- return templateYaml;
174
- }
175
- for (const [logicalId, resource] of Object.entries(doc.Resources)) {
176
- if (!resource || typeof resource !== 'object') continue;
177
- const ruleIds = [];
178
- for (const o of activeOverrides) {
179
- if (matchesGlob(o.scopeGlob, logicalId)) ruleIds.push(o.ruleId);
180
- }
181
- if (ruleIds.length === 0) continue;
182
- resource.Metadata = resource.Metadata || {};
183
- resource.Metadata.guard = resource.Metadata.guard || {};
184
- const existing = Array.isArray(resource.Metadata.guard.SuppressedRules)
185
- ? resource.Metadata.guard.SuppressedRules
186
- : [];
187
- const merged = Array.from(new Set([...existing, ...ruleIds]));
188
- resource.Metadata.guard.SuppressedRules = merged;
189
- }
190
- return yaml.dump(doc, { schema: CFN_SCHEMA, lineWidth: 200, noRefs: true });
191
- }
192
-
193
- // --- detectUnauthorizedSuppressions ---------------------------------------
194
- // Walk the ORIGINAL template (not the injected copy) and return any inline
195
- // Metadata.guard.SuppressedRules entries that are NOT covered by an active
196
- // override row. "Covered" = some active override matches BOTH the resource's
197
- // logical ID (scopeGlob) AND the suppressed rule ID. This is the anti-rot
198
- // check — a hand-edited suppression in YAML must trace back to an audited row.
199
- export function detectUnauthorizedSuppressions(templateYaml, activeOverrides) {
200
- const findings = [];
201
- let doc;
202
- try {
203
- doc = yaml.load(templateYaml, { schema: CFN_SCHEMA });
204
- } catch {
205
- // Same rationale as injectSuppressions: an unknown tag must not turn the
206
- // anti-rot check into a Node crash that takes down the whole validate
207
- // pipeline. The linters will report the parse problem; we report no
208
- // unauthorized suppressions because we couldn't see them.
209
- return findings;
210
- }
211
- if (!doc || typeof doc !== 'object' || !doc.Resources || typeof doc.Resources !== 'object') {
212
- return findings;
213
- }
214
- for (const [logicalId, resource] of Object.entries(doc.Resources)) {
215
- if (!resource || typeof resource !== 'object') continue;
216
- const suppressed = resource?.Metadata?.guard?.SuppressedRules;
217
- if (!Array.isArray(suppressed)) continue;
218
- for (const ruleId of suppressed) {
219
- const covered = activeOverrides.some(
220
- (o) => o.ruleId === ruleId && matchesGlob(o.scopeGlob, logicalId),
221
- );
222
- if (!covered) findings.push({ resourceId: logicalId, ruleId });
223
- }
224
- }
225
- return findings;
226
- }
1
+ // Lint-override loader + cfn-guard SuppressedRules wrapper.
2
+ //
3
+ // Reads rules/lint-overrides.md (the single, audited allowlist), turns its
4
+ // Markdown table into structured overrides, partitions expired rows, injects
5
+ // the active ones into a CFN template's Metadata.guard.SuppressedRules so
6
+ // cfn-guard skips them, and detects in-template suppressions that lack an
7
+ // overrides-file row (anti-rot — "suppression without justification").
8
+ //
9
+ // Functions are pure where possible: every time-sensitive call (filterExpired)
10
+ // takes the current Date as an argument so tests are deterministic. Callers
11
+ // (src/commands/validate.js) supply `new Date()` at the call site.
12
+ //
13
+ // YAML round-trip uses the shared CFN short-tag schema in src/lib/cfn-yaml.js.
14
+ // iam-audit / graph.js still carry local copies — they COULD import src/lib via
15
+ // the same upward-probe graph.js already uses for project-config.js; folding
16
+ // them onto cfn-yaml.js is a worthwhile follow-up (the copies have drifted
17
+ // before: graph.js was missing `!Transform` until 1.2.0). The schema is
18
+ // construct-only, so `!Sub "x"` round-trips to `Fn::Sub: x` — both forms are
19
+ // valid CloudFormation and cfn-guard accepts either.
20
+
21
+ import { readFileSync, existsSync } from 'node:fs';
22
+ import yaml from 'js-yaml';
23
+ import { CFN_SCHEMA } from './cfn-yaml.js';
24
+
25
+ // --- loadOverrides ---------------------------------------------------------
26
+ // Parse the Markdown table in rules/lint-overrides.md and return an array of
27
+ // { ruleId, scopeGlob, reason, expires, owner }
28
+ //
29
+ // Header-name driven: we normalize `rule-id` → `ruleId`, `scope` → `scopeGlob`.
30
+ // The doc's `tool` column is read but discarded (cfn-guard SuppressedRules
31
+ // don't distinguish source). `owner` is optional and defaults to null when the
32
+ // column is absent or empty.
33
+ //
34
+ // Placeholder rows (empty rule-id, or markdown-italic markers like `_none yet_`)
35
+ // are skipped — the bundled doc ships with one such row by design.
36
+ //
37
+ // Missing file: returns [] (a fresh consumer with no overrides is a valid
38
+ // state, mirroring the legacy_prefixes contract).
39
+ export function loadOverrides(mdPath) {
40
+ if (!existsSync(mdPath)) return [];
41
+ const text = readFileSync(mdPath, 'utf8');
42
+ return parseOverridesMarkdown(text);
43
+ }
44
+
45
+ export function parseOverridesMarkdown(text) {
46
+ const lines = text.split(/\r?\n/);
47
+ // Find the first table whose header includes a "rule-id" column.
48
+ const out = [];
49
+ let i = 0;
50
+ while (i < lines.length) {
51
+ const line = lines[i];
52
+ if (isTableRow(line)) {
53
+ const header = splitRow(line);
54
+ const norm = header.map(normalizeHeader);
55
+ const idIdx = norm.indexOf('ruleId');
56
+ if (idIdx === -1) { i++; continue; }
57
+ // Next line should be the separator (|---|---|...). Skip it.
58
+ const sep = lines[i + 1];
59
+ if (!sep || !/^\s*\|?\s*:?-{2,}/.test(sep.trim())) { i++; continue; }
60
+ i += 2;
61
+ while (i < lines.length && isTableRow(lines[i])) {
62
+ const cells = splitRow(lines[i]);
63
+ const row = {};
64
+ for (let c = 0; c < norm.length; c++) row[norm[c]] = (cells[c] ?? '').trim();
65
+ const entry = rowToOverride(row);
66
+ if (entry) out.push(entry);
67
+ i++;
68
+ }
69
+ return out;
70
+ }
71
+ i++;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function isTableRow(line) {
77
+ return typeof line === 'string' && line.trim().startsWith('|') && line.trim().endsWith('|');
78
+ }
79
+
80
+ function splitRow(line) {
81
+ // Strip leading/trailing pipes then split on remaining pipes.
82
+ const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
83
+ return trimmed.split('|').map((s) => s.trim());
84
+ }
85
+
86
+ function normalizeHeader(h) {
87
+ const k = h.trim().toLowerCase();
88
+ if (k === 'rule-id' || k === 'rule id' || k === 'ruleid') return 'ruleId';
89
+ if (k === 'scope' || k === 'scope-glob' || k === 'scopeglob') return 'scopeGlob';
90
+ return k;
91
+ }
92
+
93
+ function rowToOverride(row) {
94
+ const ruleId = row.ruleId;
95
+ if (!ruleId) return null;
96
+ // Skip markdown placeholder markers like `_none yet_`, `_pending_`, `n/a`.
97
+ if (/^_.*_$/.test(ruleId) || /^n\/a$/i.test(ruleId)) return null;
98
+ const scopeGlob = row.scopeGlob || '*';
99
+ const reason = row.reason || '';
100
+ const owner = row.owner ? row.owner : null;
101
+ const expires = parseExpires(row.expires);
102
+ return { ruleId, scopeGlob, reason, expires, owner };
103
+ }
104
+
105
+ function parseExpires(raw) {
106
+ if (!raw) return null;
107
+ const v = raw.trim();
108
+ if (!v || /^_.*_$/.test(v) || /^n\/a$/i.test(v) || v === '-') return null;
109
+ const d = new Date(v);
110
+ if (Number.isNaN(d.getTime())) return null;
111
+ return d;
112
+ }
113
+
114
+ // --- filterExpired ---------------------------------------------------------
115
+ // Pure partition. `now` is REQUIRED — callers pass `new Date()` in production
116
+ // and a fixed Date instance in tests. Entries with expires: null never expire.
117
+ export function filterExpired(overrides, now) {
118
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
119
+ throw new TypeError('filterExpired(overrides, now): `now` must be a valid Date instance');
120
+ }
121
+ const active = [];
122
+ const expired = [];
123
+ for (const o of overrides) {
124
+ if (o.expires && o.expires.getTime() < now.getTime()) expired.push(o);
125
+ else active.push(o);
126
+ }
127
+ return { active, expired };
128
+ }
129
+
130
+ // --- glob matching for logical IDs ----------------------------------------
131
+ // Tiny `*` → `.*` translator; `?` → `.`. `global` and `*` both mean match-all.
132
+ // We intentionally don't pull a glob lib — overrides scope is a single token
133
+ // against a CFN logical ID (alphanumeric), so the surface is small.
134
+ export function matchesGlob(glob, value) {
135
+ if (!glob || glob === '*' || glob.toLowerCase() === 'global') return true;
136
+ const re = globToRegex(glob);
137
+ return re.test(value);
138
+ }
139
+
140
+ function globToRegex(glob) {
141
+ let out = '^';
142
+ for (const ch of glob) {
143
+ if (ch === '*') out += '.*';
144
+ else if (ch === '?') out += '.';
145
+ else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
146
+ }
147
+ out += '$';
148
+ return new RegExp(out);
149
+ }
150
+
151
+ // --- injectSuppressions ----------------------------------------------------
152
+ // For each resource in the template, append (deduped) any overrides whose
153
+ // scopeGlob matches the logical ID to Metadata.guard.SuppressedRules. Does
154
+ // NOT clobber pre-existing inline entries — a resource may legitimately carry
155
+ // one (and detectUnauthorizedSuppressions decides if that's allowed).
156
+ //
157
+ // Returns the modified YAML string (long-form intrinsics — see schema note
158
+ // at top of file).
159
+ export function injectSuppressions(templateYaml, activeOverrides) {
160
+ let doc;
161
+ try {
162
+ doc = yaml.load(templateYaml, { schema: CFN_SCHEMA });
163
+ } catch {
164
+ // Unknown short tag (e.g. `!Length`, language-extension tags we don't
165
+ // have a construct for), or any other YAML parse error. Returning the
166
+ // input unchanged preserves the contract "injection is best-effort, never
167
+ // a deploy-gate crash" — the real linters (cfn-lint / cfn-nag / cfn-guard)
168
+ // still run against the original file and will surface the parse problem
169
+ // with their own diagnostics.
170
+ return templateYaml;
171
+ }
172
+ if (!doc || typeof doc !== 'object' || !doc.Resources || typeof doc.Resources !== 'object') {
173
+ return templateYaml;
174
+ }
175
+ for (const [logicalId, resource] of Object.entries(doc.Resources)) {
176
+ if (!resource || typeof resource !== 'object') continue;
177
+ const ruleIds = [];
178
+ for (const o of activeOverrides) {
179
+ if (matchesGlob(o.scopeGlob, logicalId)) ruleIds.push(o.ruleId);
180
+ }
181
+ if (ruleIds.length === 0) continue;
182
+ resource.Metadata = resource.Metadata || {};
183
+ resource.Metadata.guard = resource.Metadata.guard || {};
184
+ const existing = Array.isArray(resource.Metadata.guard.SuppressedRules)
185
+ ? resource.Metadata.guard.SuppressedRules
186
+ : [];
187
+ const merged = Array.from(new Set([...existing, ...ruleIds]));
188
+ resource.Metadata.guard.SuppressedRules = merged;
189
+ }
190
+ return yaml.dump(doc, { schema: CFN_SCHEMA, lineWidth: 200, noRefs: true });
191
+ }
192
+
193
+ // --- detectUnauthorizedSuppressions ---------------------------------------
194
+ // Walk the ORIGINAL template (not the injected copy) and return any inline
195
+ // Metadata.guard.SuppressedRules entries that are NOT covered by an active
196
+ // override row. "Covered" = some active override matches BOTH the resource's
197
+ // logical ID (scopeGlob) AND the suppressed rule ID. This is the anti-rot
198
+ // check — a hand-edited suppression in YAML must trace back to an audited row.
199
+ export function detectUnauthorizedSuppressions(templateYaml, activeOverrides) {
200
+ const findings = [];
201
+ let doc;
202
+ try {
203
+ doc = yaml.load(templateYaml, { schema: CFN_SCHEMA });
204
+ } catch {
205
+ // Same rationale as injectSuppressions: an unknown tag must not turn the
206
+ // anti-rot check into a Node crash that takes down the whole validate
207
+ // pipeline. The linters will report the parse problem; we report no
208
+ // unauthorized suppressions because we couldn't see them.
209
+ return findings;
210
+ }
211
+ if (!doc || typeof doc !== 'object' || !doc.Resources || typeof doc.Resources !== 'object') {
212
+ return findings;
213
+ }
214
+ for (const [logicalId, resource] of Object.entries(doc.Resources)) {
215
+ if (!resource || typeof resource !== 'object') continue;
216
+ const suppressed = resource?.Metadata?.guard?.SuppressedRules;
217
+ if (!Array.isArray(suppressed)) continue;
218
+ for (const ruleId of suppressed) {
219
+ const covered = activeOverrides.some(
220
+ (o) => o.ruleId === ruleId && matchesGlob(o.scopeGlob, logicalId),
221
+ );
222
+ if (!covered) findings.push({ resourceId: logicalId, ruleId });
223
+ }
224
+ }
225
+ return findings;
226
+ }
package/src/lib/logger.js CHANGED
@@ -1,17 +1,17 @@
1
1
  import chalk from 'chalk';
2
- import { readEnv } from './rename-compat.js';
3
-
4
- const ts = () => new Date().toISOString().slice(11, 19);
5
-
6
- export const logger = {
7
- info: (msg) => console.log(`${chalk.gray(ts())} ${chalk.cyan('•')} ${msg}`),
8
- ok: (msg) => console.log(`${chalk.gray(ts())} ${chalk.green('✓')} ${msg}`),
9
- warn: (msg) => console.warn(`${chalk.gray(ts())} ${chalk.yellow('!')} ${msg}`),
10
- error: (msg) => console.error(`${chalk.gray(ts())} ${chalk.red('✗')} ${msg}`),
11
- step: (msg) => console.log(`${chalk.gray(ts())} ${chalk.magenta('→')} ${chalk.bold(msg)}`),
12
- debug: (msg) => {
13
- if (readEnv('DEBUG')) {
14
- console.log(`${chalk.gray(ts())} ${chalk.gray('debug')} ${chalk.gray(msg)}`);
15
- }
16
- },
17
- };
2
+ import { readEnv } from './rename-compat.js';
3
+
4
+ const ts = () => new Date().toISOString().slice(11, 19);
5
+
6
+ export const logger = {
7
+ info: (msg) => console.log(`${chalk.gray(ts())} ${chalk.cyan('•')} ${msg}`),
8
+ ok: (msg) => console.log(`${chalk.gray(ts())} ${chalk.green('✓')} ${msg}`),
9
+ warn: (msg) => console.warn(`${chalk.gray(ts())} ${chalk.yellow('!')} ${msg}`),
10
+ error: (msg) => console.error(`${chalk.gray(ts())} ${chalk.red('✗')} ${msg}`),
11
+ step: (msg) => console.log(`${chalk.gray(ts())} ${chalk.magenta('→')} ${chalk.bold(msg)}`),
12
+ debug: (msg) => {
13
+ if (readEnv('DEBUG')) {
14
+ console.log(`${chalk.gray(ts())} ${chalk.gray('debug')} ${chalk.gray(msg)}`);
15
+ }
16
+ },
17
+ };