nexarch 0.12.6 → 0.12.7

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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Terraform state projector (ADR: infrastructure-as-code ingestion, v1).
3
+ *
4
+ * Turns `terraform show -json` into the small, safe projection that ingestion
5
+ * is allowed to send. This is the security-critical component of the feature:
6
+ * state files carry generated passwords, storage keys, connection strings and
7
+ * certificate material in clear text, verified against a real estate.
8
+ *
9
+ * Two independent gates, both deny-by-default:
10
+ *
11
+ * 1. The catalogue names, per resource type, the attributes that may be read.
12
+ * Anything not named is dropped. The catalogue is reference data served by
13
+ * the platform, not a list baked into this CLI, so support for a new Azure
14
+ * service is a data change rather than a release.
15
+ *
16
+ * 2. Terraform's own `sensitive_values` map is consulted for every attribute
17
+ * that survives gate 1, and anything flagged there is dropped even when the
18
+ * catalogue names it. A mistake in the curated list is therefore not on its
19
+ * own sufficient to leak a secret.
20
+ *
21
+ * Values are additionally restricted to scalars and shallow string arrays.
22
+ * Nested blocks are never emitted wholesale: a container app's `secret` block or
23
+ * an env var's `value` can hold credentials, so structure is only ever read by a
24
+ * named extractor that has been reviewed for that purpose.
25
+ */
26
+ /**
27
+ * Parses a state document from raw bytes.
28
+ *
29
+ * PowerShell's `>` redirection writes UTF-16 with a BOM, which is what a Windows
30
+ * user gets from `terraform show -json > state.json` and what a naive reader
31
+ * chokes on. Both endiannesses and plain UTF-8 are accepted.
32
+ */
33
+ export function parseTerraformStateBuffer(buffer) {
34
+ let text;
35
+ if (buffer[0] === 0xff && buffer[1] === 0xfe)
36
+ text = buffer.toString("utf16le");
37
+ else if (buffer[0] === 0xfe && buffer[1] === 0xff)
38
+ text = Buffer.from(buffer).swap16().toString("utf16le");
39
+ else
40
+ text = buffer.toString("utf8");
41
+ return JSON.parse(text.replace(/^/, ""));
42
+ }
43
+ /**
44
+ * Infers the environment from resource tags.
45
+ *
46
+ * Estates that tag at all tag consistently — the first real one carried
47
+ * `environment` on every resource — so the dominant value of a conventional tag
48
+ * key beats asking a human to pass a flag correctly in a pipeline. Returns null
49
+ * when there is no clear winner, leaving the caller to fall back to the
50
+ * Terraform workspace or an explicit flag.
51
+ */
52
+ export function inferEnvironmentFromTags(resources) {
53
+ const TAG_KEYS = ["environment", "env", "Environment", "Env"];
54
+ const counts = new Map();
55
+ for (const resource of resources) {
56
+ for (const key of TAG_KEYS) {
57
+ const value = resource.tags[key];
58
+ if (typeof value !== "string" || !value.trim())
59
+ continue;
60
+ const normalised = value.trim().toLowerCase();
61
+ counts.set(normalised, (counts.get(normalised) ?? 0) + 1);
62
+ break;
63
+ }
64
+ }
65
+ if (counts.size === 0)
66
+ return null;
67
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
68
+ const [environment, hits] = ranked[0];
69
+ const total = ranked.reduce((sum, [, n]) => sum + n, 0);
70
+ return { environment, confidence: hits / total };
71
+ }
72
+ /** Maps a free-text environment name onto an ontology environment subtype. */
73
+ export function environmentSubtypeFor(environment) {
74
+ const name = environment.trim().toLowerCase();
75
+ if (/^(prod|production|live)$/.test(name))
76
+ return "env_production";
77
+ if (/^(stag|staging|preprod|pre-prod|uat)$/.test(name))
78
+ return "env_staging";
79
+ if (/^(test|qa|sit)$/.test(name))
80
+ return "env_test";
81
+ if (/^(dr|failover)$/.test(name))
82
+ return "env_dr";
83
+ return "env_development";
84
+ }
85
+ /** Longest value we will carry for any single attribute. */
86
+ const MAX_VALUE_LENGTH = 400;
87
+ const MAX_ARRAY_ITEMS = 12;
88
+ function flattenResources(module, out = []) {
89
+ if (!module)
90
+ return out;
91
+ for (const resource of module.resources ?? [])
92
+ out.push(resource);
93
+ for (const child of module.child_modules ?? [])
94
+ flattenResources(child, out);
95
+ return out;
96
+ }
97
+ /**
98
+ * True when Terraform flagged this attribute as sensitive. The map mirrors the
99
+ * shape of `values`, so a `true` anywhere beneath the attribute — an env var's
100
+ * value inside a container template, for instance — disqualifies the whole
101
+ * attribute rather than just the leaf.
102
+ */
103
+ function isSensitive(sensitiveValues, attribute) {
104
+ if (!sensitiveValues || typeof sensitiveValues !== "object" || Array.isArray(sensitiveValues))
105
+ return false;
106
+ const flag = sensitiveValues[attribute];
107
+ if (flag === undefined)
108
+ return false;
109
+ if (flag === true)
110
+ return true;
111
+ return JSON.stringify(flag).includes("true");
112
+ }
113
+ /** Scalars and shallow string arrays only; anything structural is refused. */
114
+ function safeValue(value) {
115
+ if (value === null || value === undefined)
116
+ return null;
117
+ if (typeof value === "boolean" || typeof value === "number")
118
+ return value;
119
+ if (typeof value === "string")
120
+ return value.length > MAX_VALUE_LENGTH ? value.slice(0, MAX_VALUE_LENGTH) : value;
121
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
122
+ return value.slice(0, MAX_ARRAY_ITEMS).map((item) => item.slice(0, MAX_VALUE_LENGTH));
123
+ }
124
+ return null;
125
+ }
126
+ /**
127
+ * Tags carry the binding convention, so they are read — but a tag whose value
128
+ * Terraform flagged, or which does not look like a label, is left behind.
129
+ */
130
+ function projectTags(values, sensitiveValues) {
131
+ if (isSensitive(sensitiveValues, "tags"))
132
+ return {};
133
+ const raw = values.tags;
134
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
135
+ return {};
136
+ const tags = {};
137
+ for (const [key, value] of Object.entries(raw)) {
138
+ if (typeof value !== "string")
139
+ continue;
140
+ if (value.length > 200)
141
+ continue;
142
+ tags[key] = value;
143
+ }
144
+ return tags;
145
+ }
146
+ /**
147
+ * User-assigned identity references. Reviewed extractor: identity ids are
148
+ * resource paths, never credentials, and they are how a workload is tied to the
149
+ * role assignments that define what it may reach.
150
+ */
151
+ function projectIdentityIds(values) {
152
+ const identity = values.identity;
153
+ if (!Array.isArray(identity))
154
+ return [];
155
+ const ids = [];
156
+ for (const block of identity) {
157
+ if (!block || typeof block !== "object")
158
+ continue;
159
+ const identityIds = block.identity_ids;
160
+ if (!Array.isArray(identityIds))
161
+ continue;
162
+ for (const id of identityIds)
163
+ if (typeof id === "string")
164
+ ids.push(id);
165
+ }
166
+ return ids.slice(0, MAX_ARRAY_ITEMS);
167
+ }
168
+ export function projectTerraformState(params) {
169
+ const { document, catalogue, environment } = params;
170
+ const byType = new Map(catalogue.map((entry) => [entry.resourceType, entry]));
171
+ const resources = flattenResources(document.values?.root_module);
172
+ const projected = [];
173
+ const unknown = new Map();
174
+ let attributesDroppedNotAllowlisted = 0;
175
+ let attributesDroppedSensitive = 0;
176
+ for (const resource of resources) {
177
+ const resourceType = typeof resource.type === "string" ? resource.type : "";
178
+ if (!resourceType)
179
+ continue;
180
+ const entry = byType.get(resourceType);
181
+ if (!entry) {
182
+ unknown.set(resourceType, (unknown.get(resourceType) ?? 0) + 1);
183
+ continue;
184
+ }
185
+ const values = (resource.values && typeof resource.values === "object" ? resource.values : {});
186
+ const sensitiveValues = resource.sensitive_values;
187
+ const attributes = {};
188
+ for (const [key, value] of Object.entries(values)) {
189
+ if (!entry.attributes.includes(key)) {
190
+ attributesDroppedNotAllowlisted += 1;
191
+ continue;
192
+ }
193
+ if (isSensitive(sensitiveValues, key)) {
194
+ attributesDroppedSensitive += 1;
195
+ continue;
196
+ }
197
+ const safe = safeValue(value);
198
+ if (safe === null) {
199
+ attributesDroppedNotAllowlisted += 1;
200
+ continue;
201
+ }
202
+ attributes[key] = safe;
203
+ }
204
+ projected.push({
205
+ address: typeof resource.address === "string" ? resource.address : `${resourceType}.${resource.name ?? "unknown"}`,
206
+ resourceType,
207
+ entityTypeCode: entry.entityTypeCode,
208
+ entitySubtypeCode: entry.entitySubtypeCode ?? null,
209
+ role: entry.role,
210
+ mode: typeof resource.mode === "string" ? resource.mode : "managed",
211
+ name: typeof values.name === "string" ? values.name : null,
212
+ attributes,
213
+ tags: projectTags(values, sensitiveValues),
214
+ identityIds: projectIdentityIds(values),
215
+ });
216
+ }
217
+ return {
218
+ environment,
219
+ terraformVersion: typeof document.terraform_version === "string" ? document.terraform_version : null,
220
+ resources: projected,
221
+ unknownTypes: [...unknown.entries()]
222
+ .map(([resourceType, count]) => ({ resourceType, count }))
223
+ .sort((a, b) => b.count - a.count),
224
+ stats: {
225
+ resourcesSeen: resources.length,
226
+ resourcesProjected: projected.length,
227
+ attributesDroppedNotAllowlisted,
228
+ attributesDroppedSensitive,
229
+ },
230
+ };
231
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexarch",
3
- "version": "0.12.6",
3
+ "version": "0.12.7",
4
4
  "description": "Your architecture workspace for AI delivery.",
5
5
  "keywords": [
6
6
  "nexarch",