@intentius/chant 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -4,7 +4,7 @@ import { loadPlugins } from "../cli/plugins";
4
4
 
5
5
  /** All post-synth check ids the audit can actually surface, from the lexicons. */
6
6
  async function realCheckIds(): Promise<Set<string>> {
7
- const plugins = await loadPlugins(["github", "gitlab", "forgejo", "k8s", "docker"]);
7
+ const plugins = await loadPlugins(["github", "gitlab", "forgejo", "k8s", "docker", "aws", "azure", "gcp", "helm"]);
8
8
  const ids = new Set<string>();
9
9
  for (const plugin of plugins) {
10
10
  for (const check of plugin.postSynthChecks?.() ?? []) {
@@ -86,6 +86,18 @@ const DOCKER_SEC: Authority = {
86
86
  name: "Docker — Security best practices",
87
87
  url: "https://docs.docker.com/develop/security-best-practices/",
88
88
  };
89
+ const AWS_SEC: Authority = {
90
+ name: "AWS — Security Pillar (Well-Architected)",
91
+ url: "https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/welcome.html",
92
+ };
93
+ const AZ_SEC: Authority = {
94
+ name: "Microsoft Cloud Security Benchmark",
95
+ url: "https://learn.microsoft.com/en-us/security/benchmark/azure/",
96
+ };
97
+ const GCP_SEC: Authority = {
98
+ name: "Google Cloud — Security best practices",
99
+ url: "https://cloud.google.com/security/best-practices",
100
+ };
89
101
 
90
102
  function meta(
91
103
  id: string,
@@ -235,6 +247,107 @@ export const RULE_CATALOG: Record<string, RuleMeta> = {
235
247
  DKRD010: meta("DKRD010", R, G, "apt-get install without --no-install-recommends", "Add --no-install-recommends to keep images small."),
236
248
  DKRD011: meta("DKRD011", R, G, "ADD used where COPY would do", "Prefer COPY unless fetching a URL or extracting an archive."),
237
249
  DKRD012: meta("DKRD012", M, G, "No USER instruction — container runs as root", "Add a non-root USER instruction.", [DOCKER_SEC]),
250
+
251
+ // ── AWS CloudFormation (WAW / COR / EXT) ───────────────────────────
252
+ COR020: meta("COR020", M, G, "Circular resource dependency", "Break the dependency cycle between resources."),
253
+ EXT001: meta("EXT001", M, G, "Extension constraint violation", "Fix the cross-property constraint flagged by the cfn-lint extension schema."),
254
+ WAW010: meta("WAW010", R, G, "Redundant DependsOn", "Remove DependsOn already implied by a Ref/GetAtt."),
255
+ WAW011: meta("WAW011", R, G, "Deprecated Lambda runtime", "Upgrade to a supported Lambda runtime."),
256
+ WAW013: meta("WAW013", M, G, "Child stack exports nothing", "Add stackOutput() exports the parent can reference."),
257
+ WAW014: meta("WAW014", R, G, "Nested stack outputs never referenced", "Reference the outputs or split into a separate build."),
258
+ WAW015: meta("WAW015", M, G, "Circular dependency between nested stacks", "Break the cycle between nested stacks."),
259
+ WAW016: meta("WAW016", R, G, "Deprecated property", "Replace the deprecated CloudFormation property."),
260
+ WAW017: meta("WAW017", R, G, "Missing tags on a taggable resource", "Add tags for cost allocation and compliance."),
261
+ WAW018: meta("WAW018", M, G, "S3 bucket missing public access block", "Add a PublicAccessBlockConfiguration blocking all public access.", [AWS_SEC]),
262
+ WAW019: meta("WAW019", M, G, "Security group allows unrestricted ingress on a sensitive port", "Restrict the CIDR on SSH/RDP/database ports to known sources.", [AWS_SEC]),
263
+ WAW020: meta("WAW020", M, G, "IAM policy uses a wildcard Action", "Scope the policy to specific actions (least privilege).", [AWS_SEC]),
264
+ WAW021: meta("WAW021", M, G, "RDS storage not encrypted", "Enable StorageEncrypted for encryption at rest.", [AWS_SEC]),
265
+ WAW022: meta("WAW022", R, G, "Lambda has no VpcConfig", "Consider a VpcConfig for network isolation if the function needs VPC resources."),
266
+ WAW023: meta("WAW023", R, G, "CloudFront has no WAF web ACL", "Consider attaching a WAF web ACL."),
267
+ WAW024: meta("WAW024", R, G, "ALB access logging disabled", "Enable access logging for audit trails."),
268
+ WAW025: meta("WAW025", M, G, "SNS topic not encrypted", "Set KmsMasterKeyId for encryption at rest.", [AWS_SEC]),
269
+ WAW026: meta("WAW026", M, G, "SQS queue not encrypted", "Enable SqsManagedSseEnabled or set KmsMasterKeyId.", [AWS_SEC]),
270
+ WAW027: meta("WAW027", R, G, "DynamoDB point-in-time recovery disabled", "Enable PITR for recovery."),
271
+ WAW028: meta("WAW028", M, G, "EBS volume not encrypted", "Enable encryption at rest.", [AWS_SEC]),
272
+ WAW029: meta("WAW029", M, G, "Invalid DependsOn target", "Fix the dangling/self DependsOn reference."),
273
+ WAW030: meta("WAW030", R, G, "Missing DependsOn for a known ordering pattern", "Add the DependsOn the pattern requires."),
274
+ WAW031: meta("WAW031", R, G, "EKS Addon missing ServiceAccountRoleArn", "Set ServiceAccountRoleArn (IRSA) for addons that need it."),
275
+ WAW032: meta("WAW032", M, G, "EFS transit encryption disabled on Fargate", "Enable transit encryption for the EFS volume.", [AWS_SEC]),
276
+ WAW033: meta("WAW033", M, G, "Solr heap exceeds Fargate task memory", "Lower SOLR_HEAP or raise task memory."),
277
+ WAW034: meta("WAW034", R, G, "Fargate Solr task under-provisioned", "Allocate >= 2048MB for the Solr task."),
278
+ WAW035: meta("WAW035", R, G, "Solr container missing nofile ulimit", "Set a nofile ulimit >= 65535."),
279
+ WAW036: meta("WAW036", M, G, "Non-ASCII characters in resource properties", "Remove non-ASCII characters rejected at changeset time."),
280
+ WAW037: meta("WAW037", M, G, "Null values in resource properties", "Fix the invalid AttrRef producing null property values."),
281
+
282
+ // ── Azure ARM (AZR) ────────────────────────────────────────────────
283
+ AZR010: meta("AZR010", R, G, "Redundant dependsOn", "Remove dependsOn already implied by reference()/resourceId()."),
284
+ AZR011: meta("AZR011", M, G, "Missing or invalid apiVersion", "Set a valid YYYY-MM-DD apiVersion on every resource."),
285
+ AZR012: meta("AZR012", R, G, "Deprecated API version", "Move to a current apiVersion."),
286
+ AZR013: meta("AZR013", M, G, "Resource missing location", "Add the required location property."),
287
+ AZR014: meta("AZR014", M, G, "Storage account allows public blob access", "Set allowBlobPublicAccess to false.", [AZ_SEC]),
288
+ AZR015: meta("AZR015", M, G, "Storage account missing encryption", "Enable encryption services for data at rest.", [AZ_SEC]),
289
+ AZR016: meta("AZR016", R, G, "Key Vault soft-delete not enabled", "Enable soft-delete."),
290
+ AZR017: meta("AZR017", R, G, "Key Vault purge protection not enabled", "Enable purge protection."),
291
+ AZR018: meta("AZR018", R, G, "SQL Server missing auditing", "Enable auditing for compliance and threat detection."),
292
+ AZR019: meta("AZR019", M, G, "SQL database missing TDE", "Enable Transparent Data Encryption.", [AZ_SEC]),
293
+ AZR020: meta("AZR020", R, G, "App Service missing managed identity", "Enable a system- or user-assigned identity."),
294
+ AZR021: meta("AZR021", M, G, "App Service not HTTPS-only", "Set httpsOnly to true.", [AZ_SEC]),
295
+ AZR022: meta("AZR022", M, G, "App Service min TLS below 1.2", "Set minTlsVersion to 1.2+.", [AZ_SEC]),
296
+ AZR023: meta("AZR023", R, G, "VM not using a managed disk", "Use a managed disk."),
297
+ AZR024: meta("AZR024", R, G, "VM missing boot diagnostics", "Enable boot diagnostics."),
298
+ AZR025: meta("AZR025", R, G, "AKS cluster missing RBAC", "Enable Kubernetes RBAC."),
299
+ AZR026: meta("AZR026", R, G, "AKS cluster missing network policy", "Configure a networkPolicy."),
300
+ AZR027: meta("AZR027", M, G, "Container Registry admin user enabled", "Disable the admin user; use Azure AD / service principals.", [AZ_SEC]),
301
+ AZR028: meta("AZR028", R, G, "Network interface missing NSG", "Associate an NSG to control traffic."),
302
+ AZR029: meta("AZR029", M, G, "Managed disk missing encryption", "Enable encryption for data at rest.", [AZ_SEC]),
303
+
304
+ // ── GCP Config Connector (WGC) ─────────────────────────────────────
305
+ WGC101: meta("WGC101", M, G, "Storage/SQL without encryption configuration", "Configure encryption (e.g. a CMEK key) for data at rest.", [GCP_SEC]),
306
+ WGC102: meta("WGC102", M, G, "Public IAM member (allUsers/allAuthenticatedUsers)", "Remove allUsers/allAuthenticatedUsers bindings.", [GCP_SEC]),
307
+ WGC103: meta("WGC103", R, G, "Missing project-id annotation", "Add the cnrm.cloud.google.com/project-id annotation."),
308
+ WGC104: meta("WGC104", M, G, "Bucket without uniform bucket-level access", "Enable uniformBucketLevelAccess.", [GCP_SEC]),
309
+ WGC105: meta("WGC105", M, G, "Cloud SQL open to 0.0.0.0/0", "Restrict authorizedNetworks to known sources.", [GCP_SEC]),
310
+ WGC106: meta("WGC106", R, G, "Missing deletion-policy annotation", "Add the cnrm.cloud.google.com/deletion-policy annotation."),
311
+ WGC107: meta("WGC107", R, G, "Bucket versioning disabled", "Enable object versioning."),
312
+ WGC108: meta("WGC108", R, G, "Cloud SQL backups disabled", "Enable backup configuration."),
313
+ WGC109: meta("WGC109", M, G, "Firewall open to 0.0.0.0/0", "Restrict sourceRanges to known sources.", [GCP_SEC]),
314
+ WGC110: meta("WGC110", M, G, "KMS key without rotation", "Set a rotationPeriod on the CryptoKey.", [GCP_SEC]),
315
+ WGC111: meta("WGC111", M, G, "Reference to an undefined resource", "Point the reference at a resource in the output."),
316
+ WGC112: meta("WGC112", M, G, "Missing or invalid apiVersion", "Set a valid cnrm.cloud.google.com apiVersion."),
317
+ WGC113: meta("WGC113", R, G, "Alpha API version", "Move to a beta/GA API version."),
318
+ WGC201: meta("WGC201", R, G, "Missing managed-by label", "Add the app.kubernetes.io/managed-by label."),
319
+ WGC202: meta("WGC202", M, G, "Cluster without Workload Identity", "Enable Workload Identity on the ContainerCluster.", [GCP_SEC]),
320
+ WGC203: meta("WGC203", M, G, "Node pool uses broad cloud-platform scope", "Use narrowly-scoped OAuth scopes instead of cloud-platform.", [GCP_SEC]),
321
+ WGC204: meta("WGC204", R, G, "Compute instance without Shielded VM", "Enable Shielded VM configuration."),
322
+ WGC301: meta("WGC301", R, G, "No IAMAuditConfig found", "Configure audit logging via IAMAuditConfig."),
323
+ WGC302: meta("WGC302", R, G, "No Service (enabled APIs) found", "Declare the GCP APIs you depend on."),
324
+ WGC303: meta("WGC303", R, G, "No VPC Service Controls perimeter", "Consider an AccessContextManager ServicePerimeter."),
325
+ WGC401: meta("WGC401", M, G, "Unknown field in resource spec", "Remove the unknown spec field."),
326
+ WGC402: meta("WGC402", M, G, "Missing required spec field", "Add the required spec field."),
327
+ WGC403: meta("WGC403", M, G, "Spec field has wrong type/structure", "Fix the field's type/structure."),
328
+
329
+ // ── Helm (WHM) ─────────────────────────────────────────────────────
330
+ WHM005: meta("WHM005", R, G, "Sub-chart wrapper with no templates", "Deploy the upstream chart directly instead of an empty wrapper."),
331
+ WHM101: meta("WHM101", M, G, "Chart.yaml missing required fields", "Set apiVersion (v2), name, and version in Chart.yaml."),
332
+ WHM102: meta("WHM102", R, G, "Missing values.schema.json", "Add a values.schema.json to validate values."),
333
+ WHM103: meta("WHM103", M, G, "Invalid Go template syntax", "Fix the unbalanced template braces."),
334
+ WHM104: meta("WHM104", R, G, "Missing NOTES.txt", "Add templates/NOTES.txt for application charts."),
335
+ WHM105: meta("WHM105", R, G, "Missing _helpers.tpl", "Add templates/_helpers.tpl."),
336
+ WHM201: meta("WHM201", R, G, "Missing standard Helm labels", "Add the recommended app.kubernetes.io labels."),
337
+ WHM202: meta("WHM202", R, G, "Hook weights undefined", "Define hook weights when multiple hooks exist."),
338
+ WHM203: meta("WHM203", R, G, "Undocumented values", "Document values via schema or comments."),
339
+ WHM204: meta("WHM204", R, G, "Dependencies pinned, not ranged", "Use semver ranges for chart dependencies."),
340
+ WHM301: meta("WHM301", R, G, "No Helm test", "Add at least one Helm test for application charts."),
341
+ WHM302: meta("WHM302", R, G, "Container resources not set", "Set limits/requests via values or defaults."),
342
+ WHM401: meta("WHM401", M, G, "Container image uses :latest or no tag", "Pin the image to an explicit version tag.", [SCORECARD_PINNED]),
343
+ WHM402: meta("WHM402", M, G, "Container may run as root", "Set runAsNonRoot in the security context.", [K8S_PSS]),
344
+ WHM403: meta("WHM403", M, G, "Root filesystem writable", "Set readOnlyRootFilesystem.", [K8S_PSS]),
345
+ WHM404: meta("WHM404", M, G, "Privileged container", "Remove privileged mode.", [K8S_PSS]),
346
+ WHM405: meta("WHM405", R, G, "Resource specs missing cpu/memory", "Set cpu and memory in limits/requests."),
347
+ WHM406: meta("WHM406", R, G, "CRDs in crds/ are never upgraded", "Manage CRD upgrades outside Helm or via a separate chart."),
348
+ WHM407: meta("WHM407", M, G, "Inline Secret data", "Use ExternalSecret/SealedSecret instead of inline Secret data.", [K8S_SECRETS]),
349
+ WHM501: meta("WHM501", R, G, "Unused values key", "Remove values defined but never referenced."),
350
+ WHM502: meta("WHM502", M, G, "Deprecated/invalid Kubernetes API version", "Update to a supported apiVersion."),
238
351
  };
239
352
 
240
353
  /** Look up catalog metadata for a check id, if known. */
package/src/audit/core.ts CHANGED
Binary file
@@ -15,6 +15,10 @@ const GROUPS: Array<{ heading: string; prefixes: string[]; blurb: string }> = [
15
15
  { heading: "Forgejo (WFJ)", prefixes: ["WFJ"], blurb: "" },
16
16
  { heading: "Kubernetes (WK8 / ARGO)", prefixes: ["WK8", "ARGO"], blurb: "Run against Kubernetes manifests." },
17
17
  { heading: "Docker (DKRD)", prefixes: ["DKRD"], blurb: "Run against Dockerfiles and Compose files." },
18
+ { heading: "AWS CloudFormation (WAW / COR / EXT)", prefixes: ["WAW", "COR", "EXT"], blurb: "Run against CloudFormation templates (JSON or YAML)." },
19
+ { heading: "Azure ARM (AZR)", prefixes: ["AZR"], blurb: "Run against ARM deployment templates (JSON)." },
20
+ { heading: "GCP Config Connector (WGC)", prefixes: ["WGC"], blurb: "Run against Config Connector (cnrm.cloud.google.com) manifests." },
21
+ { heading: "Helm (WHM)", prefixes: ["WHM"], blurb: "Run against Helm charts (Chart.yaml + templates)." },
18
22
  ];
19
23
 
20
24
  function ruleBlock(m: RuleMeta): string {
@@ -0,0 +1,11 @@
1
+ AWSTemplateFormatVersion: "2010-09-09"
2
+ Resources:
3
+ Sg:
4
+ Type: AWS::EC2::SecurityGroup
5
+ Properties:
6
+ GroupDescription: web
7
+ SecurityGroupIngress:
8
+ - IpProtocol: tcp
9
+ FromPort: 22
10
+ ToPort: 22
11
+ CidrIp: 0.0.0.0/0
@@ -0,0 +1,7 @@
1
+ {
2
+ "AWSTemplateFormatVersion": "2010-09-09",
3
+ "Resources": {
4
+ "Data": { "Type": "AWS::S3::Bucket", "Properties": {} },
5
+ "Db": { "Type": "AWS::RDS::DBInstance", "Properties": { "Engine": "postgres" } }
6
+ }
7
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
3
+ "contentVersion": "1.0.0.0",
4
+ "resources": [
5
+ {
6
+ "type": "Microsoft.Storage/storageAccounts",
7
+ "apiVersion": "2022-09-01",
8
+ "name": "data",
9
+ "location": "eastus",
10
+ "properties": { "allowBlobPublicAccess": true }
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,6 @@
1
+ apiVersion: storage.cnrm.cloud.google.com/v1beta1
2
+ kind: StorageBucket
3
+ metadata:
4
+ name: data
5
+ spec:
6
+ location: US
@@ -0,0 +1,9 @@
1
+ apiVersion: compute.cnrm.cloud.google.com/v1beta1
2
+ kind: ComputeFirewall
3
+ metadata:
4
+ name: allow-all
5
+ spec:
6
+ sourceRanges:
7
+ - 0.0.0.0/0
8
+ allow:
9
+ - protocol: tcp
@@ -0,0 +1,3 @@
1
+ apiVersion: v2
2
+ name: mychart
3
+ version: 0.1.0
@@ -0,0 +1,12 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: {{ .Release.Name }}
5
+ spec:
6
+ template:
7
+ spec:
8
+ containers:
9
+ - name: app
10
+ image: nginx:latest
11
+ securityContext:
12
+ privileged: true
@@ -0,0 +1 @@
1
+ image: myapp:latest
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { fileURLToPath } from "url";
3
- import { auditCommand, discoverCiFiles, discoverManifests, discoverDocker, tokenForHost, coverageNotes } from "./audit";
3
+ import { auditCommand, discoverCiFiles, discoverManifests, discoverDocker, discoverCloudFormation, discoverArm, discoverGcp, discoverHelm, tokenForHost, coverageNotes } from "./audit";
4
4
  import { MissingLexiconError, type AuditInput } from "../../audit/core";
5
5
  import { readFileSync, existsSync, rmSync } from "fs";
6
6
  import { tmpdir } from "os";
@@ -58,6 +58,64 @@ describe("auditCommand", () => {
58
58
  expect(ids).toContain("DKRD003"); // compose exposes SSH port 22
59
59
  });
60
60
 
61
+ test("discovers and audits CloudFormation (JSON and YAML)", async () => {
62
+ const repo = fileURLToPath(new URL("./__fixtures__/audit-aws", import.meta.url));
63
+ const files = discoverCloudFormation(repo);
64
+ const paths = files.map((f) => f.path).sort();
65
+ expect(paths).toContain("template.json");
66
+ expect(paths).toContain("stack.yaml");
67
+ // YAML is normalized to a JSON string the aws checks can JSON.parse.
68
+ expect(() => JSON.parse(files.find((f) => f.path === "stack.yaml")!.content)).not.toThrow();
69
+
70
+ const result = await auditCommand({ path: repo, format: "stylish" });
71
+ expect(result.success).toBe(true);
72
+ const ids = new Set(result.findings.map((f) => f.checkId));
73
+ expect(ids).toContain("WAW018"); // S3 missing public access block (JSON template)
74
+ expect(ids).toContain("WAW021"); // RDS not encrypted (JSON template)
75
+ expect(ids).toContain("WAW019"); // SG open SSH (YAML template — proves YAML works)
76
+ });
77
+
78
+ test("discovers and audits Azure ARM templates", async () => {
79
+ const repo = fileURLToPath(new URL("./__fixtures__/audit-azure", import.meta.url));
80
+ expect(discoverArm(repo).map((f) => f.path)).toContain("azuredeploy.json");
81
+ const result = await auditCommand({ path: repo, format: "stylish" });
82
+ expect(result.success).toBe(true);
83
+ const ids = new Set(result.findings.map((f) => f.checkId));
84
+ expect(ids).toContain("AZR014"); // storage allows public blob access
85
+ });
86
+
87
+ test("discovers and audits GCP Config Connector (not misclassified as k8s)", async () => {
88
+ const repo = fileURLToPath(new URL("./__fixtures__/audit-gcp", import.meta.url));
89
+ const gcp = discoverGcp(repo);
90
+ expect(gcp.map((f) => f.path).sort()).toEqual(["bucket.yaml", "firewall.yaml"]);
91
+ // cnrm manifests must NOT also be picked up as k8s.
92
+ expect(discoverManifests(repo)).toEqual([]);
93
+
94
+ const result = await auditCommand({ path: repo, format: "stylish" });
95
+ expect(result.success).toBe(true);
96
+ const ids = new Set(result.findings.map((f) => f.checkId));
97
+ expect(ids).toContain("WGC109"); // firewall open to 0.0.0.0/0
98
+ // k8s checks did not run on these.
99
+ expect([...ids].some((id) => id.startsWith("WK8"))).toBe(false);
100
+ });
101
+
102
+ test("discovers and audits a Helm chart (as a bundle, not loose manifests)", async () => {
103
+ const repo = fileURLToPath(new URL("./__fixtures__/audit-helm", import.meta.url));
104
+ const charts = discoverHelm(repo);
105
+ expect(charts).toHaveLength(1);
106
+ expect(charts[0].lexicon).toBe("helm");
107
+ expect(charts[0].files!["Chart.yaml"]).toContain("name: mychart");
108
+ expect(charts[0].files!["templates/deployment.yaml"]).toContain("privileged");
109
+
110
+ const result = await auditCommand({ path: repo, format: "stylish" });
111
+ expect(result.success).toBe(true);
112
+ const ids = new Set(result.findings.map((f) => f.checkId));
113
+ expect(ids).toContain("WHM401"); // :latest image in the chart
114
+ expect(ids).toContain("WHM404"); // privileged container in a template
115
+ // the chart's template was NOT double-audited as a loose k8s manifest
116
+ expect([...ids].some((id) => id.startsWith("WK8"))).toBe(false);
117
+ });
118
+
61
119
  test("discovers CI files under a repo root", () => {
62
120
  const files = discoverCiFiles(REPO);
63
121
  expect(files.map((f) => f.path)).toContain(".github/workflows/ci.yml");
@@ -146,14 +146,17 @@ function readSafe(full: string): string | undefined {
146
146
  }
147
147
  }
148
148
 
149
- /** True if any YAML document in the content is a Kubernetes manifest. */
149
+ const CNRM = "cnrm.cloud.google.com";
150
+
151
+ /** True if any YAML doc is a Kubernetes manifest (excluding GCP Config Connector). */
150
152
  function looksLikeK8s(content: string): boolean {
151
153
  for (const doc of content.split(/\n---\n/)) {
152
154
  const t = doc.trim();
153
155
  if (!t) continue;
154
156
  try {
155
157
  const obj = parseYAML(t) as Record<string, unknown>;
156
- if (obj && typeof obj.apiVersion === "string" && typeof obj.kind === "string") return true;
158
+ const apiVersion = obj?.apiVersion;
159
+ if (typeof apiVersion === "string" && typeof obj.kind === "string" && !apiVersion.includes(CNRM)) return true;
157
160
  } catch {
158
161
  // not parseable as a single doc — skip
159
162
  }
@@ -161,6 +164,36 @@ function looksLikeK8s(content: string): boolean {
161
164
  return false;
162
165
  }
163
166
 
167
+ /** True if any YAML doc is a GCP Config Connector resource (cnrm.cloud.google.com). */
168
+ function looksLikeGcp(content: string): boolean {
169
+ for (const doc of content.split(/\n---\n/)) {
170
+ const t = doc.trim();
171
+ if (!t) continue;
172
+ try {
173
+ const obj = parseYAML(t) as Record<string, unknown>;
174
+ if (typeof obj?.apiVersion === "string" && obj.apiVersion.includes(CNRM)) return true;
175
+ } catch {
176
+ // skip
177
+ }
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /** Discover GCP Config Connector manifests under a repo root. */
183
+ export function discoverGcp(root: string): AuditInput[] {
184
+ const files: string[] = [];
185
+ walkFiles(root, files);
186
+ const inputs: AuditInput[] = [];
187
+ for (const full of files) {
188
+ if (!isYaml(basename(full))) continue;
189
+ const content = readSafe(full);
190
+ if (content !== undefined && looksLikeGcp(content)) {
191
+ inputs.push({ path: relative(root, full), content, lexicon: "gcp" });
192
+ }
193
+ }
194
+ return inputs;
195
+ }
196
+
164
197
  /** Discover Kubernetes manifest files under a repo root (content-detected). */
165
198
  export function discoverManifests(root: string): AuditInput[] {
166
199
  const files: string[] = [];
@@ -189,6 +222,106 @@ function looksLikeCompose(content: string): boolean {
189
222
  }
190
223
  }
191
224
 
225
+ /** Parse JSON or YAML content into an object, or undefined. */
226
+ function parseStructured(content: string): Record<string, unknown> | undefined {
227
+ try {
228
+ const j = JSON.parse(content);
229
+ if (j && typeof j === "object") return j as Record<string, unknown>;
230
+ } catch {
231
+ // not JSON — try YAML
232
+ }
233
+ try {
234
+ const y = parseYAML(content) as Record<string, unknown>;
235
+ if (y && typeof y === "object") return y;
236
+ } catch {
237
+ // not YAML either
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ /** True if a parsed object is a CloudFormation template. */
243
+ function looksLikeCloudFormation(obj: Record<string, unknown>): boolean {
244
+ if (obj.AWSTemplateFormatVersion !== undefined) return true;
245
+ const resources = obj.Resources;
246
+ if (resources && typeof resources === "object") {
247
+ for (const r of Object.values(resources as Record<string, unknown>)) {
248
+ const type = r && typeof r === "object" ? (r as Record<string, unknown>).Type : undefined;
249
+ if (typeof type === "string" && type.startsWith("AWS::")) return true;
250
+ }
251
+ }
252
+ return false;
253
+ }
254
+
255
+ /**
256
+ * Discover CloudFormation templates (JSON or YAML, `.json`/`.yaml`/`.yml`/`.template`).
257
+ * The aws checks `JSON.parse` the template, so YAML templates are normalized to a
258
+ * JSON string here (intrinsics like `!Ref` survive as strings — fine for the
259
+ * structural security checks).
260
+ */
261
+ export function discoverCloudFormation(root: string): AuditInput[] {
262
+ const files: string[] = [];
263
+ walkFiles(root, files);
264
+ const inputs: AuditInput[] = [];
265
+ for (const full of files) {
266
+ const name = basename(full);
267
+ if (!/\.(json|ya?ml|template)$/i.test(name)) continue;
268
+ const content = readSafe(full);
269
+ if (content === undefined) continue;
270
+ const parsed = parseStructured(content);
271
+ if (parsed && looksLikeCloudFormation(parsed)) {
272
+ inputs.push({ path: relative(root, full), content: JSON.stringify(parsed), lexicon: "aws" });
273
+ }
274
+ }
275
+ return inputs;
276
+ }
277
+
278
+ /** True if a parsed object is an Azure ARM deployment template. */
279
+ function looksLikeArm(obj: Record<string, unknown>): boolean {
280
+ return typeof obj.$schema === "string" && obj.$schema.includes("deploymentTemplate") && Array.isArray(obj.resources);
281
+ }
282
+
283
+ /** Discover Azure ARM templates (`.json`) under a repo root. */
284
+ export function discoverArm(root: string): AuditInput[] {
285
+ const files: string[] = [];
286
+ walkFiles(root, files);
287
+ const inputs: AuditInput[] = [];
288
+ for (const full of files) {
289
+ if (!/\.json$/i.test(basename(full))) continue;
290
+ const content = readSafe(full);
291
+ if (content === undefined) continue;
292
+ const parsed = parseStructured(content);
293
+ if (parsed && looksLikeArm(parsed)) {
294
+ inputs.push({ path: relative(root, full), content: JSON.stringify(parsed), lexicon: "azure" });
295
+ }
296
+ }
297
+ return inputs;
298
+ }
299
+
300
+ /**
301
+ * Discover Helm charts. A chart (directory with Chart.yaml) is one bundle: the
302
+ * helm checks read `output.files` keyed by chart-relative path, so all of the
303
+ * chart's files are collected into a single AuditInput.files map.
304
+ */
305
+ export function discoverHelm(root: string): AuditInput[] {
306
+ const all: string[] = [];
307
+ walkFiles(root, all);
308
+ const chartDirs = all.filter((f) => basename(f) === "Chart.yaml").map((f) => f.slice(0, -("/Chart.yaml".length)));
309
+ const inputs: AuditInput[] = [];
310
+ for (const dir of chartDirs) {
311
+ const prefix = dir + "/";
312
+ const files: Record<string, string> = {};
313
+ for (const f of all) {
314
+ if (!f.startsWith(prefix)) continue;
315
+ const content = readSafe(f);
316
+ if (content !== undefined) files[f.slice(prefix.length)] = content;
317
+ }
318
+ if (files["Chart.yaml"] === undefined) continue;
319
+ const chartPath = relative(root, dir) || ".";
320
+ inputs.push({ path: chartPath, content: files["Chart.yaml"], lexicon: "helm", files });
321
+ }
322
+ return inputs;
323
+ }
324
+
192
325
  /** Discover Docker artifacts: Dockerfiles (by name) and Compose files (by `services:`). */
193
326
  export function discoverDocker(root: string): AuditInput[] {
194
327
  const files: string[] = [];
@@ -337,7 +470,20 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
337
470
  if (!existsSync(options.path)) {
338
471
  return { success: false, output: "", findings: [], scanned: [], exitCode: 1, error: `Path not found: ${options.path}` };
339
472
  }
340
- inputs = [...discoverCiFiles(options.path), ...discoverManifests(options.path), ...discoverDocker(options.path)];
473
+ // Helm claims whole chart directories; exclude chart-internal files from the
474
+ // other discoverers so raw templates aren't double-audited as loose manifests.
475
+ const helm = discoverHelm(options.path);
476
+ const chartPrefixes = helm.map((h) => (h.path === "." ? "" : `${h.path}/`));
477
+ const underChart = (p: string): boolean => chartPrefixes.some((pre) => (pre === "" ? true : p.startsWith(pre)));
478
+ const others = [
479
+ ...discoverCiFiles(options.path),
480
+ ...discoverManifests(options.path),
481
+ ...discoverDocker(options.path),
482
+ ...discoverCloudFormation(options.path),
483
+ ...discoverArm(options.path),
484
+ ...discoverGcp(options.path),
485
+ ].filter((i) => !underChart(i.path));
486
+ inputs = [...others, ...helm];
341
487
  }
342
488
 
343
489
  const scanned = inputs.map((i) => i.path);