@thejoseki/clawform 0.0.1 → 2.2.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/LICENSE +95 -4
- package/README.md +87 -10
- package/bin/clawform.js +151 -16
- package/package.json +47 -11
- package/src/aws/catalog.js +116 -0
- package/src/aws/changeset.js +231 -0
- package/src/aws/drift.js +59 -0
- package/src/aws/exports.js +213 -0
- package/src/aws/inventory.js +156 -0
- package/src/commands/activate.js +105 -0
- package/src/commands/ci-init.js +212 -0
- package/src/commands/cost-report.js +166 -0
- package/src/commands/deactivate.js +36 -0
- package/src/commands/deploy.js +413 -0
- package/src/commands/diff.js +39 -0
- package/src/commands/drift.js +35 -0
- package/src/commands/init-plugin.js +168 -0
- package/src/commands/init.js +360 -0
- package/src/commands/license-status.js +28 -0
- package/src/commands/rollback.js +126 -0
- package/src/commands/status.js +150 -0
- package/src/commands/sync.js +53 -0
- package/src/commands/validate.js +349 -0
- package/src/hooks/block-dangerous-eval.js +160 -0
- package/src/hooks/block-dangerous.js +62 -0
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
- package/src/hooks/cfn-lint-after-edit.js +81 -0
- package/src/hooks/check-catalog-fresh-eval.js +63 -0
- package/src/hooks/check-catalog-fresh.js +33 -0
- package/src/hooks/session-context-eval.js +81 -0
- package/src/hooks/session-context.js +41 -0
- package/src/hooks/subagent-context-eval.js +44 -0
- package/src/hooks/subagent-context.js +48 -0
- package/src/lib/audit-synthesis.js +24 -0
- package/src/lib/aws-client.js +15 -0
- package/src/lib/cfn-yaml.js +92 -0
- package/src/lib/config.js +76 -0
- package/src/lib/constants.js +85 -0
- package/src/lib/defaults.js +16 -0
- package/src/lib/fingerprint.js +65 -0
- package/src/lib/install-workflow.js +28 -0
- package/src/lib/kit-source.js +81 -0
- package/src/lib/license-client.js +84 -0
- package/src/lib/license-config.js +162 -0
- package/src/lib/license-store.js +107 -0
- package/src/lib/license.js +146 -0
- package/src/lib/lint-overrides.js +226 -0
- package/src/lib/logger.js +17 -0
- package/src/lib/payload.js +74 -0
- package/src/lib/project-config.js +145 -0
- package/src/lib/providers/polar.js +215 -0
- package/src/lib/rename-compat.js +50 -0
- package/src/lib/session-state.js +91 -0
- package/src/lib/trial-claim.js +65 -0
- package/src/lib/watermark.js +65 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// License enforcement decision — the one function value commands call before
|
|
2
|
+
// doing work. Everything impure is injected (store, clock, validation client,
|
|
3
|
+
// env) so the state matrix tests deterministically; production call sites pass
|
|
4
|
+
// nothing and get the real store + adapter.
|
|
5
|
+
//
|
|
6
|
+
// The client seam is vendor-agnostic on purpose: whichever vendor survives the
|
|
7
|
+
// payout test implements `validate(key) → { status, reason? }` with statuses
|
|
8
|
+
// 'valid' — vendor confirmed the key
|
|
9
|
+
// 'invalid' — vendor answered and said no (revoked / refunded / not found)
|
|
10
|
+
// 'error' — vendor unreachable (network down, vendor outage)
|
|
11
|
+
// The distinction between 'invalid' and 'error' is the whole design: outages
|
|
12
|
+
// get the offline grace window, revocations do not.
|
|
13
|
+
|
|
14
|
+
import { readLicense as storeRead, writeLicense as storeWrite } from './license-store.js';
|
|
15
|
+
import { readEnv } from './rename-compat.js';
|
|
16
|
+
import { logger } from './logger.js';
|
|
17
|
+
import { GRACE_DAYS, REVALIDATE_HOURS, CLOCK_SKEW_HOURS } from './license-config.js';
|
|
18
|
+
|
|
19
|
+
// Emergency operations are never license-gated: an expired card must not be
|
|
20
|
+
// the reason a stuck production stack cannot be rolled back. Additions to this
|
|
21
|
+
// list are a product decision, not a convenience — a test pins its contents.
|
|
22
|
+
export const NEVER_GATED = new Set(['deploy', 'rollback']);
|
|
23
|
+
|
|
24
|
+
const HOUR = 3_600_000;
|
|
25
|
+
const DAY = 24 * HOUR;
|
|
26
|
+
|
|
27
|
+
function hoursSince(iso, nowMs) {
|
|
28
|
+
const t = Date.parse(iso ?? '');
|
|
29
|
+
return Number.isFinite(t) ? (nowMs - t) / HOUR : Infinity;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function assertLicensed({
|
|
33
|
+
command,
|
|
34
|
+
readLicense = storeRead,
|
|
35
|
+
writeLicense = storeWrite,
|
|
36
|
+
client,
|
|
37
|
+
now = Date.now,
|
|
38
|
+
env = process.env,
|
|
39
|
+
warn = (m) => logger.warn(m),
|
|
40
|
+
} = {}) {
|
|
41
|
+
if (NEVER_GATED.has(command)) return;
|
|
42
|
+
|
|
43
|
+
// Dormant gate: until a vendor adapter ships, there is no way to activate a
|
|
44
|
+
// key at all, so enforcement would lock every user out. A client that marks
|
|
45
|
+
// itself configured:false keeps the gate open; the adapter flips this, and a
|
|
46
|
+
// pinned test makes that flip a reviewed decision.
|
|
47
|
+
if (client?.configured === false) return;
|
|
48
|
+
|
|
49
|
+
// CI path: a key in the environment is validated on every run and nothing is
|
|
50
|
+
// persisted — CI boxes are ephemeral, so a local cache (and with it the
|
|
51
|
+
// offline grace window) would be meaningless there.
|
|
52
|
+
const envKey = readEnv('LICENSE_KEY', env);
|
|
53
|
+
if (envKey) {
|
|
54
|
+
const res = await client.validate(envKey);
|
|
55
|
+
if (res.status === 'valid') return;
|
|
56
|
+
if (res.status === 'invalid') {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`CLAWFORM_LICENSE_KEY was rejected (${res.reason ?? 'invalid'}). ` +
|
|
59
|
+
'Check the key, or buy a seat — see the README for where.',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Could not verify CLAWFORM_LICENSE_KEY (${res.reason ?? 'network error'}) and CI has no ` +
|
|
64
|
+
'offline grace. Retry when the license service is reachable.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const record = readLicense();
|
|
69
|
+
if (record.status !== 'active' || !record.key) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`'clawform ${command}' needs an activated license. Run: clawform activate <key> ` +
|
|
72
|
+
'(the key is in your purchase email).',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A trial carries its own end date, handed over by the vendor at activation
|
|
77
|
+
// and covered by the record's signature so it cannot be edited outward.
|
|
78
|
+
//
|
|
79
|
+
// Checked BEFORE the freshness shortcut below, which returns without touching
|
|
80
|
+
// the network: a recently-validated record would otherwise sail past its own
|
|
81
|
+
// expiry for another day.
|
|
82
|
+
//
|
|
83
|
+
// Expiry is a property of the licence, not of connectivity, so the offline
|
|
84
|
+
// grace never reaches it — and the message says "trial", never "offline". A
|
|
85
|
+
// buyer told their connection failed will go looking for a fault that is not
|
|
86
|
+
// there.
|
|
87
|
+
//
|
|
88
|
+
// A record with no expiresAt is a perpetual seat, not one that expired at the
|
|
89
|
+
// epoch. Absence has to be checked explicitly.
|
|
90
|
+
if (record.expiresAt) {
|
|
91
|
+
const endsAt = Date.parse(record.expiresAt);
|
|
92
|
+
if (Number.isFinite(endsAt) && now() >= endsAt) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Your Clawform trial ended on ${record.expiresAt.slice(0, 10)}, so 'clawform ${command}' `
|
|
95
|
+
+ 'is no longer available. Buy a seat at https://clawform.thejoseki.com and run '
|
|
96
|
+
+ '`clawform activate <key>`. Note that `clawform deploy` and `clawform rollback` keep '
|
|
97
|
+
+ 'working either way — an expired licence never strands a stack.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Freshness has a floor as well as a ceiling. A record stamped in the future
|
|
103
|
+
// yields a negative age, which would read as "verified moments ago" forever —
|
|
104
|
+
// a permanent bypass that never contacts the vendor again. Below the floor we
|
|
105
|
+
// revalidate rather than refuse, so a genuinely fast clock costs a network
|
|
106
|
+
// call instead of an accusation.
|
|
107
|
+
const age = hoursSince(record.lastValidatedAt, now());
|
|
108
|
+
if (age >= -CLOCK_SKEW_HOURS && age < REVALIDATE_HOURS) return; // fresh verdict — no network
|
|
109
|
+
|
|
110
|
+
const res = await client.validate(record.key);
|
|
111
|
+
|
|
112
|
+
if (res.status === 'valid') {
|
|
113
|
+
// Carry a refreshed expiry through: the vendor is authoritative about it,
|
|
114
|
+
// and an extended trial should take effect without a re-activation.
|
|
115
|
+
writeLicense({
|
|
116
|
+
...record,
|
|
117
|
+
lastValidatedAt: new Date(now()).toISOString(),
|
|
118
|
+
...(res.expiresAt ? { expiresAt: res.expiresAt } : {}),
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (res.status === 'invalid') {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`This license is no longer valid (${res.reason ?? 'revoked'}). ` +
|
|
126
|
+
'If you believe this is a mistake, contact support with your purchase email.',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Vendor unreachable — fall back on the grace window measured from the last
|
|
131
|
+
// SUCCESSFUL validation, so a machine that keeps failing to validate does not
|
|
132
|
+
// keep re-arming its own grace.
|
|
133
|
+
const graceLeftDays = GRACE_DAYS - age / 24;
|
|
134
|
+
if (graceLeftDays > 0) {
|
|
135
|
+
warn(
|
|
136
|
+
`Could not reach the license service to verify (${res.reason ?? 'offline'}). ` +
|
|
137
|
+
`Working offline — ${Math.ceil(graceLeftDays)} day(s) of grace remaining.`,
|
|
138
|
+
);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
throw new Error(
|
|
143
|
+
`The license could not be verified for more than ${GRACE_DAYS} days (offline or the ` +
|
|
144
|
+
'license service was unreachable). Reconnect once and the grace window resets.',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Plugin payload codec: a bundle of text files → one compressed, encrypted
|
|
2
|
+
// blob shipped inside the public npm tarball, decrypted at `init plugin` time
|
|
3
|
+
// so a non-buyer cannot read the plugin content.
|
|
4
|
+
//
|
|
5
|
+
// Deterrence, not secrecy: the decrypt key ships in the CLI (the accepted "1a"
|
|
6
|
+
// model). What this module guarantees is a lossless round-trip and that
|
|
7
|
+
// tampering or a wrong key is CAUGHT (AES-256-GCM auth tag), never silently
|
|
8
|
+
// turned into garbage. Uses only node:crypto + node:zlib — no dependency.
|
|
9
|
+
//
|
|
10
|
+
// The bundle is JSON {v, files:{path:content}} because every plugin file is
|
|
11
|
+
// text (md/yaml/json/js/py). If a binary asset ever needs shipping, switch the
|
|
12
|
+
// per-file encoding — the format version guards that migration.
|
|
13
|
+
|
|
14
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
15
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
16
|
+
|
|
17
|
+
const BUNDLE_VERSION = 1;
|
|
18
|
+
const ALGO = 'aes-256-gcm';
|
|
19
|
+
const IV_LEN = 12; // GCM standard nonce length
|
|
20
|
+
const TAG_LEN = 16; // GCM auth tag length
|
|
21
|
+
const KEY_LEN = 32; // AES-256
|
|
22
|
+
|
|
23
|
+
export function packFiles(entries) {
|
|
24
|
+
const files = {};
|
|
25
|
+
for (const { path, content } of entries) files[path] = content;
|
|
26
|
+
const json = JSON.stringify({ v: BUNDLE_VERSION, files });
|
|
27
|
+
return gzipSync(Buffer.from(json, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function unpackFiles(buffer) {
|
|
31
|
+
const obj = JSON.parse(gunzipSync(buffer).toString('utf8'));
|
|
32
|
+
if (obj.v !== BUNDLE_VERSION) {
|
|
33
|
+
throw new Error(`Unsupported payload bundle version ${obj.v} (expected ${BUNDLE_VERSION}).`);
|
|
34
|
+
}
|
|
35
|
+
// Preserve insertion order of the original entries — Object key order is
|
|
36
|
+
// stable for string keys, which is enough for a lossless round-trip.
|
|
37
|
+
return Object.entries(obj.files).map(([path, content]) => ({ path, content }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertKey(key) {
|
|
41
|
+
if (!Buffer.isBuffer(key) || key.length !== KEY_LEN) {
|
|
42
|
+
throw new Error(`Encryption key must be a ${KEY_LEN}-byte Buffer.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Output layout: iv (12) ‖ authTag (16) ‖ ciphertext. Self-describing so
|
|
47
|
+
// decrypt needs only the key.
|
|
48
|
+
export function encrypt(data, key) {
|
|
49
|
+
assertKey(key);
|
|
50
|
+
const iv = randomBytes(IV_LEN);
|
|
51
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
52
|
+
const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
53
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function decrypt(blob, key) {
|
|
57
|
+
assertKey(key);
|
|
58
|
+
const iv = blob.subarray(0, IV_LEN);
|
|
59
|
+
const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
|
60
|
+
const ciphertext = blob.subarray(IV_LEN + TAG_LEN);
|
|
61
|
+
const decipher = createDecipheriv(ALGO, key, iv);
|
|
62
|
+
decipher.setAuthTag(tag);
|
|
63
|
+
// .final() throws if the tag does not verify — a wrong key or a tampered
|
|
64
|
+
// blob both land here, so callers never see corrupt plaintext.
|
|
65
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function packAndEncrypt(entries, key) {
|
|
69
|
+
return encrypt(packFiles(entries), key);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function decryptAndUnpack(blob, key) {
|
|
73
|
+
return unpackFiles(decrypt(blob, key));
|
|
74
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Consumer-config loader for Clawform. Reads <projectDir>/clawform.config.json,
|
|
2
|
+
// merges it onto the built-in defaults, returns a frozen object. Pure I/O — no
|
|
3
|
+
// validation of "is customer set"; callers that need it (naming.js) check at
|
|
4
|
+
// the point they need a value, with a focused error message.
|
|
5
|
+
//
|
|
6
|
+
// Merge order (low → high):
|
|
7
|
+
// 1. DEFAULT_CONFIG (src/lib/defaults.js)
|
|
8
|
+
// 2. <projectDir>/clawform.config.json (consumer-committed, may be missing)
|
|
9
|
+
// 3. caller-supplied overrides (CLI flags)
|
|
10
|
+
//
|
|
11
|
+
// Missing file → silently skipped (a fresh consumer is a valid state).
|
|
12
|
+
// Malformed JSON → throws with the file path and the parse error.
|
|
13
|
+
// Wrong shape (e.g. → throws — we'd rather fail loud than ship Acme's defaults under
|
|
14
|
+
// legacy_prefixes: a wrong key.
|
|
15
|
+
// "OLDNET")
|
|
16
|
+
|
|
17
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
18
|
+
import { join, basename } from 'node:path';
|
|
19
|
+
import { DEFAULT_CONFIG } from './defaults.js';
|
|
20
|
+
import {
|
|
21
|
+
CONFIG_FILENAMES,
|
|
22
|
+
isDeprecatedConfigFilename,
|
|
23
|
+
warnDeprecatedConfigFilename,
|
|
24
|
+
} from './rename-compat.js';
|
|
25
|
+
|
|
26
|
+
const ARRAY_KEYS = new Set(['envs', 'legacy_prefixes', 'service_shortnames_extra']);
|
|
27
|
+
const OBJECT_KEYS = new Set(['accounts', 'tags_defaults']);
|
|
28
|
+
const STRING_KEYS = new Set(['customer', 'region']);
|
|
29
|
+
|
|
30
|
+
// First match wins, so a project mid-rename that still has both files gets the
|
|
31
|
+
// new one. Silent about the old name here — the warning belongs on the read,
|
|
32
|
+
// not on every existence probe.
|
|
33
|
+
export function findConfigPath(projectDir) {
|
|
34
|
+
if (!projectDir) return null;
|
|
35
|
+
for (const name of CONFIG_FILENAMES) {
|
|
36
|
+
const p = join(projectDir, name);
|
|
37
|
+
if (existsSync(p)) return p;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readProjectConfig(projectDir) {
|
|
43
|
+
const p = findConfigPath(projectDir);
|
|
44
|
+
if (!p) return {};
|
|
45
|
+
if (isDeprecatedConfigFilename(basename(p))) warnDeprecatedConfigFilename(p);
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = readFileSync(p, 'utf8');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(`Failed to read ${p}: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw new Error(`Malformed JSON in ${p}: ${err.message}`);
|
|
57
|
+
}
|
|
58
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
59
|
+
throw new Error(`Expected JSON object in ${p}, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
|
|
60
|
+
}
|
|
61
|
+
return validateShape(parsed, p);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateShape(cfg, sourcePath) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
67
|
+
if (k.startsWith('$')) continue;
|
|
68
|
+
if (STRING_KEYS.has(k)) {
|
|
69
|
+
if (typeof v !== 'string') throw new Error(`${sourcePath}: "${k}" must be a string`);
|
|
70
|
+
out[k] = v;
|
|
71
|
+
} else if (ARRAY_KEYS.has(k)) {
|
|
72
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
|
|
73
|
+
throw new Error(`${sourcePath}: "${k}" must be an array of strings`);
|
|
74
|
+
}
|
|
75
|
+
out[k] = [...v];
|
|
76
|
+
} else if (OBJECT_KEYS.has(k)) {
|
|
77
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
|
|
78
|
+
throw new Error(`${sourcePath}: "${k}" must be an object`);
|
|
79
|
+
}
|
|
80
|
+
out[k] = { ...v };
|
|
81
|
+
} else {
|
|
82
|
+
// Unknown key — keep verbatim so the consumer can add metadata, but don't
|
|
83
|
+
// act on it. This trades strictness for forward-compatibility.
|
|
84
|
+
out[k] = v;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function loadProjectConfig({ projectDir, overrides = {} } = {}) {
|
|
91
|
+
const fromFile = readProjectConfig(projectDir);
|
|
92
|
+
const merged = mergeShallow(DEFAULT_CONFIG, fromFile, overrides);
|
|
93
|
+
return Object.freeze(merged);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mergeShallow(...sources) {
|
|
97
|
+
const out = {
|
|
98
|
+
...DEFAULT_CONFIG,
|
|
99
|
+
accounts: { ...DEFAULT_CONFIG.accounts },
|
|
100
|
+
tags_defaults: { ...DEFAULT_CONFIG.tags_defaults },
|
|
101
|
+
envs: [...DEFAULT_CONFIG.envs],
|
|
102
|
+
legacy_prefixes: [...DEFAULT_CONFIG.legacy_prefixes],
|
|
103
|
+
service_shortnames_extra: [...DEFAULT_CONFIG.service_shortnames_extra],
|
|
104
|
+
};
|
|
105
|
+
for (const src of sources) {
|
|
106
|
+
if (!src) continue;
|
|
107
|
+
for (const [k, v] of Object.entries(src)) {
|
|
108
|
+
if (OBJECT_KEYS.has(k) && v && typeof v === 'object' && !Array.isArray(v)) {
|
|
109
|
+
out[k] = { ...out[k], ...v };
|
|
110
|
+
} else if (ARRAY_KEYS.has(k) && Array.isArray(v)) {
|
|
111
|
+
out[k] = [...v];
|
|
112
|
+
} else {
|
|
113
|
+
out[k] = v;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveProjectDir(explicit) {
|
|
121
|
+
return explicit || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Build the LEGACY_PREFIX_RE used by the hook from a runtime list. Returns null
|
|
125
|
+
// when the list is empty — the hook treats null as "no legacy-freeze gate".
|
|
126
|
+
//
|
|
127
|
+
// Unanchored (\b...) on purpose: the hook applies it to a whole COMMAND LINE,
|
|
128
|
+
// where the stack name appears mid-string. Do NOT reuse this against a bare
|
|
129
|
+
// export/stack name — `\b` treats every hyphen as a boundary, so a legacy token
|
|
130
|
+
// appearing mid-name (`p-acme-…-OLDAPP-arn`) would false-positive. Use the anchored
|
|
131
|
+
// builder below for whole-name checks.
|
|
132
|
+
export function buildLegacyPrefixRegex(prefixes) {
|
|
133
|
+
if (!Array.isArray(prefixes) || prefixes.length === 0) return null;
|
|
134
|
+
const escaped = prefixes.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
135
|
+
return new RegExp(`\\b(${escaped.join('|')})-[A-Za-z0-9-]+`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Anchored variant for matching a WHOLE name (export name, stack name) rather
|
|
139
|
+
// than a substring of a command line: the legacy prefix must be the name's
|
|
140
|
+
// leading segment. `OLDNET-VPC` matches; `p-acme-pri-lmd-payroll-OLDAPP-arn` does not.
|
|
141
|
+
export function buildAnchoredLegacyPrefixRegex(prefixes) {
|
|
142
|
+
if (!Array.isArray(prefixes) || prefixes.length === 0) return null;
|
|
143
|
+
const escaped = prefixes.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
144
|
+
return new RegExp(`^(${escaped.join('|')})-`);
|
|
145
|
+
}
|