@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.
- 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 +34 -2
- 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 +15 -2
- 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
package/src/commands/validate.js
CHANGED
|
@@ -1,349 +1,349 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import { execa } from 'execa';
|
|
7
|
-
import { ValidateTemplateCommand } from '@aws-sdk/client-cloudformation';
|
|
8
|
-
import { logger } from '../lib/logger.js';
|
|
9
|
-
import { cfnClient } from '../lib/aws-client.js';
|
|
10
|
-
import {
|
|
11
|
-
loadOverrides,
|
|
12
|
-
filterExpired,
|
|
13
|
-
injectSuppressions,
|
|
14
|
-
detectUnauthorizedSuppressions,
|
|
15
|
-
} from '../lib/lint-overrides.js';
|
|
16
|
-
import { assertLicensed } from '../lib/license.js';
|
|
17
|
-
import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
|
|
18
|
-
|
|
19
|
-
const LINT_LEVEL = { Error: 'E', Warning: 'W', Informational: 'I' };
|
|
20
|
-
const OVERRIDES_PATH = join(process.cwd(), 'rules', 'lint-overrides.md');
|
|
21
|
-
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
22
|
-
|
|
23
|
-
// cfn-guard rules ship with the clawform package. A consumer project that
|
|
24
|
-
// installed clawform (plugin cache / node_modules) has no `rules.guard/` in
|
|
25
|
-
// cwd — resolving relative to cwd used to make cfn-guard exit non-zero
|
|
26
|
-
// ("missing rules dir"), which was counted as a CRITICAL and failed every
|
|
27
|
-
// validate for exactly the installed-plugin case. Resolve from the package
|
|
28
|
-
// root; a project-local `rules.guard/` (consumer-authored rules) wins when
|
|
29
|
-
// present so consumers can extend without forking.
|
|
30
|
-
export function resolveGuardRulesDir({ cwd = process.cwd(), pkgRoot = PKG_ROOT } = {}) {
|
|
31
|
-
const local = join(cwd, 'rules.guard');
|
|
32
|
-
if (existsSync(local)) return local;
|
|
33
|
-
return join(pkgRoot, 'rules.guard');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export default async function validate({ file, skipAws = false }) {
|
|
37
|
-
await assertLicensed({ command: 'validate', client: resolveLicenseClient() });
|
|
38
|
-
logger.step(`clawform validate ${file}`);
|
|
39
|
-
const body = await readFile(file, 'utf8');
|
|
40
|
-
|
|
41
|
-
const lint = await runCfnLint(file);
|
|
42
|
-
const nag = await runCfnNag(file);
|
|
43
|
-
|
|
44
|
-
// Wave 3 overrides hook: load the audited allowlist, partition expired,
|
|
45
|
-
// inject active suppressions into a temp copy for cfn-guard, then run the
|
|
46
|
-
// anti-rot check against the ORIGINAL template (inline suppressions without
|
|
47
|
-
// an overrides row are folded into the critical count and gate /deploy).
|
|
48
|
-
const overrides = loadOverrides(OVERRIDES_PATH);
|
|
49
|
-
const { active, expired } = filterExpired(overrides, new Date());
|
|
50
|
-
for (const e of expired) {
|
|
51
|
-
logger.warn(
|
|
52
|
-
`lint-override expired ${e.ruleId} (scope=${e.scopeGlob}, expired ${e.expires.toISOString().slice(0, 10)}). ` +
|
|
53
|
-
`Re-evaluate in rules/lint-overrides.md or remove the row.`,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const { guard, unauthorized } = await runCfnGuardWithOverrides(file, body, active);
|
|
58
|
-
|
|
59
|
-
for (const u of unauthorized) {
|
|
60
|
-
logger.error(`unauthorized inline SuppressedRules: ${u.resourceId} suppresses ${u.ruleId} with no rules/lint-overrides.md row`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const aws = skipAws ? null : await runValidateTemplate(body);
|
|
64
|
-
|
|
65
|
-
const critical =
|
|
66
|
-
(lint?.errors ?? 0) + (nag?.failures ?? 0) + (guard?.failures ?? 0) + (aws?.errors ?? 0)
|
|
67
|
-
+ unauthorized.length;
|
|
68
|
-
const warnings = (lint?.warnings ?? 0) + (nag?.warnings ?? 0) + (guard?.warnings ?? 0);
|
|
69
|
-
// A "skipped" tool contributes 0 errors AND 0 warnings under nullish-coalescing —
|
|
70
|
-
// before this gate, a fresh dev box with no cfn-lint / cfn-nag installed would
|
|
71
|
-
// print "All checks passed" exit 0 even though the only thing that ran was the
|
|
72
|
-
// shallow CloudFormation ValidateTemplate. That silently bypasses every Layer-2
|
|
73
|
-
// rule the rule files promise (W11/W12/F2/W2/W9/W35/W74/W2001/...).
|
|
74
|
-
const skipped = [
|
|
75
|
-
lint?.skipped ? 'cfn-lint' : null,
|
|
76
|
-
nag?.skipped ? 'cfn-nag' : null,
|
|
77
|
-
guard?.skipped ? 'cfn-guard' : null,
|
|
78
|
-
!aws ? 'ValidateTemplate (--skip-aws)' : null,
|
|
79
|
-
].filter(Boolean);
|
|
80
|
-
|
|
81
|
-
logger.info('');
|
|
82
|
-
logger.info('Summary:');
|
|
83
|
-
logger.info(` cfn-lint ${formatCount(lint)}`);
|
|
84
|
-
logger.info(` cfn-nag ${formatCount(nag)}`);
|
|
85
|
-
logger.info(` cfn-guard ${formatCount(guard)}`);
|
|
86
|
-
logger.info(` ValidateTemplate ${formatCount(aws)}`);
|
|
87
|
-
// Surface unauthorized inline suppressions in the Summary so a clean
|
|
88
|
-
// lint/nag/guard run still gives the user a visible cause for exit 1.
|
|
89
|
-
logger.info(` lint-overrides ${unauthorized.length === 0 ? '✓ clean' : `🔴 ${unauthorized.length} unauthorized suppression(s)`}`);
|
|
90
|
-
|
|
91
|
-
if (critical > 0) {
|
|
92
|
-
logger.error(`${critical} CRITICAL finding(s). Fix before /deploy.`);
|
|
93
|
-
process.exit(1);
|
|
94
|
-
} else if (warnings > 0) {
|
|
95
|
-
logger.warn(`${warnings} warning(s). Review then proceed.`);
|
|
96
|
-
} else if (skipped.length > 0) {
|
|
97
|
-
// Don't print "All checks passed" when we didn't actually run every check.
|
|
98
|
-
logger.warn(
|
|
99
|
-
`Partial pass — ${skipped.length} tool(s) skipped: ${skipped.join(', ')}. ` +
|
|
100
|
-
`Install missing tools (pip install cfn-lint / gem install cfn-nag / cargo install cfn-guard --locked) before relying on this as a deploy gate.`,
|
|
101
|
-
);
|
|
102
|
-
} else {
|
|
103
|
-
logger.ok('All checks passed.');
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function formatCount(r) {
|
|
108
|
-
if (!r) return '⊘ skipped';
|
|
109
|
-
if (r.skipped) return `⊘ skipped (${r.skipReason})`;
|
|
110
|
-
const e = r.errors ?? r.failures ?? 0;
|
|
111
|
-
const w = r.warnings ?? 0;
|
|
112
|
-
if (e === 0 && w === 0) return '✓ clean';
|
|
113
|
-
return `${e ? '🔴' : '🟡'} ${e} error(s), ${w} warning(s)`;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Exported for unit tests (validate-runners.test.js). The default export is
|
|
117
|
-
// still the orchestrator — these helpers stay internal to validate(), but the
|
|
118
|
-
// runner tests need to drive them directly with a mocked execa.
|
|
119
|
-
export async function runCfnLint(file) {
|
|
120
|
-
const result = await execa('cfn-lint', ['--format', 'json', file], { reject: false });
|
|
121
|
-
if (toolMissing(result)) {
|
|
122
|
-
logger.warn('cfn-lint not found. Install: pip install cfn-lint');
|
|
123
|
-
return { skipped: true, skipReason: 'not installed' };
|
|
124
|
-
}
|
|
125
|
-
const stdout = result.stdout ?? '';
|
|
126
|
-
const stderr = result.stderr ?? '';
|
|
127
|
-
|
|
128
|
-
// M4 — cfn-lint exits non-zero on rule-engine errors (bad template path,
|
|
129
|
-
// unrecognized flag, broken plugin). Previously we fell through to JSON.parse
|
|
130
|
-
// and either crashed (M5) or silently returned 0/0. Surface a real error so
|
|
131
|
-
// /deploy is gated. cfn-lint ALSO exits non-zero when it just found
|
|
132
|
-
// findings — those are handled below via parsed JSON, so we only fail loud
|
|
133
|
-
// when stdout has no parseable findings AND stderr looks like a tool error.
|
|
134
|
-
// Empty stdout is valid (no findings) — short-circuit before the JSON path.
|
|
135
|
-
if (!stdout.trim()) {
|
|
136
|
-
if (result.failed && stderr.trim()) {
|
|
137
|
-
const firstLine = stderr.split(/\r?\n/)[0].trim();
|
|
138
|
-
return { errors: 1, warnings: 0, message: `cfn-lint exited non-zero: ${firstLine}` };
|
|
139
|
-
}
|
|
140
|
-
return { errors: 0, warnings: 0 };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// M5 — guard JSON.parse so a malformed stdout (rule-engine crashed mid-output,
|
|
144
|
-
// or cfn-lint printed a non-JSON banner) surfaces as a finding rather than
|
|
145
|
-
// crashing the whole validate command.
|
|
146
|
-
let findings;
|
|
147
|
-
try {
|
|
148
|
-
findings = JSON.parse(stdout);
|
|
149
|
-
} catch {
|
|
150
|
-
const preview = stdout.slice(0, 100).replace(/\s+/g, ' ');
|
|
151
|
-
return {
|
|
152
|
-
errors: 1,
|
|
153
|
-
warnings: 0,
|
|
154
|
-
message: `cfn-lint returned non-JSON output (length ${stdout.length}): ${preview}`,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
// P1 — JSON.parse can succeed on shapes that aren't a findings array
|
|
158
|
-
// (e.g. cfn-lint plugin returning `{ "error": "..." }`, or stdout being
|
|
159
|
-
// a bare `null`/string/number from a wrapped script). Surface as a
|
|
160
|
-
// warning so /deploy is not silently gated on a shape we can't iterate.
|
|
161
|
-
if (!Array.isArray(findings)) {
|
|
162
|
-
logger.warn(`cfn-lint returned unexpected JSON shape: ${typeof findings}`);
|
|
163
|
-
return { errors: 0, warnings: 1 };
|
|
164
|
-
}
|
|
165
|
-
let errors = 0;
|
|
166
|
-
let warnings = 0;
|
|
167
|
-
for (const f of findings) {
|
|
168
|
-
const lvl = LINT_LEVEL[f.Level] ?? 'I';
|
|
169
|
-
if (lvl === 'E') {
|
|
170
|
-
errors++;
|
|
171
|
-
logger.error(`cfn-lint ${f.Rule?.Id ?? ''} ${f.Location?.Start ? `L${f.Location.Start.LineNumber}` : ''}: ${f.Message}`);
|
|
172
|
-
} else if (lvl === 'W') {
|
|
173
|
-
warnings++;
|
|
174
|
-
logger.warn(`cfn-lint ${f.Rule?.Id ?? ''}: ${f.Message}`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return { errors, warnings };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function runCfnNag(file) {
|
|
181
|
-
const cmd = process.platform === 'win32' ? 'cfn_nag_scan.bat' : 'cfn_nag_scan';
|
|
182
|
-
// P3 — use the short form `-o json` for the output-format flag. Both `-o`
|
|
183
|
-
// and `--output-format` are valid cfn-nag flags, but the test suite asserts
|
|
184
|
-
// the short form so that a future refactor doesn't silently drop into
|
|
185
|
-
// cfn-nag's default text output (which would trip the M5 JSON.parse catch
|
|
186
|
-
// as a false-positive critical finding on every run).
|
|
187
|
-
const result = await execa(cmd, ['--input-path', file, '-o', 'json'], { reject: false });
|
|
188
|
-
if (toolMissing(result)) {
|
|
189
|
-
logger.warn('cfn-nag not found. Install: gem install cfn-nag');
|
|
190
|
-
return { skipped: true, skipReason: 'not installed' };
|
|
191
|
-
}
|
|
192
|
-
const stdout = result.stdout ?? '';
|
|
193
|
-
const stderr = result.stderr ?? '';
|
|
194
|
-
|
|
195
|
-
// M4 — cfn-nag exits non-zero when violations are found AND when the tool
|
|
196
|
-
// itself fails (broken ruby env, malformed template path). cfn-nag with no
|
|
197
|
-
// findings emits a JSON array, so empty stdout typically means the tool
|
|
198
|
-
// crashed before producing output — surface that loudly instead of masking
|
|
199
|
-
// it as failures: 0.
|
|
200
|
-
if (!stdout.trim()) {
|
|
201
|
-
if (result.failed && stderr.trim()) {
|
|
202
|
-
const firstLine = stderr.split(/\r?\n/)[0].trim();
|
|
203
|
-
return { failures: 1, warnings: 0, message: `cfn-nag exited non-zero: ${firstLine}` };
|
|
204
|
-
}
|
|
205
|
-
// P2 — exit 0 with empty stdout is suspicious: even a clean cfn-nag run
|
|
206
|
-
// emits `[]` (or the per-file JSON array). True empty stdout usually means
|
|
207
|
-
// a wrapper/redirect ate the output. Emit an observability nudge but
|
|
208
|
-
// don't false-fail — we already know the tool exited clean.
|
|
209
|
-
logger.warn('cfn-nag produced no output (expected JSON findings array, empty)');
|
|
210
|
-
return { failures: 0, warnings: 0 };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// M5 — guard JSON.parse so a malformed stdout (ruby stack-trace, banner
|
|
214
|
-
// text from a flag mismatch) surfaces as a finding rather than crashing
|
|
215
|
-
// the whole validate command.
|
|
216
|
-
let parsed;
|
|
217
|
-
try {
|
|
218
|
-
parsed = JSON.parse(stdout);
|
|
219
|
-
} catch {
|
|
220
|
-
const preview = stdout.slice(0, 100).replace(/\s+/g, ' ');
|
|
221
|
-
return {
|
|
222
|
-
failures: 1,
|
|
223
|
-
warnings: 0,
|
|
224
|
-
message: `cfn-nag returned non-JSON output (length ${stdout.length}): ${preview}`,
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
// P1 — even with successful JSON parse, the shape must be the per-file
|
|
228
|
-
// findings array. cfn-nag has historically returned `{ "violations": [...] }`
|
|
229
|
-
// in some wrappers and a bare object in error cases; either crashes the
|
|
230
|
-
// for..of below. Surface as a warning instead of an unhandled TypeError.
|
|
231
|
-
if (!Array.isArray(parsed)) {
|
|
232
|
-
logger.warn(`cfn-nag returned unexpected JSON shape: ${typeof parsed}`);
|
|
233
|
-
return { failures: 0, warnings: 1 };
|
|
234
|
-
}
|
|
235
|
-
let failures = 0;
|
|
236
|
-
let warnings = 0;
|
|
237
|
-
for (const entry of parsed) {
|
|
238
|
-
for (const v of entry.file_results?.violations ?? []) {
|
|
239
|
-
if (v.type === 'FAIL') {
|
|
240
|
-
failures++;
|
|
241
|
-
logger.error(`cfn-nag ${v.id}: ${v.message} (${v.logical_resource_ids?.join(', ') ?? ''})`);
|
|
242
|
-
} else if (v.type === 'WARN') {
|
|
243
|
-
warnings++;
|
|
244
|
-
logger.warn(`cfn-nag ${v.id}: ${v.message}`);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
return { failures, warnings };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Wraps runCfnGuard with the Wave 3 overrides flow: when there are no active
|
|
252
|
-
// overrides, run cfn-guard against the original file (preserves the test
|
|
253
|
-
// surface of runCfnGuard). When there are, write the injected copy to a
|
|
254
|
-
// tmpdir and run cfn-guard against that temp path; clean up regardless.
|
|
255
|
-
// Also runs the anti-rot detector against the ORIGINAL template body.
|
|
256
|
-
async function runCfnGuardWithOverrides(file, body, activeOverrides) {
|
|
257
|
-
const unauthorized = detectUnauthorizedSuppressions(body, activeOverrides);
|
|
258
|
-
if (activeOverrides.length === 0) {
|
|
259
|
-
return { guard: await runCfnGuard(file), unauthorized };
|
|
260
|
-
}
|
|
261
|
-
const injected = injectSuppressions(body, activeOverrides);
|
|
262
|
-
const dir = await mkdtemp(join(tmpdir(), 'clawform-validate-'));
|
|
263
|
-
const tempPath = join(dir, 'template.yaml');
|
|
264
|
-
try {
|
|
265
|
-
await writeFile(tempPath, injected, 'utf8');
|
|
266
|
-
const guard = await runCfnGuard(tempPath);
|
|
267
|
-
return { guard, unauthorized };
|
|
268
|
-
} finally {
|
|
269
|
-
await rm(dir, { recursive: true, force: true });
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async function runCfnGuard(file) {
|
|
274
|
-
// Probe for the binary first so a missing install yields a clean "skipped"
|
|
275
|
-
// instead of confusing rule-engine output.
|
|
276
|
-
const probe = await execa('cfn-guard', ['--version'], { reject: false });
|
|
277
|
-
if (toolMissing(probe)) {
|
|
278
|
-
logger.warn('cfn-guard not found. Install: cargo install cfn-guard --locked');
|
|
279
|
-
return { skipped: true, skipReason: 'not installed' };
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const result = await execa(
|
|
283
|
-
'cfn-guard',
|
|
284
|
-
[
|
|
285
|
-
'validate',
|
|
286
|
-
'-d',
|
|
287
|
-
file,
|
|
288
|
-
'-r',
|
|
289
|
-
resolveGuardRulesDir(),
|
|
290
|
-
'--show-summary',
|
|
291
|
-
'all',
|
|
292
|
-
'--output-format',
|
|
293
|
-
'single-line-summary',
|
|
294
|
-
],
|
|
295
|
-
{ reject: false },
|
|
296
|
-
);
|
|
297
|
-
|
|
298
|
-
const stdout = result.stdout ?? '';
|
|
299
|
-
const stderr = result.stderr ?? '';
|
|
300
|
-
|
|
301
|
-
// cfn-guard exits non-zero when any rule fails. Each failing rule line is
|
|
302
|
-
// surfaced as a critical finding; cfn-guard 3.x does not have a "warning"
|
|
303
|
-
// severity, so everything that fires is counted as a failure.
|
|
304
|
-
let failures = 0;
|
|
305
|
-
const warnings = 0;
|
|
306
|
-
if (result.exitCode !== 0) {
|
|
307
|
-
const lines = stdout.split(/\r?\n/).filter((l) => l.trim());
|
|
308
|
-
for (const line of lines) {
|
|
309
|
-
// single-line-summary emits one line per rule outcome; only count FAIL.
|
|
310
|
-
if (/\bFAIL\b/i.test(line) || /Status\s*=\s*FAIL/i.test(line)) {
|
|
311
|
-
failures++;
|
|
312
|
-
logger.error(`cfn-guard ${line.trim()}`);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
if (failures === 0) {
|
|
316
|
-
// Non-zero exit but no parseable FAIL line — surface raw output so the
|
|
317
|
-
// user can debug rule-engine errors (bad rule syntax, missing rules dir).
|
|
318
|
-
failures = 1;
|
|
319
|
-
logger.error(`cfn-guard: ${stderr.trim() || stdout.trim() || 'non-zero exit'}`);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
return { failures, warnings };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function toolMissing(r) {
|
|
326
|
-
if (!r.failed) return false;
|
|
327
|
-
const stderr = r.stderr ?? '';
|
|
328
|
-
const msg = r.shortMessage ?? r.message ?? '';
|
|
329
|
-
// Linux/Mac: spawn ENOENT. Windows cmd: "is not recognized as an internal or external command".
|
|
330
|
-
return r.code === 'ENOENT'
|
|
331
|
-
|| r.errno === -2
|
|
332
|
-
|| /\bENOENT\b/i.test(msg)
|
|
333
|
-
|| /is not recognized as an internal or external command/i.test(stderr)
|
|
334
|
-
|| /command not found/i.test(stderr);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async function runValidateTemplate(body) {
|
|
338
|
-
try {
|
|
339
|
-
const client = cfnClient();
|
|
340
|
-
const out = await client.send(new ValidateTemplateCommand({ TemplateBody: body }));
|
|
341
|
-
if (out.Capabilities?.length) {
|
|
342
|
-
logger.info(`Template requires capabilities: ${out.Capabilities.join(', ')} — pass via --capabilities on deploy.`);
|
|
343
|
-
}
|
|
344
|
-
return { errors: 0, warnings: 0 };
|
|
345
|
-
} catch (err) {
|
|
346
|
-
logger.error(`ValidateTemplate: ${err.message}`);
|
|
347
|
-
return { errors: 1, warnings: 0 };
|
|
348
|
-
}
|
|
349
|
-
}
|
|
1
|
+
import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { execa } from 'execa';
|
|
7
|
+
import { ValidateTemplateCommand } from '@aws-sdk/client-cloudformation';
|
|
8
|
+
import { logger } from '../lib/logger.js';
|
|
9
|
+
import { cfnClient } from '../lib/aws-client.js';
|
|
10
|
+
import {
|
|
11
|
+
loadOverrides,
|
|
12
|
+
filterExpired,
|
|
13
|
+
injectSuppressions,
|
|
14
|
+
detectUnauthorizedSuppressions,
|
|
15
|
+
} from '../lib/lint-overrides.js';
|
|
16
|
+
import { assertLicensed } from '../lib/license.js';
|
|
17
|
+
import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
|
|
18
|
+
|
|
19
|
+
const LINT_LEVEL = { Error: 'E', Warning: 'W', Informational: 'I' };
|
|
20
|
+
const OVERRIDES_PATH = join(process.cwd(), 'rules', 'lint-overrides.md');
|
|
21
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
22
|
+
|
|
23
|
+
// cfn-guard rules ship with the clawform package. A consumer project that
|
|
24
|
+
// installed clawform (plugin cache / node_modules) has no `rules.guard/` in
|
|
25
|
+
// cwd — resolving relative to cwd used to make cfn-guard exit non-zero
|
|
26
|
+
// ("missing rules dir"), which was counted as a CRITICAL and failed every
|
|
27
|
+
// validate for exactly the installed-plugin case. Resolve from the package
|
|
28
|
+
// root; a project-local `rules.guard/` (consumer-authored rules) wins when
|
|
29
|
+
// present so consumers can extend without forking.
|
|
30
|
+
export function resolveGuardRulesDir({ cwd = process.cwd(), pkgRoot = PKG_ROOT } = {}) {
|
|
31
|
+
const local = join(cwd, 'rules.guard');
|
|
32
|
+
if (existsSync(local)) return local;
|
|
33
|
+
return join(pkgRoot, 'rules.guard');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default async function validate({ file, skipAws = false }) {
|
|
37
|
+
await assertLicensed({ command: 'validate', client: resolveLicenseClient() });
|
|
38
|
+
logger.step(`clawform validate ${file}`);
|
|
39
|
+
const body = await readFile(file, 'utf8');
|
|
40
|
+
|
|
41
|
+
const lint = await runCfnLint(file);
|
|
42
|
+
const nag = await runCfnNag(file);
|
|
43
|
+
|
|
44
|
+
// Wave 3 overrides hook: load the audited allowlist, partition expired,
|
|
45
|
+
// inject active suppressions into a temp copy for cfn-guard, then run the
|
|
46
|
+
// anti-rot check against the ORIGINAL template (inline suppressions without
|
|
47
|
+
// an overrides row are folded into the critical count and gate /deploy).
|
|
48
|
+
const overrides = loadOverrides(OVERRIDES_PATH);
|
|
49
|
+
const { active, expired } = filterExpired(overrides, new Date());
|
|
50
|
+
for (const e of expired) {
|
|
51
|
+
logger.warn(
|
|
52
|
+
`lint-override expired ${e.ruleId} (scope=${e.scopeGlob}, expired ${e.expires.toISOString().slice(0, 10)}). ` +
|
|
53
|
+
`Re-evaluate in rules/lint-overrides.md or remove the row.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { guard, unauthorized } = await runCfnGuardWithOverrides(file, body, active);
|
|
58
|
+
|
|
59
|
+
for (const u of unauthorized) {
|
|
60
|
+
logger.error(`unauthorized inline SuppressedRules: ${u.resourceId} suppresses ${u.ruleId} with no rules/lint-overrides.md row`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const aws = skipAws ? null : await runValidateTemplate(body);
|
|
64
|
+
|
|
65
|
+
const critical =
|
|
66
|
+
(lint?.errors ?? 0) + (nag?.failures ?? 0) + (guard?.failures ?? 0) + (aws?.errors ?? 0)
|
|
67
|
+
+ unauthorized.length;
|
|
68
|
+
const warnings = (lint?.warnings ?? 0) + (nag?.warnings ?? 0) + (guard?.warnings ?? 0);
|
|
69
|
+
// A "skipped" tool contributes 0 errors AND 0 warnings under nullish-coalescing —
|
|
70
|
+
// before this gate, a fresh dev box with no cfn-lint / cfn-nag installed would
|
|
71
|
+
// print "All checks passed" exit 0 even though the only thing that ran was the
|
|
72
|
+
// shallow CloudFormation ValidateTemplate. That silently bypasses every Layer-2
|
|
73
|
+
// rule the rule files promise (W11/W12/F2/W2/W9/W35/W74/W2001/...).
|
|
74
|
+
const skipped = [
|
|
75
|
+
lint?.skipped ? 'cfn-lint' : null,
|
|
76
|
+
nag?.skipped ? 'cfn-nag' : null,
|
|
77
|
+
guard?.skipped ? 'cfn-guard' : null,
|
|
78
|
+
!aws ? 'ValidateTemplate (--skip-aws)' : null,
|
|
79
|
+
].filter(Boolean);
|
|
80
|
+
|
|
81
|
+
logger.info('');
|
|
82
|
+
logger.info('Summary:');
|
|
83
|
+
logger.info(` cfn-lint ${formatCount(lint)}`);
|
|
84
|
+
logger.info(` cfn-nag ${formatCount(nag)}`);
|
|
85
|
+
logger.info(` cfn-guard ${formatCount(guard)}`);
|
|
86
|
+
logger.info(` ValidateTemplate ${formatCount(aws)}`);
|
|
87
|
+
// Surface unauthorized inline suppressions in the Summary so a clean
|
|
88
|
+
// lint/nag/guard run still gives the user a visible cause for exit 1.
|
|
89
|
+
logger.info(` lint-overrides ${unauthorized.length === 0 ? '✓ clean' : `🔴 ${unauthorized.length} unauthorized suppression(s)`}`);
|
|
90
|
+
|
|
91
|
+
if (critical > 0) {
|
|
92
|
+
logger.error(`${critical} CRITICAL finding(s). Fix before /deploy.`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
} else if (warnings > 0) {
|
|
95
|
+
logger.warn(`${warnings} warning(s). Review then proceed.`);
|
|
96
|
+
} else if (skipped.length > 0) {
|
|
97
|
+
// Don't print "All checks passed" when we didn't actually run every check.
|
|
98
|
+
logger.warn(
|
|
99
|
+
`Partial pass — ${skipped.length} tool(s) skipped: ${skipped.join(', ')}. ` +
|
|
100
|
+
`Install missing tools (pip install cfn-lint / gem install cfn-nag / cargo install cfn-guard --locked) before relying on this as a deploy gate.`,
|
|
101
|
+
);
|
|
102
|
+
} else {
|
|
103
|
+
logger.ok('All checks passed.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatCount(r) {
|
|
108
|
+
if (!r) return '⊘ skipped';
|
|
109
|
+
if (r.skipped) return `⊘ skipped (${r.skipReason})`;
|
|
110
|
+
const e = r.errors ?? r.failures ?? 0;
|
|
111
|
+
const w = r.warnings ?? 0;
|
|
112
|
+
if (e === 0 && w === 0) return '✓ clean';
|
|
113
|
+
return `${e ? '🔴' : '🟡'} ${e} error(s), ${w} warning(s)`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Exported for unit tests (validate-runners.test.js). The default export is
|
|
117
|
+
// still the orchestrator — these helpers stay internal to validate(), but the
|
|
118
|
+
// runner tests need to drive them directly with a mocked execa.
|
|
119
|
+
export async function runCfnLint(file) {
|
|
120
|
+
const result = await execa('cfn-lint', ['--format', 'json', file], { reject: false });
|
|
121
|
+
if (toolMissing(result)) {
|
|
122
|
+
logger.warn('cfn-lint not found. Install: pip install cfn-lint');
|
|
123
|
+
return { skipped: true, skipReason: 'not installed' };
|
|
124
|
+
}
|
|
125
|
+
const stdout = result.stdout ?? '';
|
|
126
|
+
const stderr = result.stderr ?? '';
|
|
127
|
+
|
|
128
|
+
// M4 — cfn-lint exits non-zero on rule-engine errors (bad template path,
|
|
129
|
+
// unrecognized flag, broken plugin). Previously we fell through to JSON.parse
|
|
130
|
+
// and either crashed (M5) or silently returned 0/0. Surface a real error so
|
|
131
|
+
// /deploy is gated. cfn-lint ALSO exits non-zero when it just found
|
|
132
|
+
// findings — those are handled below via parsed JSON, so we only fail loud
|
|
133
|
+
// when stdout has no parseable findings AND stderr looks like a tool error.
|
|
134
|
+
// Empty stdout is valid (no findings) — short-circuit before the JSON path.
|
|
135
|
+
if (!stdout.trim()) {
|
|
136
|
+
if (result.failed && stderr.trim()) {
|
|
137
|
+
const firstLine = stderr.split(/\r?\n/)[0].trim();
|
|
138
|
+
return { errors: 1, warnings: 0, message: `cfn-lint exited non-zero: ${firstLine}` };
|
|
139
|
+
}
|
|
140
|
+
return { errors: 0, warnings: 0 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// M5 — guard JSON.parse so a malformed stdout (rule-engine crashed mid-output,
|
|
144
|
+
// or cfn-lint printed a non-JSON banner) surfaces as a finding rather than
|
|
145
|
+
// crashing the whole validate command.
|
|
146
|
+
let findings;
|
|
147
|
+
try {
|
|
148
|
+
findings = JSON.parse(stdout);
|
|
149
|
+
} catch {
|
|
150
|
+
const preview = stdout.slice(0, 100).replace(/\s+/g, ' ');
|
|
151
|
+
return {
|
|
152
|
+
errors: 1,
|
|
153
|
+
warnings: 0,
|
|
154
|
+
message: `cfn-lint returned non-JSON output (length ${stdout.length}): ${preview}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// P1 — JSON.parse can succeed on shapes that aren't a findings array
|
|
158
|
+
// (e.g. cfn-lint plugin returning `{ "error": "..." }`, or stdout being
|
|
159
|
+
// a bare `null`/string/number from a wrapped script). Surface as a
|
|
160
|
+
// warning so /deploy is not silently gated on a shape we can't iterate.
|
|
161
|
+
if (!Array.isArray(findings)) {
|
|
162
|
+
logger.warn(`cfn-lint returned unexpected JSON shape: ${typeof findings}`);
|
|
163
|
+
return { errors: 0, warnings: 1 };
|
|
164
|
+
}
|
|
165
|
+
let errors = 0;
|
|
166
|
+
let warnings = 0;
|
|
167
|
+
for (const f of findings) {
|
|
168
|
+
const lvl = LINT_LEVEL[f.Level] ?? 'I';
|
|
169
|
+
if (lvl === 'E') {
|
|
170
|
+
errors++;
|
|
171
|
+
logger.error(`cfn-lint ${f.Rule?.Id ?? ''} ${f.Location?.Start ? `L${f.Location.Start.LineNumber}` : ''}: ${f.Message}`);
|
|
172
|
+
} else if (lvl === 'W') {
|
|
173
|
+
warnings++;
|
|
174
|
+
logger.warn(`cfn-lint ${f.Rule?.Id ?? ''}: ${f.Message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { errors, warnings };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function runCfnNag(file) {
|
|
181
|
+
const cmd = process.platform === 'win32' ? 'cfn_nag_scan.bat' : 'cfn_nag_scan';
|
|
182
|
+
// P3 — use the short form `-o json` for the output-format flag. Both `-o`
|
|
183
|
+
// and `--output-format` are valid cfn-nag flags, but the test suite asserts
|
|
184
|
+
// the short form so that a future refactor doesn't silently drop into
|
|
185
|
+
// cfn-nag's default text output (which would trip the M5 JSON.parse catch
|
|
186
|
+
// as a false-positive critical finding on every run).
|
|
187
|
+
const result = await execa(cmd, ['--input-path', file, '-o', 'json'], { reject: false });
|
|
188
|
+
if (toolMissing(result)) {
|
|
189
|
+
logger.warn('cfn-nag not found. Install: gem install cfn-nag');
|
|
190
|
+
return { skipped: true, skipReason: 'not installed' };
|
|
191
|
+
}
|
|
192
|
+
const stdout = result.stdout ?? '';
|
|
193
|
+
const stderr = result.stderr ?? '';
|
|
194
|
+
|
|
195
|
+
// M4 — cfn-nag exits non-zero when violations are found AND when the tool
|
|
196
|
+
// itself fails (broken ruby env, malformed template path). cfn-nag with no
|
|
197
|
+
// findings emits a JSON array, so empty stdout typically means the tool
|
|
198
|
+
// crashed before producing output — surface that loudly instead of masking
|
|
199
|
+
// it as failures: 0.
|
|
200
|
+
if (!stdout.trim()) {
|
|
201
|
+
if (result.failed && stderr.trim()) {
|
|
202
|
+
const firstLine = stderr.split(/\r?\n/)[0].trim();
|
|
203
|
+
return { failures: 1, warnings: 0, message: `cfn-nag exited non-zero: ${firstLine}` };
|
|
204
|
+
}
|
|
205
|
+
// P2 — exit 0 with empty stdout is suspicious: even a clean cfn-nag run
|
|
206
|
+
// emits `[]` (or the per-file JSON array). True empty stdout usually means
|
|
207
|
+
// a wrapper/redirect ate the output. Emit an observability nudge but
|
|
208
|
+
// don't false-fail — we already know the tool exited clean.
|
|
209
|
+
logger.warn('cfn-nag produced no output (expected JSON findings array, empty)');
|
|
210
|
+
return { failures: 0, warnings: 0 };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// M5 — guard JSON.parse so a malformed stdout (ruby stack-trace, banner
|
|
214
|
+
// text from a flag mismatch) surfaces as a finding rather than crashing
|
|
215
|
+
// the whole validate command.
|
|
216
|
+
let parsed;
|
|
217
|
+
try {
|
|
218
|
+
parsed = JSON.parse(stdout);
|
|
219
|
+
} catch {
|
|
220
|
+
const preview = stdout.slice(0, 100).replace(/\s+/g, ' ');
|
|
221
|
+
return {
|
|
222
|
+
failures: 1,
|
|
223
|
+
warnings: 0,
|
|
224
|
+
message: `cfn-nag returned non-JSON output (length ${stdout.length}): ${preview}`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// P1 — even with successful JSON parse, the shape must be the per-file
|
|
228
|
+
// findings array. cfn-nag has historically returned `{ "violations": [...] }`
|
|
229
|
+
// in some wrappers and a bare object in error cases; either crashes the
|
|
230
|
+
// for..of below. Surface as a warning instead of an unhandled TypeError.
|
|
231
|
+
if (!Array.isArray(parsed)) {
|
|
232
|
+
logger.warn(`cfn-nag returned unexpected JSON shape: ${typeof parsed}`);
|
|
233
|
+
return { failures: 0, warnings: 1 };
|
|
234
|
+
}
|
|
235
|
+
let failures = 0;
|
|
236
|
+
let warnings = 0;
|
|
237
|
+
for (const entry of parsed) {
|
|
238
|
+
for (const v of entry.file_results?.violations ?? []) {
|
|
239
|
+
if (v.type === 'FAIL') {
|
|
240
|
+
failures++;
|
|
241
|
+
logger.error(`cfn-nag ${v.id}: ${v.message} (${v.logical_resource_ids?.join(', ') ?? ''})`);
|
|
242
|
+
} else if (v.type === 'WARN') {
|
|
243
|
+
warnings++;
|
|
244
|
+
logger.warn(`cfn-nag ${v.id}: ${v.message}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { failures, warnings };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Wraps runCfnGuard with the Wave 3 overrides flow: when there are no active
|
|
252
|
+
// overrides, run cfn-guard against the original file (preserves the test
|
|
253
|
+
// surface of runCfnGuard). When there are, write the injected copy to a
|
|
254
|
+
// tmpdir and run cfn-guard against that temp path; clean up regardless.
|
|
255
|
+
// Also runs the anti-rot detector against the ORIGINAL template body.
|
|
256
|
+
async function runCfnGuardWithOverrides(file, body, activeOverrides) {
|
|
257
|
+
const unauthorized = detectUnauthorizedSuppressions(body, activeOverrides);
|
|
258
|
+
if (activeOverrides.length === 0) {
|
|
259
|
+
return { guard: await runCfnGuard(file), unauthorized };
|
|
260
|
+
}
|
|
261
|
+
const injected = injectSuppressions(body, activeOverrides);
|
|
262
|
+
const dir = await mkdtemp(join(tmpdir(), 'clawform-validate-'));
|
|
263
|
+
const tempPath = join(dir, 'template.yaml');
|
|
264
|
+
try {
|
|
265
|
+
await writeFile(tempPath, injected, 'utf8');
|
|
266
|
+
const guard = await runCfnGuard(tempPath);
|
|
267
|
+
return { guard, unauthorized };
|
|
268
|
+
} finally {
|
|
269
|
+
await rm(dir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function runCfnGuard(file) {
|
|
274
|
+
// Probe for the binary first so a missing install yields a clean "skipped"
|
|
275
|
+
// instead of confusing rule-engine output.
|
|
276
|
+
const probe = await execa('cfn-guard', ['--version'], { reject: false });
|
|
277
|
+
if (toolMissing(probe)) {
|
|
278
|
+
logger.warn('cfn-guard not found. Install: cargo install cfn-guard --locked');
|
|
279
|
+
return { skipped: true, skipReason: 'not installed' };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const result = await execa(
|
|
283
|
+
'cfn-guard',
|
|
284
|
+
[
|
|
285
|
+
'validate',
|
|
286
|
+
'-d',
|
|
287
|
+
file,
|
|
288
|
+
'-r',
|
|
289
|
+
resolveGuardRulesDir(),
|
|
290
|
+
'--show-summary',
|
|
291
|
+
'all',
|
|
292
|
+
'--output-format',
|
|
293
|
+
'single-line-summary',
|
|
294
|
+
],
|
|
295
|
+
{ reject: false },
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const stdout = result.stdout ?? '';
|
|
299
|
+
const stderr = result.stderr ?? '';
|
|
300
|
+
|
|
301
|
+
// cfn-guard exits non-zero when any rule fails. Each failing rule line is
|
|
302
|
+
// surfaced as a critical finding; cfn-guard 3.x does not have a "warning"
|
|
303
|
+
// severity, so everything that fires is counted as a failure.
|
|
304
|
+
let failures = 0;
|
|
305
|
+
const warnings = 0;
|
|
306
|
+
if (result.exitCode !== 0) {
|
|
307
|
+
const lines = stdout.split(/\r?\n/).filter((l) => l.trim());
|
|
308
|
+
for (const line of lines) {
|
|
309
|
+
// single-line-summary emits one line per rule outcome; only count FAIL.
|
|
310
|
+
if (/\bFAIL\b/i.test(line) || /Status\s*=\s*FAIL/i.test(line)) {
|
|
311
|
+
failures++;
|
|
312
|
+
logger.error(`cfn-guard ${line.trim()}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (failures === 0) {
|
|
316
|
+
// Non-zero exit but no parseable FAIL line — surface raw output so the
|
|
317
|
+
// user can debug rule-engine errors (bad rule syntax, missing rules dir).
|
|
318
|
+
failures = 1;
|
|
319
|
+
logger.error(`cfn-guard: ${stderr.trim() || stdout.trim() || 'non-zero exit'}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { failures, warnings };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function toolMissing(r) {
|
|
326
|
+
if (!r.failed) return false;
|
|
327
|
+
const stderr = r.stderr ?? '';
|
|
328
|
+
const msg = r.shortMessage ?? r.message ?? '';
|
|
329
|
+
// Linux/Mac: spawn ENOENT. Windows cmd: "is not recognized as an internal or external command".
|
|
330
|
+
return r.code === 'ENOENT'
|
|
331
|
+
|| r.errno === -2
|
|
332
|
+
|| /\bENOENT\b/i.test(msg)
|
|
333
|
+
|| /is not recognized as an internal or external command/i.test(stderr)
|
|
334
|
+
|| /command not found/i.test(stderr);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function runValidateTemplate(body) {
|
|
338
|
+
try {
|
|
339
|
+
const client = cfnClient();
|
|
340
|
+
const out = await client.send(new ValidateTemplateCommand({ TemplateBody: body }));
|
|
341
|
+
if (out.Capabilities?.length) {
|
|
342
|
+
logger.info(`Template requires capabilities: ${out.Capabilities.join(', ')} — pass via --capabilities on deploy.`);
|
|
343
|
+
}
|
|
344
|
+
return { errors: 0, warnings: 0 };
|
|
345
|
+
} catch (err) {
|
|
346
|
+
logger.error(`ValidateTemplate: ${err.message}`);
|
|
347
|
+
return { errors: 1, warnings: 0 };
|
|
348
|
+
}
|
|
349
|
+
}
|