@intentius/chant-lexicon-aws 0.45.0 → 0.49.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.
Files changed (147) hide show
  1. package/dist/api/read-client.d.ts +10 -2
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/components/cloud-executor.d.ts.map +1 -1
  4. package/dist/composites/agentcore-agent.d.ts +26 -1
  5. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  6. package/dist/composites/bucket-deployment.d.ts +82 -0
  7. package/dist/composites/bucket-deployment.d.ts.map +1 -0
  8. package/dist/composites/dynamodb-table.d.ts +45 -0
  9. package/dist/composites/dynamodb-table.d.ts.map +1 -0
  10. package/dist/composites/ec2-instance-bundle.d.ts +61 -0
  11. package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
  12. package/dist/composites/ecr-repository.d.ts +49 -0
  13. package/dist/composites/ecr-repository.d.ts.map +1 -0
  14. package/dist/composites/fargate-service.d.ts +16 -0
  15. package/dist/composites/fargate-service.d.ts.map +1 -1
  16. package/dist/composites/index.d.ts +16 -2
  17. package/dist/composites/index.d.ts.map +1 -1
  18. package/dist/composites/lambda-function.d.ts +4 -4
  19. package/dist/composites/lambda-function.d.ts.map +1 -1
  20. package/dist/composites/monitoring-stack.d.ts +99 -0
  21. package/dist/composites/monitoring-stack.d.ts.map +1 -0
  22. package/dist/composites/nlb-service.d.ts +48 -0
  23. package/dist/composites/nlb-service.d.ts.map +1 -0
  24. package/dist/composites/step-functions-workflow.d.ts +36 -0
  25. package/dist/composites/step-functions-workflow.d.ts.map +1 -0
  26. package/dist/deep-observe.d.ts +45 -0
  27. package/dist/deep-observe.d.ts.map +1 -1
  28. package/dist/deep-topology.d.ts +104 -0
  29. package/dist/deep-topology.d.ts.map +1 -0
  30. package/dist/effect-receipt-row.d.ts +92 -0
  31. package/dist/effect-receipt-row.d.ts.map +1 -0
  32. package/dist/index.d.ts +6 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/integrity.json +9 -4
  35. package/dist/lint/audit-catalog.d.ts.map +1 -1
  36. package/dist/lint/post-synth/cf-refs.d.ts +24 -0
  37. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  38. package/dist/lint/post-synth/index.d.ts.map +1 -1
  39. package/dist/lint/post-synth/waw059.d.ts +36 -0
  40. package/dist/lint/post-synth/waw059.d.ts.map +1 -0
  41. package/dist/lint/post-synth/waw060.d.ts +16 -0
  42. package/dist/lint/post-synth/waw060.d.ts.map +1 -0
  43. package/dist/lint/post-synth/waw061.d.ts +19 -0
  44. package/dist/lint/post-synth/waw061.d.ts.map +1 -0
  45. package/dist/lint/post-synth/waw062.d.ts +19 -0
  46. package/dist/lint/post-synth/waw062.d.ts.map +1 -0
  47. package/dist/lint/post-synth/waw063.d.ts +35 -0
  48. package/dist/lint/post-synth/waw063.d.ts.map +1 -0
  49. package/dist/manifest.json +1 -1
  50. package/dist/okf/index.md +5 -0
  51. package/dist/okf/rules/WAW059.md +25 -0
  52. package/dist/okf/rules/WAW060.md +17 -0
  53. package/dist/okf/rules/WAW061.md +17 -0
  54. package/dist/okf/rules/WAW062.md +27 -0
  55. package/dist/okf/rules/WAW063.md +19 -0
  56. package/dist/okf/types/Action.md +2 -0
  57. package/dist/okf/types/AutoScalingGroup.md +1 -0
  58. package/dist/okf/types/Bucket.md +2 -0
  59. package/dist/okf/types/ECRRepository.md +1 -0
  60. package/dist/okf/types/Function.md +1 -0
  61. package/dist/okf/types/GlobalTable.md +4 -0
  62. package/dist/okf/types/IamPolicy.md +3 -0
  63. package/dist/okf/types/InstanceProfile.md +4 -0
  64. package/dist/okf/types/LaunchTemplate.md +1 -0
  65. package/dist/okf/types/LoadBalancer.md +1 -0
  66. package/dist/okf/types/ManagedPolicy.md +4 -0
  67. package/dist/okf/types/Map.md +2 -0
  68. package/dist/okf/types/Queue.md +1 -0
  69. package/dist/okf/types/Role.md +3 -0
  70. package/dist/okf/types/SecurityGroup.md +1 -0
  71. package/dist/okf/types/StateMachine.md +4 -0
  72. package/dist/okf/types/Subnet.md +4 -0
  73. package/dist/okf/types/Table.md +2 -0
  74. package/dist/okf/types/Type.md +5 -0
  75. package/dist/okf/types/Vpc.md +1 -0
  76. package/dist/op/activities/aws-apply.d.ts +48 -5
  77. package/dist/op/activities/aws-apply.d.ts.map +1 -1
  78. package/dist/op/activities/index.d.ts +11 -4
  79. package/dist/op/activities/index.d.ts.map +1 -1
  80. package/dist/ownership.d.ts +18 -0
  81. package/dist/ownership.d.ts.map +1 -1
  82. package/dist/plugin.d.ts.map +1 -1
  83. package/dist/receipt-store.d.ts +109 -0
  84. package/dist/receipt-store.d.ts.map +1 -0
  85. package/dist/rules/cf-refs.ts +62 -0
  86. package/dist/rules/waw059.ts +353 -0
  87. package/dist/rules/waw060.ts +91 -0
  88. package/dist/rules/waw061.ts +67 -0
  89. package/dist/rules/waw062.ts +0 -0
  90. package/dist/rules/waw063.ts +0 -0
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/skills/chant-aws-carve-terraform.md +26 -23
  93. package/dist/teardown.d.ts +85 -0
  94. package/dist/teardown.d.ts.map +1 -0
  95. package/package.json +3 -3
  96. package/src/api/read-client.test.ts +9 -0
  97. package/src/api/read-client.ts +15 -2
  98. package/src/components/cloud-executor.ts +10 -1
  99. package/src/composites/agentcore-agent.ts +35 -13
  100. package/src/composites/bucket-deployment.test.ts +84 -0
  101. package/src/composites/bucket-deployment.ts +153 -0
  102. package/src/composites/composites.test.ts +325 -0
  103. package/src/composites/dynamodb-table.test.ts +154 -0
  104. package/src/composites/dynamodb-table.ts +124 -0
  105. package/src/composites/ec2-instance-bundle.ts +134 -0
  106. package/src/composites/ecr-repository.test.ts +118 -0
  107. package/src/composites/ecr-repository.ts +117 -0
  108. package/src/composites/fargate-service.ts +19 -3
  109. package/src/composites/index.ts +21 -2
  110. package/src/composites/monitoring-stack.test.ts +130 -0
  111. package/src/composites/monitoring-stack.ts +164 -0
  112. package/src/composites/nlb-service.ts +117 -0
  113. package/src/composites/step-functions-workflow.ts +127 -0
  114. package/src/deep-observe.test.ts +201 -1
  115. package/src/deep-observe.ts +204 -0
  116. package/src/deep-topology.test.ts +392 -0
  117. package/src/deep-topology.ts +237 -0
  118. package/src/effect-receipt-row.test.ts +164 -0
  119. package/src/effect-receipt-row.ts +147 -0
  120. package/src/index.ts +28 -7
  121. package/src/lifecycle-integration.test.ts +4 -0
  122. package/src/lint/audit-catalog.ts +11 -0
  123. package/src/lint/post-synth/cf-refs.ts +62 -0
  124. package/src/lint/post-synth/index.ts +10 -0
  125. package/src/lint/post-synth/waw059.test.ts +309 -0
  126. package/src/lint/post-synth/waw059.ts +353 -0
  127. package/src/lint/post-synth/waw060.test.ts +131 -0
  128. package/src/lint/post-synth/waw060.ts +91 -0
  129. package/src/lint/post-synth/waw061.test.ts +99 -0
  130. package/src/lint/post-synth/waw061.ts +67 -0
  131. package/src/lint/post-synth/waw062.test.ts +109 -0
  132. package/src/lint/post-synth/waw062.ts +0 -0
  133. package/src/lint/post-synth/waw063.test.ts +220 -0
  134. package/src/lint/post-synth/waw063.ts +0 -0
  135. package/src/op/activities/aws-apply.test.ts +111 -0
  136. package/src/op/activities/aws-apply.ts +100 -6
  137. package/src/op/activities/index.ts +20 -3
  138. package/src/ownership.test.ts +24 -1
  139. package/src/ownership.ts +37 -0
  140. package/src/plugin.ts +59 -10
  141. package/src/receipt-store.test.ts +204 -0
  142. package/src/receipt-store.ts +315 -0
  143. package/src/serializer-ownership.test.ts +18 -0
  144. package/src/serializer.ts +114 -3
  145. package/src/skills/chant-aws-carve-terraform.md +26 -23
  146. package/src/teardown.test.ts +258 -0
  147. package/src/teardown.ts +276 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The aws `ReceiptStore` (#1835, epic #1703) — core's injectable receipt seam
3
+ * (#1834, `@intentius/chant/op/receipt-store`) implemented over SSM Parameter
4
+ * Store, plain `String`, at the path identity ./effect-receipt-row.ts derives
5
+ * from the ownership marker fields.
6
+ *
7
+ * The transport is the lexicon's own read/apply transport (./api/read-client,
8
+ * #1206), pointed at the SSM JSON API: `fetch`, SigV4 when credentials
9
+ * resolve, and the one endpoint-override rule (#1694) — the `endpoint`
10
+ * option, else `AWS_ENDPOINT_URL_SSM`, else `AWS_ENDPOINT_URL` — so a local
11
+ * emulator lane reads and writes receipts without any store-specific wiring.
12
+ *
13
+ * Write discipline (epic decision 3): `write` exists for the `effect()` step
14
+ * alone — the step's read-compare-run-write is the only path that reaches it,
15
+ * on success, last. `PutParameter` is `Type: "String"` always; the first
16
+ * write creates the parameter with the ownership tags, and a later write
17
+ * overwrites the value (SSM refuses `Overwrite` and `Tags` in one call, so
18
+ * tags ride creation only — they never change after).
19
+ *
20
+ * Identity: the parameter name needs `<stack>` and `<env>`, which the
21
+ * activity args deliberately do not carry (the `EffectReceiptRef` is
22
+ * identity-of-the-effect, not identity-of-the-deployment). The store resolves
23
+ * them once, lazily, at first use: an explicit option, else `CHANT_ENV` (what
24
+ * `chant run --env` sets) and the project's `ownership` block — the same
25
+ * fields that stamp markers (epic decision 4). Nothing resolving is an error,
26
+ * never a guessed segment.
27
+ */
28
+ import type { ReceiptStore } from "@intentius/chant/op/receipt-store";
29
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
30
+ import type { UnobservedReason } from "@intentius/chant/observation";
31
+ import { type AwsCredentialSource, type AwsReadClientOptions, type AwsReadHttp } from "./api/read-client.js";
32
+ /** Options for {@link awsReceiptStore}. All optional: the default store reads
33
+ * its identity from the project and its endpoint from the environment. */
34
+ export interface AwsReceiptStoreOptions {
35
+ /** The path's `<stack>` segment. Omitted, the project's `ownership.stack`
36
+ * (chant.config.ts, found upward from `cwd`) answers. */
37
+ stack?: string;
38
+ /** The path's `<env>` segment — explicit by decision 4. Omitted, `CHANT_ENV`
39
+ * (set by `chant run --env`) answers, then a literal `ownership.env`. */
40
+ environment?: string;
41
+ /** Where to look for chant.config.ts. Defaults to the working directory. */
42
+ cwd?: string;
43
+ /** Endpoint override; omitted, `AWS_ENDPOINT_URL[_SSM]` answers (#1694). */
44
+ endpoint?: string;
45
+ /** Region for the real-AWS host. */
46
+ region?: string;
47
+ /** Injectable HTTP, mirroring the read client's. Tests avoid the network. */
48
+ http?: AwsReadHttp;
49
+ /** Environment record the endpoint/credential/identity fallbacks read.
50
+ * Defaults to `process.env`; injectable for tests. */
51
+ env?: Record<string, string | undefined>;
52
+ /** What to sign with — same seam as the read client. */
53
+ credentials?: AwsCredentialSource;
54
+ /** Sign even against an endpoint override — for an override that is real AWS. */
55
+ signEndpointOverride?: boolean;
56
+ }
57
+ /** One SSM JSON call. Exported for the observation leg (plugin.ts), which
58
+ * reads the same parameters the store writes. */
59
+ export declare function ssmCall(action: string, payload: Record<string, unknown>, options: AwsReadClientOptions): Promise<{
60
+ status: number;
61
+ json: Record<string, unknown>;
62
+ }>;
63
+ /** The API's own error code from a JSON-protocol error body — `__type`, with
64
+ * any `namespace#` prefix stripped. */
65
+ export declare function ssmErrorCode(json: Record<string, unknown>): string | undefined;
66
+ /**
67
+ * `GetParameter` by name. Absent (`ParameterNotFound`) is `undefined` — a real
68
+ * answer, distinct from a failed read, which throws.
69
+ */
70
+ export declare function ssmGetParameter(name: string, options?: AwsReadClientOptions): Promise<string | undefined>;
71
+ /**
72
+ * `PutParameter`, plain `String`. Creation carries `tags`; an existing
73
+ * parameter is overwritten (`Overwrite: true`) without them — SSM refuses
74
+ * `Overwrite` and `Tags` in the same call, and ownership tags never change.
75
+ */
76
+ export declare function ssmPutParameter(name: string, value: string, tags: Record<string, string>, options?: AwsReadClientOptions): Promise<void>;
77
+ /** What the observation leg learned about the declared receipt rows. */
78
+ export interface ReceiptRowObservation {
79
+ resources: Record<string, ResourceMetadata>;
80
+ unobserved: Record<string, {
81
+ type: string;
82
+ reason: UnobservedReason;
83
+ detail: string;
84
+ }>;
85
+ }
86
+ /**
87
+ * The plan-side live read of the receipt rows (#1835's observation leg).
88
+ *
89
+ * A receipt is not a stack member — the applier never writes it (#1832) — so
90
+ * `describe-stack-resources` honestly reports it absent even while the
91
+ * parameter exists. The serializer renders each receipt's derived path into
92
+ * the template's `Metadata` (./serializer.ts), so this leg reads the paths
93
+ * back from the build output — one derivation, decision 4 — and asks SSM
94
+ * `GetParameter` for each. Present maps the stored value onto
95
+ * `attributes.value` (core's `RECEIPT_VALUE_ATTRIBUTE`); `ParameterNotFound`
96
+ * is a real absence and stays one; a failed read is an `unobserved` hole,
97
+ * never a wrong answer — a receipt nobody could read must not arrive
98
+ * downstream as "the effect never ran".
99
+ */
100
+ export declare function observeReceiptRows(entityNames: string[], buildOutput: string, options?: AwsReadClientOptions): Promise<ReceiptRowObservation>;
101
+ /**
102
+ * The `ReceiptStore` over SSM. Bind it once in the op activities barrel —
103
+ * `receiptActivities(awsReceiptStore())` — and the registry resolves
104
+ * `receiptRead`/`receiptWrite`/`receiptStaleness` by name, exactly like
105
+ * `ensureSecret` (#1830). Identity and endpoint resolve lazily at first use,
106
+ * so module load never reads the project or the environment.
107
+ */
108
+ export declare function awsReceiptStore(options?: AwsReceiptStoreOptions): ReceiptStore;
109
+ //# sourceMappingURL=receipt-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"receipt-store.d.ts","sourceRoot":"","sources":["../src/receipt-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAIH,OAAO,KAAK,EAAoB,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACxF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAKL,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,WAAW,EACjB,MAAM,mBAAmB,CAAC;AAO3B;0EAC0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC;6DACyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;6EACyE;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oCAAoC;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB;0DACsD;IACtD,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,wDAAwD;IACxD,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,iFAAiF;IACjF,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;iDACiD;AACjD,wBAAsB,OAAO,CAC3B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAyB5D;AAED;uCACuC;AACvC,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,SAAS,CAI9E;AAQD;;;GAGG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAQ7B;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,IAAI,CAAC,CAiBf;AA4CD,wEAAwE;AACxE,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,gBAAgB,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACxF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,kBAAkB,CACtC,WAAW,EAAE,MAAM,EAAE,EACrB,WAAW,EAAE,MAAM,EACnB,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,qBAAqB,CAAC,CA+ChC;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,YAAY,CAyBlF"}
@@ -50,6 +50,28 @@ export function findResourceRefs(value: unknown): Set<string> {
50
50
  return refs;
51
51
  }
52
52
 
53
+ /**
54
+ * Build the reverse of `findResourceRefs` for a whole template: logical id →
55
+ * the set of logical ids whose Properties reference it via Ref/Fn::GetAtt.
56
+ * Lets a check walk the graph consumer-ward (who uses this role?) instead of
57
+ * dependency-ward. Used by WAW059.
58
+ */
59
+ export function buildReverseRefIndex(template: CFTemplate): Map<string, Set<string>> {
60
+ const index = new Map<string, Set<string>>();
61
+ for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) {
62
+ for (const target of findResourceRefs(resource.Properties)) {
63
+ if (target === logicalId) continue;
64
+ let consumers = index.get(target);
65
+ if (!consumers) {
66
+ consumers = new Set();
67
+ index.set(target, consumers);
68
+ }
69
+ consumers.add(logicalId);
70
+ }
71
+ }
72
+ return index;
73
+ }
74
+
53
75
  /**
54
76
  * Check if a value is a CloudFormation intrinsic function (Ref, Fn::*, etc.)
55
77
  * that cannot be statically evaluated.
@@ -147,6 +169,46 @@ export function isFullTierEnv(env: string | undefined): boolean {
147
169
  return env === "prod" || env === "production" || env === "full";
148
170
  }
149
171
 
172
+ /**
173
+ * Parse an IPv4 CIDR literal ("10.0.0.0/16") into its numeric base address
174
+ * and prefix length. Returns null for anything that isn't a plain IPv4 CIDR
175
+ * (IPv6, malformed octets, out-of-range prefix) — callers treat that as
176
+ * "can't prove statically" rather than an error.
177
+ */
178
+ export function parseIpv4Cidr(cidr: string): { base: number; prefix: number } | null {
179
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(cidr.trim());
180
+ if (!m) return null;
181
+ const octets = m.slice(1, 5).map(Number);
182
+ if (octets.some((o) => o < 0 || o > 255)) return null;
183
+ const prefix = Number(m[5]);
184
+ if (prefix < 0 || prefix > 32) return null;
185
+ const base = ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
186
+ return { base, prefix };
187
+ }
188
+
189
+ /** [networkAddress, broadcastAddress] for a parsed IPv4 CIDR block. */
190
+ function ipv4Range(cidr: { base: number; prefix: number }): [number, number] {
191
+ const maskBits = cidr.prefix === 0 ? 0 : (0xffffffff << (32 - cidr.prefix)) >>> 0;
192
+ const network = (cidr.base & maskBits) >>> 0;
193
+ const broadcast = (network | (~maskBits >>> 0)) >>> 0;
194
+ return [network, broadcast];
195
+ }
196
+
197
+ /**
198
+ * Whether `inner` (an IPv4 CIDR literal) falls entirely within `outer`.
199
+ * Returns null — not false — when either literal isn't a plain, parseable
200
+ * IPv4 CIDR (e.g. IPv6, or a CloudFormation intrinsic already stringified
201
+ * elsewhere); a null means "statically unprovable", not "violation".
202
+ */
203
+ export function ipv4CidrContains(outer: string, inner: string): boolean | null {
204
+ const outerCidr = parseIpv4Cidr(outer);
205
+ const innerCidr = parseIpv4Cidr(inner);
206
+ if (!outerCidr || !innerCidr) return null;
207
+ const [oStart, oEnd] = ipv4Range(outerCidr);
208
+ const [iStart, iEnd] = ipv4Range(innerCidr);
209
+ return iStart >= oStart && iEnd <= oEnd;
210
+ }
211
+
150
212
  /**
151
213
  * Check if a port range [fromPort, toPort] contains any of the sensitive ports.
152
214
  */
@@ -0,0 +1,353 @@
1
+ /**
2
+ * WAW059: Wildcard Resource where the declared graph enumerates the touched set
3
+ *
4
+ * An identity policy that allows resource-scopable actions on `Resource: "*"`
5
+ * (or a service-wide ARN pattern) when every consumer of the attached role
6
+ * only touches resources declared in the same template. In that case the
7
+ * declared graph already enumerates the touched set, so the statement can be
8
+ * tightened to the `Fn::GetAtt` Arn list of those declared entities.
9
+ *
10
+ * Deliberately narrow (#1225). Fires only when ALL of these hold:
11
+ * - the statement is an `Effect: Allow` identity-policy statement (role inline
12
+ * policy, IAM::Policy, IAM::ManagedPolicy) — trust policies carry a
13
+ * Principal and are skipped, resource policies live on other types;
14
+ * - no Condition/NotAction/NotResource and no intrinsics in Action/Resource;
15
+ * - `Resource` is `"*"` or a service-wide ARN pattern;
16
+ * - every Action is in the curated resource-scopable table (s3, dynamodb,
17
+ * sqs) — actions that genuinely need `"*"` (s3:ListAllMyBuckets,
18
+ * dynamodb:ListTables, sqs:ListQueues) are not in the table;
19
+ * - the attached principal resolves to declared roles only, those roles have
20
+ * consumers in the template, and no consumer carries a foreign edge (a
21
+ * literal ARN of the service) or an intrinsic edge (Fn::ImportValue) that
22
+ * would put resources outside the declared graph in reach.
23
+ *
24
+ * Anything the static graph cannot prove stays quiet.
25
+ */
26
+
27
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
28
+ import {
29
+ parseCFTemplate,
30
+ walkPolicyStatements,
31
+ findResourceRefs,
32
+ buildReverseRefIndex,
33
+ isIntrinsic,
34
+ type CFTemplate,
35
+ type CFResource,
36
+ } from "./cf-refs";
37
+
38
+ /**
39
+ * Curated table of actions that support resource-level permissions, per
40
+ * service. Lower-cased (IAM action matching is case-insensitive). An action
41
+ * outside this table gates the whole statement off — it may legitimately
42
+ * require `Resource: "*"`.
43
+ */
44
+ export const RESOURCE_SCOPABLE_ACTIONS: Record<string, ReadonlySet<string>> = {
45
+ s3: new Set([
46
+ "getobject",
47
+ "getobjectversion",
48
+ "getobjectacl",
49
+ "getobjecttagging",
50
+ "putobject",
51
+ "putobjectacl",
52
+ "putobjecttagging",
53
+ "deleteobject",
54
+ "deleteobjectversion",
55
+ "listbucket",
56
+ "listbucketversions",
57
+ "listbucketmultipartuploads",
58
+ "listmultipartuploadparts",
59
+ "abortmultipartupload",
60
+ "getbucketlocation",
61
+ ]),
62
+ dynamodb: new Set([
63
+ "getitem",
64
+ "batchgetitem",
65
+ "query",
66
+ "scan",
67
+ "putitem",
68
+ "updateitem",
69
+ "deleteitem",
70
+ "batchwriteitem",
71
+ "conditioncheckitem",
72
+ "describetable",
73
+ "getrecords",
74
+ "getsharditerator",
75
+ "describestream",
76
+ ]),
77
+ sqs: new Set([
78
+ "sendmessage",
79
+ "sendmessagebatch",
80
+ "receivemessage",
81
+ "deletemessage",
82
+ "deletemessagebatch",
83
+ "changemessagevisibility",
84
+ "changemessagevisibilitybatch",
85
+ "getqueueattributes",
86
+ "getqueueurl",
87
+ "purgequeue",
88
+ ]),
89
+ };
90
+
91
+ /** CloudFormation types that declare an entity of each curated service. */
92
+ const SERVICE_RESOURCE_TYPES: Record<string, ReadonlySet<string>> = {
93
+ s3: new Set(["AWS::S3::Bucket"]),
94
+ dynamodb: new Set(["AWS::DynamoDB::Table", "AWS::DynamoDB::GlobalTable"]),
95
+ sqs: new Set(["AWS::SQS::Queue"]),
96
+ };
97
+
98
+ /**
99
+ * ARN resource segments that make an ARN service-wide rather than
100
+ * entity-scoped. `arn:aws:s3:::my-bucket/*` is bucket-scoped and NOT here.
101
+ */
102
+ const SERVICE_WIDE_REST: Record<string, ReadonlySet<string>> = {
103
+ s3: new Set(["*"]),
104
+ dynamodb: new Set(["*", "table/*"]),
105
+ sqs: new Set(["*"]),
106
+ };
107
+
108
+ const IAM_TYPES = new Set(["AWS::IAM::Policy", "AWS::IAM::Role", "AWS::IAM::ManagedPolicy"]);
109
+
110
+ const ARN_SERVICE_RE = /arn:[^:\s]*:([a-z0-9-]+):/g;
111
+
112
+ function asStringArray(value: unknown): string[] | null {
113
+ if (typeof value === "string") return [value];
114
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
115
+ return value as string[];
116
+ }
117
+ return null;
118
+ }
119
+
120
+ /** `"*"`, or an ARN whose service is in `services` and whose resource segment is service-wide. */
121
+ function isServiceWideResource(value: string, services: Set<string>): boolean {
122
+ if (value === "*") return true;
123
+ const m = /^arn:[^:]*:([^:]*):[^:]*:[^:]*:(.+)$/.exec(value);
124
+ if (!m) return false;
125
+ const [, service, rest] = m;
126
+ if (!services.has(service)) return false;
127
+ return SERVICE_WIDE_REST[service]?.has(rest) ?? false;
128
+ }
129
+
130
+ /**
131
+ * The services an Allow statement touches, if every action lands in the
132
+ * curated resource-scopable table. Null when any action is outside it
133
+ * (or malformed / an action-name wildcard).
134
+ */
135
+ function curatedServices(actions: string[]): Set<string> | null {
136
+ const services = new Set<string>();
137
+ for (const action of actions) {
138
+ const m = /^([a-z0-9-]+):([A-Za-z0-9]+)$/.exec(action);
139
+ if (!m) return null;
140
+ const [, service, name] = m;
141
+ const table = RESOURCE_SCOPABLE_ACTIONS[service];
142
+ if (!table || !table.has(name.toLowerCase())) return null;
143
+ services.add(service);
144
+ }
145
+ return services.size > 0 ? services : null;
146
+ }
147
+
148
+ /**
149
+ * Resolve which declared roles a policy resource is attached to. Null means
150
+ * "cannot prove" (users/groups, literal role names, no roles at all).
151
+ */
152
+ function attachedRoles(logicalId: string, resource: CFResource, template: CFTemplate): Set<string> | null {
153
+ const resources = template.Resources ?? {};
154
+ if (resource.Type === "AWS::IAM::Role") return new Set([logicalId]);
155
+
156
+ const props = resource.Properties ?? {};
157
+ // Users/groups have no consumers in the resource graph — untraceable.
158
+ if (Array.isArray(props.Users) && props.Users.length > 0) return null;
159
+ if (Array.isArray(props.Groups) && props.Groups.length > 0) return null;
160
+
161
+ const roles = new Set<string>();
162
+ if (props.Roles !== undefined) {
163
+ if (!Array.isArray(props.Roles)) return null;
164
+ for (const entry of props.Roles) {
165
+ // A literal role name points outside the declared graph.
166
+ if (typeof entry === "string") return null;
167
+ const refs = findResourceRefs(entry);
168
+ if (refs.size !== 1) return null;
169
+ const [id] = refs;
170
+ if (resources[id]?.Type !== "AWS::IAM::Role") return null;
171
+ roles.add(id);
172
+ }
173
+ }
174
+
175
+ // A managed policy can also be attached from the role side (ManagedPolicyArns).
176
+ if (resource.Type === "AWS::IAM::ManagedPolicy") {
177
+ for (const [roleId, candidate] of Object.entries(resources)) {
178
+ if (candidate.Type !== "AWS::IAM::Role") continue;
179
+ if (findResourceRefs(candidate.Properties?.ManagedPolicyArns).has(logicalId)) {
180
+ roles.add(roleId);
181
+ }
182
+ }
183
+ }
184
+
185
+ return roles.size > 0 ? roles : null;
186
+ }
187
+
188
+ /**
189
+ * The non-IAM resources that reference any of the roles (the role's
190
+ * consumers). An IAM::InstanceProfile is a pass-through hop: its own
191
+ * consumers count instead.
192
+ */
193
+ function consumersOf(
194
+ roleIds: Set<string>,
195
+ reverseIndex: Map<string, Set<string>>,
196
+ template: CFTemplate,
197
+ ): Array<[string, CFResource]> {
198
+ const resources = template.Resources ?? {};
199
+ const seen = new Set<string>();
200
+ const queue = [...roleIds].flatMap((id) => [...(reverseIndex.get(id) ?? [])]);
201
+ const consumers: Array<[string, CFResource]> = [];
202
+
203
+ while (queue.length > 0) {
204
+ const id = queue.shift() as string;
205
+ if (seen.has(id)) continue;
206
+ seen.add(id);
207
+ const resource = resources[id];
208
+ if (!resource) continue;
209
+ if (resource.Type === "AWS::IAM::InstanceProfile") {
210
+ queue.push(...(reverseIndex.get(id) ?? []));
211
+ continue;
212
+ }
213
+ if (resource.Type.startsWith("AWS::IAM::")) continue; // attachment, not consumption
214
+ consumers.push([id, resource]);
215
+ }
216
+
217
+ return consumers;
218
+ }
219
+
220
+ /**
221
+ * A consumer edge the declared graph cannot enumerate: a literal ARN of one
222
+ * of the statement's services (a foreign, undeclared entity) or an
223
+ * Fn::ImportValue anywhere in the consumer (a cross-stack edge).
224
+ */
225
+ function hasForeignOrIntrinsicEdge(value: unknown, services: Set<string>): boolean {
226
+ if (value === null || value === undefined) return false;
227
+ if (typeof value === "string") {
228
+ for (const m of value.matchAll(ARN_SERVICE_RE)) {
229
+ if (services.has(m[1])) return true;
230
+ }
231
+ return false;
232
+ }
233
+ if (typeof value !== "object") return false;
234
+ if (Array.isArray(value)) {
235
+ return value.some((item) => hasForeignOrIntrinsicEdge(item, services));
236
+ }
237
+ const obj = value as Record<string, unknown>;
238
+ if ("Fn::ImportValue" in obj) return true;
239
+ return Object.values(obj).some((v) => hasForeignOrIntrinsicEdge(v, services));
240
+ }
241
+
242
+ const S3_OBJECT_ACTIONS = new Set([
243
+ "getobject",
244
+ "getobjectversion",
245
+ "getobjectacl",
246
+ "getobjecttagging",
247
+ "putobject",
248
+ "putobjectacl",
249
+ "putobjecttagging",
250
+ "deleteobject",
251
+ "deleteobjectversion",
252
+ "listmultipartuploadparts",
253
+ "abortmultipartupload",
254
+ ]);
255
+
256
+ function policyKind(type: string): string {
257
+ if (type === "AWS::IAM::Role") return "role";
258
+ if (type === "AWS::IAM::ManagedPolicy") return "managed policy";
259
+ return "policy";
260
+ }
261
+
262
+ export function checkWildcardResourceEnumerable(ctx: PostSynthContext): PostSynthDiagnostic[] {
263
+ const diagnostics: PostSynthDiagnostic[] = [];
264
+
265
+ for (const [_lexicon, output] of ctx.outputs) {
266
+ const template = parseCFTemplate(output);
267
+ if (!template?.Resources) continue;
268
+
269
+ const reverseIndex = buildReverseRefIndex(template);
270
+
271
+ for (const [logicalId, resource] of Object.entries(template.Resources)) {
272
+ if (!IAM_TYPES.has(resource.Type)) continue;
273
+
274
+ // walkPolicyStatements also yields a role's AssumeRolePolicyDocument
275
+ // statements; those always carry a Principal and are gated off below.
276
+ for (const stmt of walkPolicyStatements(resource)) {
277
+ if (stmt.Effect !== "Allow") continue;
278
+ if ("Condition" in stmt || "NotAction" in stmt || "NotResource" in stmt) continue;
279
+ if ("Principal" in stmt || "NotPrincipal" in stmt) continue;
280
+ if (isIntrinsic(stmt.Action) || isIntrinsic(stmt.Resource)) continue;
281
+
282
+ const actions = asStringArray(stmt.Action);
283
+ const resources = asStringArray(stmt.Resource);
284
+ if (!actions || !resources || actions.length === 0 || resources.length === 0) continue;
285
+
286
+ const services = curatedServices(actions);
287
+ if (!services) continue;
288
+ if (!resources.every((r) => isServiceWideResource(r, services))) continue;
289
+
290
+ const roles = attachedRoles(logicalId, resource, template);
291
+ if (!roles) continue;
292
+
293
+ const consumers = consumersOf(roles, reverseIndex, template);
294
+ if (consumers.length === 0) continue;
295
+
296
+ // Every consumer edge must stay inside the declared graph.
297
+ if (consumers.some(([, c]) => hasForeignOrIntrinsicEdge(c.Properties, services))) continue;
298
+
299
+ // The declared entities of each granted service the consumers touch.
300
+ const touched = new Set<string>();
301
+ let enumerable = true;
302
+ for (const service of services) {
303
+ const types = SERVICE_RESOURCE_TYPES[service];
304
+ const serviceTouched = new Set<string>();
305
+ for (const [, consumer] of consumers) {
306
+ for (const ref of findResourceRefs(consumer.Properties)) {
307
+ if (types.has(template.Resources[ref]?.Type ?? "")) serviceTouched.add(ref);
308
+ }
309
+ }
310
+ if (serviceTouched.size === 0) {
311
+ enumerable = false;
312
+ break;
313
+ }
314
+ for (const id of serviceTouched) touched.add(id);
315
+ }
316
+ if (!enumerable) continue;
317
+
318
+ const suggestion = JSON.stringify(
319
+ [...touched].sort().map((id) => ({ "Fn::GetAtt": [id, "Arn"] })),
320
+ );
321
+ const objectHint = actions.some((a) => {
322
+ const [service, name] = a.split(":");
323
+ return service === "s3" && S3_OBJECT_ACTIONS.has(name.toLowerCase());
324
+ })
325
+ ? ' (s3 object-level actions also need the "/*" object suffix on the bucket Arn)'
326
+ : "";
327
+
328
+ diagnostics.push({
329
+ checkId: "WAW059",
330
+ severity: "warning",
331
+ message:
332
+ `IAM ${policyKind(resource.Type)} "${logicalId}" allows ${actions.join(", ")} on ` +
333
+ `Resource ${JSON.stringify(stmt.Resource)}, but the declared graph enumerates the touched set — ` +
334
+ `tighten Resource to ${suggestion}${objectHint}`,
335
+ entity: logicalId,
336
+ lexicon: "aws",
337
+ });
338
+ }
339
+ }
340
+ }
341
+
342
+ return diagnostics;
343
+ }
344
+
345
+ export const waw059: PostSynthCheck = {
346
+ id: "WAW059",
347
+ description:
348
+ "Wildcard Resource where the declared graph enumerates the touched set — tighten to the consumers' declared Arns",
349
+
350
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
351
+ return checkWildcardResourceEnumerable(ctx);
352
+ },
353
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * WAW060: IAM Policy Attached to No Principal
3
+ *
4
+ * Flags AWS::IAM::Policy / AWS::IAM::ManagedPolicy resources with empty or
5
+ * absent Roles, Users, and Groups that no other part of the template
6
+ * references. A policy attached to no principal grants nothing — the same
7
+ * detached-guardrail shape WAW057 catches for SCPs. Any Ref/GetAtt edge to
8
+ * the policy (a role's ManagedPolicyArns, an attachment-style resource, a
9
+ * stack Output exporting it for another stack to attach) keeps it quiet;
10
+ * plain AWS-managed ARN strings elsewhere are not references to the
11
+ * template's own policy.
12
+ */
13
+
14
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
15
+ import { parseCFTemplate, findResourceRefs, isIntrinsic, type CFTemplate } from "./cf-refs";
16
+
17
+ const POLICY_TYPES = new Set(["AWS::IAM::Policy", "AWS::IAM::ManagedPolicy"]);
18
+ const PRINCIPAL_PROPS = ["Roles", "Users", "Groups"] as const;
19
+
20
+ /**
21
+ * Collect every logical id referenced via Ref/Fn::GetAtt from anywhere in the
22
+ * template except the policy resources themselves — other resources'
23
+ * properties and the Outputs section. An Output edge counts as attachment
24
+ * evidence: the policy may be exported for a consuming stack to attach.
25
+ */
26
+ function collectExternalRefs(template: CFTemplate, excludeIds: Set<string>): Set<string> {
27
+ const refs = new Set<string>();
28
+ for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) {
29
+ if (excludeIds.has(logicalId)) continue;
30
+ for (const ref of findResourceRefs(resource)) refs.add(ref);
31
+ }
32
+ for (const ref of findResourceRefs(template.Outputs)) refs.add(ref);
33
+ return refs;
34
+ }
35
+
36
+ export function checkPolicyUnattached(ctx: PostSynthContext): PostSynthDiagnostic[] {
37
+ const diagnostics: PostSynthDiagnostic[] = [];
38
+
39
+ for (const [_lexicon, output] of ctx.outputs) {
40
+ const template = parseCFTemplate(output);
41
+ if (!template?.Resources) continue;
42
+
43
+ const policyIds = new Set(
44
+ Object.entries(template.Resources)
45
+ .filter(([, resource]) => POLICY_TYPES.has(resource.Type))
46
+ .map(([logicalId]) => logicalId),
47
+ );
48
+ if (policyIds.size === 0) continue;
49
+
50
+ const externalRefs = collectExternalRefs(template, policyIds);
51
+
52
+ for (const logicalId of policyIds) {
53
+ const resource = template.Resources[logicalId];
54
+ const props = resource.Properties ?? {};
55
+
56
+ // Any declared principal list keeps the policy quiet. An intrinsic
57
+ // can't be statically evaluated, so it counts as attached.
58
+ let attached = false;
59
+ for (const prop of PRINCIPAL_PROPS) {
60
+ const value = props[prop];
61
+ if (isIntrinsic(value)) attached = true;
62
+ else if (Array.isArray(value) && value.length > 0) attached = true;
63
+ }
64
+ if (attached) continue;
65
+
66
+ // Reverse lookup: anything else in the template holding a Ref/GetAtt
67
+ // edge to this policy attaches (or exports) it.
68
+ if (externalRefs.has(logicalId)) continue;
69
+
70
+ const kind = resource.Type === "AWS::IAM::ManagedPolicy" ? "Managed policy" : "Policy";
71
+ diagnostics.push({
72
+ checkId: "WAW060",
73
+ severity: "warning",
74
+ message: `${kind} "${logicalId}" is attached to no principal — no Roles/Users/Groups and nothing in the template references it; an unattached policy grants nothing`,
75
+ entity: logicalId,
76
+ lexicon: "aws",
77
+ });
78
+ }
79
+ }
80
+
81
+ return diagnostics;
82
+ }
83
+
84
+ export const waw060: PostSynthCheck = {
85
+ id: "WAW060",
86
+ description: "IAM policy attached to no principal — it grants nothing",
87
+
88
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
89
+ return checkPolicyUnattached(ctx);
90
+ },
91
+ };