@thejoseki/clawform 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -120
- package/bin/clawform.js +161 -151
- package/package.json +65 -65
- package/src/aws/catalog.js +116 -116
- package/src/aws/changeset.js +231 -231
- package/src/aws/drift.js +59 -59
- package/src/aws/exports.js +213 -213
- package/src/aws/inventory.js +156 -156
- package/src/commands/activate.js +137 -105
- package/src/commands/ci-init.js +393 -212
- package/src/commands/cost-report.js +163 -163
- package/src/commands/deactivate.js +9 -2
- package/src/commands/deploy.js +413 -413
- package/src/commands/diff.js +39 -39
- package/src/commands/drift.js +35 -35
- package/src/commands/init-plugin.js +6 -6
- package/src/commands/init.js +360 -360
- package/src/commands/rollback.js +126 -126
- package/src/commands/status.js +147 -147
- package/src/commands/sync.js +50 -50
- package/src/commands/update.js +24 -0
- package/src/commands/validate.js +349 -349
- package/src/hooks/block-dangerous.js +62 -62
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
- package/src/hooks/cfn-lint-after-edit.js +81 -81
- package/src/hooks/check-catalog-fresh-eval.js +63 -63
- package/src/hooks/check-catalog-fresh.js +33 -33
- package/src/hooks/session-context-eval.js +81 -81
- package/src/hooks/session-context.js +41 -41
- package/src/hooks/subagent-context-eval.js +44 -44
- package/src/hooks/subagent-context.js +48 -48
- package/src/lib/audit-synthesis.js +24 -24
- package/src/lib/aws-client.js +15 -15
- package/src/lib/cfn-yaml.js +92 -92
- package/src/lib/config.js +76 -76
- package/src/lib/constants.js +85 -85
- package/src/lib/defaults.js +16 -16
- package/src/lib/install-workflow.js +28 -28
- package/src/lib/kit-source.js +43 -5
- package/src/lib/license-client.js +84 -84
- package/src/lib/license-config.js +162 -162
- package/src/lib/license-store.js +43 -8
- package/src/lib/license.js +163 -150
- package/src/lib/lint-overrides.js +226 -226
- package/src/lib/logger.js +16 -16
- package/src/lib/project-config.js +145 -145
- package/src/lib/providers/polar.js +215 -215
- package/src/lib/session-state.js +91 -91
|
@@ -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
|
+
};
|