@intentius/chant 0.5.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 +1 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +164 -0
- package/src/audit/core.test.ts +47 -1
- package/src/audit/core.ts +0 -0
- package/src/audit/rules-doc.ts +12 -6
- package/src/cli/commands/__fixtures__/audit-aws/stack.yaml +11 -0
- package/src/cli/commands/__fixtures__/audit-aws/template.json +7 -0
- package/src/cli/commands/__fixtures__/audit-azure/azuredeploy.json +13 -0
- package/src/cli/commands/__fixtures__/audit-docker/app/Dockerfile +4 -0
- package/src/cli/commands/__fixtures__/audit-docker/docker-compose.yml +5 -0
- package/src/cli/commands/__fixtures__/audit-gcp/bucket.yaml +6 -0
- package/src/cli/commands/__fixtures__/audit-gcp/firewall.yaml +9 -0
- package/src/cli/commands/__fixtures__/audit-helm/mychart/Chart.yaml +3 -0
- package/src/cli/commands/__fixtures__/audit-helm/mychart/templates/deployment.yaml +12 -0
- package/src/cli/commands/__fixtures__/audit-helm/mychart/values.yaml +1 -0
- package/src/cli/commands/__fixtures__/audit-k8s/manifests/deploy.yaml +18 -0
- package/src/cli/commands/audit.test.ts +91 -2
- package/src/cli/commands/audit.ts +238 -2
package/package.json
CHANGED
|
@@ -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"]);
|
|
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?.() ?? []) {
|
package/src/audit/catalog.ts
CHANGED
|
@@ -74,6 +74,30 @@ const GH_OIDC: Authority = {
|
|
|
74
74
|
name: "GitHub — Security hardening with OpenID Connect",
|
|
75
75
|
url: "https://docs.github.com/en/actions/concepts/security/openid-connect",
|
|
76
76
|
};
|
|
77
|
+
const K8S_PSS: Authority = {
|
|
78
|
+
name: "Kubernetes — Pod Security Standards",
|
|
79
|
+
url: "https://kubernetes.io/docs/concepts/security/pod-security-standards/",
|
|
80
|
+
};
|
|
81
|
+
const K8S_SECRETS: Authority = {
|
|
82
|
+
name: "Kubernetes — Good practices for Secrets",
|
|
83
|
+
url: "https://kubernetes.io/docs/concepts/security/secrets-good-practices/",
|
|
84
|
+
};
|
|
85
|
+
const DOCKER_SEC: Authority = {
|
|
86
|
+
name: "Docker — Security best practices",
|
|
87
|
+
url: "https://docs.docker.com/develop/security-best-practices/",
|
|
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
|
+
};
|
|
77
101
|
|
|
78
102
|
function meta(
|
|
79
103
|
id: string,
|
|
@@ -184,6 +208,146 @@ export const RULE_CATALOG: Record<string, RuleMeta> = {
|
|
|
184
208
|
// ── Forgejo (WFJ) ──────────────────────────────────────────────────
|
|
185
209
|
WFJ010: meta("WFJ010", M, G, "Unresolved action reference on Forgejo", "Use an action reference Forgejo can resolve (full URL or a mirrored action)."),
|
|
186
210
|
WFJ011: meta("WFJ011", M, G, "GitHub-hosted runner label with no Forgejo equivalent", "Use a runner label your Forgejo instance provides."),
|
|
211
|
+
|
|
212
|
+
// ── Kubernetes (WK8 / ARGO) ────────────────────────────────────────
|
|
213
|
+
ARGO002: meta("ARGO002", M, G, "Argo Application references an undeclared AppProject", "Declare the named AppProject or reference an existing project."),
|
|
214
|
+
ARGO003: meta("ARGO003", M, G, "Argo Application targets an unregistered cluster", "Point spec.destination at a registered cluster or the in-cluster target."),
|
|
215
|
+
ARGO005: meta("ARGO005", R, G, "Argo source.path may not resolve", "Ensure the source path exists under the build root."),
|
|
216
|
+
WK8005: meta("WK8005", M, G, "Hardcoded secret in env var", "Use a secretKeyRef instead of a literal value, and rotate the secret.", [K8S_SECRETS]),
|
|
217
|
+
WK8006: meta("WK8006", M, G, "Image uses :latest or no tag", "Pin the image to an explicit version tag (ideally a digest).", [SCORECARD_PINNED]),
|
|
218
|
+
WK8041: meta("WK8041", M, G, "Hardcoded API key in env var", "Move the key to a Secret and rotate it.", [K8S_SECRETS]),
|
|
219
|
+
WK8042: meta("WK8042", M, G, "Private key stored in a ConfigMap", "Store private keys in a Secret, not a ConfigMap.", [K8S_SECRETS]),
|
|
220
|
+
WK8101: meta("WK8101", M, G, "Deployment selector does not match template labels", "Align spec.selector with the pod template labels."),
|
|
221
|
+
WK8102: meta("WK8102", R, G, "Resource missing metadata labels", "Add metadata labels for filtering and tooling."),
|
|
222
|
+
WK8103: meta("WK8103", M, G, "Container missing name", "Add the required container `name`."),
|
|
223
|
+
WK8104: meta("WK8104", R, G, "Container ports not named", "Name ports for clearer Service/NetworkPolicy config."),
|
|
224
|
+
WK8105: meta("WK8105", R, G, "imagePullPolicy not explicit", "Set imagePullPolicy explicitly to avoid surprising defaults."),
|
|
225
|
+
WK8201: meta("WK8201", R, G, "Container missing resource limits", "Set CPU and memory limits."),
|
|
226
|
+
WK8202: meta("WK8202", M, G, "Privileged container", "Remove privileged: true; grant only the specific capabilities needed.", [K8S_PSS]),
|
|
227
|
+
WK8203: meta("WK8203", M, G, "Root filesystem is writable", "Set readOnlyRootFilesystem: true.", [K8S_PSS]),
|
|
228
|
+
WK8204: meta("WK8204", M, G, "Container may run as root", "Set runAsNonRoot: true (and a non-zero runAsUser).", [K8S_PSS]),
|
|
229
|
+
WK8205: meta("WK8205", M, G, "Capabilities not dropped", "drop: [ALL] and add back only what is required.", [K8S_PSS]),
|
|
230
|
+
WK8207: meta("WK8207", M, G, "Pod uses host network", "Remove hostNetwork; it bypasses network isolation.", [K8S_PSS]),
|
|
231
|
+
WK8208: meta("WK8208", M, G, "Pod shares host PID namespace", "Remove hostPID.", [K8S_PSS]),
|
|
232
|
+
WK8209: meta("WK8209", M, G, "Pod shares host IPC namespace", "Remove hostIPC.", [K8S_PSS]),
|
|
233
|
+
WK8301: meta("WK8301", R, G, "Container missing probes", "Add liveness and readiness probes."),
|
|
234
|
+
WK8302: meta("WK8302", R, G, "Deployment has a single replica", "Use replicas >= 2 for availability."),
|
|
235
|
+
WK8303: meta("WK8303", R, G, "No PodDisruptionBudget for an HA Deployment", "Add a PDB to protect availability during disruptions."),
|
|
236
|
+
WK8304: meta("WK8304", R, G, "SSL redirect without a certificate", "Provide a certificate and HTTPS listen-ports for the ssl-redirect annotation."),
|
|
237
|
+
WK8305: meta("WK8305", M, G, "Ingress backend port does not match the Service", "Point the Ingress backend at a declared Service port."),
|
|
238
|
+
WK8306: meta("WK8306", M, G, "Container command starts with a flag", "The first command element should be a binary, not a flag."),
|
|
239
|
+
WK8401: meta("WK8401", M, G, "shmSize exceeds the container memory limit", "Lower shmSize or raise the memory limit so the pod can schedule."),
|
|
240
|
+
WK8402: meta("WK8402", R, G, "RayCluster missing spec.rayVersion", "Set spec.rayVersion so KubeRay picks the right autoscaler image."),
|
|
241
|
+
WK8403: meta("WK8403", R, G, "rayVersion does not match the head image tag", "Align spec.rayVersion with the Ray version in the head container image."),
|
|
242
|
+
|
|
243
|
+
// ── Docker (DKRD) ──────────────────────────────────────────────────
|
|
244
|
+
DKRD001: meta("DKRD001", M, G, "Service uses :latest or untagged image", "Pin the image to an explicit version tag (ideally a digest).", [SCORECARD_PINNED]),
|
|
245
|
+
DKRD002: meta("DKRD002", R, G, "Named volume declared but unused", "Remove the unused volume or mount it in a service."),
|
|
246
|
+
DKRD003: meta("DKRD003", M, G, "Service exposes SSH (port 22)", "Don't expose SSH from a container; use exec/ephemeral access instead.", [DOCKER_SEC]),
|
|
247
|
+
DKRD010: meta("DKRD010", R, G, "apt-get install without --no-install-recommends", "Add --no-install-recommends to keep images small."),
|
|
248
|
+
DKRD011: meta("DKRD011", R, G, "ADD used where COPY would do", "Prefer COPY unless fetching a URL or extracting an archive."),
|
|
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."),
|
|
187
351
|
};
|
|
188
352
|
|
|
189
353
|
/** Look up catalog metadata for a check id, if known. */
|
package/src/audit/core.test.ts
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { auditFiles, type AuditInput } from "./core";
|
|
2
|
+
import { auditFiles, CROSS_FILE, type AuditInput } from "./core";
|
|
3
3
|
import type { PostSynthCheck } from "../lint/post-synth";
|
|
4
4
|
|
|
5
|
+
const ARGO_APP = `apiVersion: argoproj.io/v1alpha1
|
|
6
|
+
kind: Application
|
|
7
|
+
metadata:
|
|
8
|
+
name: myapp
|
|
9
|
+
spec:
|
|
10
|
+
project: team-a
|
|
11
|
+
source:
|
|
12
|
+
repoURL: https://example.com/repo
|
|
13
|
+
path: .
|
|
14
|
+
destination:
|
|
15
|
+
server: https://kubernetes.default.svc
|
|
16
|
+
namespace: default
|
|
17
|
+
`;
|
|
18
|
+
const ARGO_PROJECT = `apiVersion: argoproj.io/v1alpha1
|
|
19
|
+
kind: AppProject
|
|
20
|
+
metadata:
|
|
21
|
+
name: team-a
|
|
22
|
+
`;
|
|
23
|
+
const WF = (name: string) => `name: ${name}\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n`;
|
|
24
|
+
|
|
5
25
|
const DIRTY_GH = `name: CI
|
|
6
26
|
on:
|
|
7
27
|
push:
|
|
@@ -108,6 +128,32 @@ describe("auditFiles", () => {
|
|
|
108
128
|
expect(findings.map((f) => f.file).sort()).toEqual(["a.yml", "b.yml"]);
|
|
109
129
|
});
|
|
110
130
|
|
|
131
|
+
test("resolves a cross-file relationship (ARGO002 sees the AppProject in another file)", async () => {
|
|
132
|
+
// The Application alone → ARGO002 fires (project not declared here).
|
|
133
|
+
const alone = await auditFiles([{ path: "app.yaml", content: ARGO_APP, lexicon: "k8s" }]);
|
|
134
|
+
expect(alone.some((f) => f.checkId === "ARGO002")).toBe(true);
|
|
135
|
+
|
|
136
|
+
// With the AppProject in a separate file → ARGO002 must NOT fire.
|
|
137
|
+
const together = await auditFiles([
|
|
138
|
+
{ path: "app.yaml", content: ARGO_APP, lexicon: "k8s" },
|
|
139
|
+
{ path: "project.yaml", content: ARGO_PROJECT, lexicon: "k8s" },
|
|
140
|
+
]);
|
|
141
|
+
expect(together.some((f) => f.checkId === "ARGO002")).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("surfaces a genuine cross-file finding (GHA006 duplicate workflow name)", async () => {
|
|
145
|
+
const findings = await auditFiles([
|
|
146
|
+
{ path: ".github/workflows/a.yml", content: WF("CI"), lexicon: "github" },
|
|
147
|
+
{ path: ".github/workflows/b.yml", content: WF("CI"), lexicon: "github" },
|
|
148
|
+
]);
|
|
149
|
+
const dup = findings.find((f) => f.checkId === "GHA006");
|
|
150
|
+
expect(dup).toBeDefined();
|
|
151
|
+
expect(dup!.file).toBe(CROSS_FILE);
|
|
152
|
+
// A single workflow → no duplicate finding.
|
|
153
|
+
const single = await auditFiles([{ path: "a.yml", content: WF("CI"), lexicon: "github" }]);
|
|
154
|
+
expect(single.some((f) => f.checkId === "GHA006")).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
111
157
|
test("a check that throws does not abort the audit", async () => {
|
|
112
158
|
const boom: PostSynthCheck = {
|
|
113
159
|
id: "BOOM",
|
package/src/audit/core.ts
CHANGED
|
Binary file
|
package/src/audit/rules-doc.ts
CHANGED
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
|
|
10
10
|
import { RULE_CATALOG, type RuleMeta } from "./catalog";
|
|
11
11
|
|
|
12
|
-
const GROUPS: Array<{ heading: string;
|
|
13
|
-
{ heading: "GitHub Actions (GHA)",
|
|
14
|
-
{ heading: "GitLab CI (WGL)",
|
|
15
|
-
{ heading: "Forgejo (WFJ)",
|
|
12
|
+
const GROUPS: Array<{ heading: string; prefixes: string[]; blurb: string }> = [
|
|
13
|
+
{ heading: "GitHub Actions (GHA)", prefixes: ["GHA"], blurb: "Also applied to Forgejo workflows, which are GitHub-dialect." },
|
|
14
|
+
{ heading: "GitLab CI (WGL)", prefixes: ["WGL"], blurb: "" },
|
|
15
|
+
{ heading: "Forgejo (WFJ)", prefixes: ["WFJ"], blurb: "" },
|
|
16
|
+
{ heading: "Kubernetes (WK8 / ARGO)", prefixes: ["WK8", "ARGO"], blurb: "Run against Kubernetes manifests." },
|
|
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)." },
|
|
16
22
|
];
|
|
17
23
|
|
|
18
24
|
function ruleBlock(m: RuleMeta): string {
|
|
@@ -26,8 +32,8 @@ function ruleBlock(m: RuleMeta): string {
|
|
|
26
32
|
/** Render the full audit rules reference page (frontmatter + body). */
|
|
27
33
|
export function renderRulesReference(): string {
|
|
28
34
|
const ids = Object.keys(RULE_CATALOG).sort();
|
|
29
|
-
const sections = GROUPS.map(({ heading,
|
|
30
|
-
const blocks = ids.filter((id) => id.startsWith(
|
|
35
|
+
const sections = GROUPS.map(({ heading, prefixes, blurb }) => {
|
|
36
|
+
const blocks = ids.filter((id) => prefixes.some((p) => id.startsWith(p))).map((id) => ruleBlock(RULE_CATALOG[id]));
|
|
31
37
|
if (blocks.length === 0) return "";
|
|
32
38
|
return `## ${heading}\n${blurb ? `\n${blurb}\n` : ""}\n${blocks.join("\n\n")}`;
|
|
33
39
|
}).filter(Boolean);
|
|
@@ -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 @@
|
|
|
1
|
+
image: myapp:latest
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
apiVersion: apps/v1
|
|
2
|
+
kind: Deployment
|
|
3
|
+
metadata:
|
|
4
|
+
name: web
|
|
5
|
+
spec:
|
|
6
|
+
selector:
|
|
7
|
+
matchLabels:
|
|
8
|
+
app: web
|
|
9
|
+
template:
|
|
10
|
+
metadata:
|
|
11
|
+
labels:
|
|
12
|
+
app: web
|
|
13
|
+
spec:
|
|
14
|
+
containers:
|
|
15
|
+
- name: web
|
|
16
|
+
image: nginx:latest
|
|
17
|
+
securityContext:
|
|
18
|
+
privileged: true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { fileURLToPath } from "url";
|
|
3
|
-
import { auditCommand, discoverCiFiles, 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";
|
|
@@ -30,6 +30,92 @@ describe("auditCommand", () => {
|
|
|
30
30
|
expect(coverageNotes(gh)).toEqual([]);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
test("discovers and audits Kubernetes manifests", async () => {
|
|
34
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-k8s", import.meta.url));
|
|
35
|
+
const files = discoverManifests(repo);
|
|
36
|
+
expect(files.map((f) => f.path)).toContain("manifests/deploy.yaml");
|
|
37
|
+
expect(files.every((f) => f.lexicon === "k8s")).toBe(true);
|
|
38
|
+
|
|
39
|
+
const result = await auditCommand({ path: repo, format: "stylish" });
|
|
40
|
+
expect(result.success).toBe(true);
|
|
41
|
+
const ids = new Set(result.findings.map((f) => f.checkId));
|
|
42
|
+
expect(ids).toContain("WK8202"); // privileged container
|
|
43
|
+
expect(ids).toContain("WK8006"); // :latest image
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("discovers and audits Docker artifacts (nested Dockerfile + compose)", async () => {
|
|
47
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-docker", import.meta.url));
|
|
48
|
+
const files = discoverDocker(repo);
|
|
49
|
+
const paths = files.map((f) => f.path).sort();
|
|
50
|
+
expect(paths).toContain("app/Dockerfile");
|
|
51
|
+
expect(paths).toContain("docker-compose.yml");
|
|
52
|
+
|
|
53
|
+
const result = await auditCommand({ path: repo, format: "stylish" });
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
const ids = new Set(result.findings.map((f) => f.checkId));
|
|
56
|
+
expect(ids).toContain("DKRD012"); // Dockerfile has no USER (nested — basename-key fix)
|
|
57
|
+
expect(ids).toContain("DKRD010"); // apt-get without --no-install-recommends
|
|
58
|
+
expect(ids).toContain("DKRD003"); // compose exposes SSH port 22
|
|
59
|
+
});
|
|
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
|
+
|
|
33
119
|
test("discovers CI files under a repo root", () => {
|
|
34
120
|
const files = discoverCiFiles(REPO);
|
|
35
121
|
expect(files.map((f) => f.path)).toContain(".github/workflows/ci.yml");
|
|
@@ -99,11 +185,14 @@ describe("auditCommand", () => {
|
|
|
99
185
|
});
|
|
100
186
|
|
|
101
187
|
test("a path with no CI files succeeds with a clear message", async () => {
|
|
102
|
-
const tmp =
|
|
188
|
+
const tmp = join(tmpdir(), `chant-audit-empty-${process.pid}`);
|
|
189
|
+
const { mkdirSync } = await import("fs");
|
|
190
|
+
mkdirSync(tmp, { recursive: true });
|
|
103
191
|
const result = await auditCommand({ path: tmp });
|
|
104
192
|
expect(result.success).toBe(true);
|
|
105
193
|
expect(result.exitCode).toBe(0);
|
|
106
194
|
expect(result.output).toContain("No CI files found");
|
|
195
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
107
196
|
});
|
|
108
197
|
|
|
109
198
|
test("audits a remote repo URL via injected fetch", async () => {
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
9
|
-
import { join, relative } from "path";
|
|
9
|
+
import { join, relative, basename } from "path";
|
|
10
|
+
import { parseYAML } from "../../yaml";
|
|
10
11
|
import { auditFiles, type AuditInput, type AuditFinding, type AuditLexicon, type ChecksProvider } from "../../audit/core";
|
|
11
12
|
import { RULE_CATALOG } from "../../audit/catalog";
|
|
12
13
|
import { renderMarkdown } from "../../audit/report";
|
|
@@ -115,6 +116,228 @@ export function discoverCiFiles(root: string): AuditInput[] {
|
|
|
115
116
|
return inputs;
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
const WALK_SKIP = new Set(["node_modules", ".git", "dist", ".github", ".forgejo"]);
|
|
120
|
+
const MAX_WALK_FILES = 1000;
|
|
121
|
+
|
|
122
|
+
/** Recursively collect file paths under a root, skipping noise/dot dirs. */
|
|
123
|
+
function walkFiles(dir: string, out: string[]): void {
|
|
124
|
+
if (out.length >= MAX_WALK_FILES) return;
|
|
125
|
+
let entries;
|
|
126
|
+
try {
|
|
127
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
128
|
+
} catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
132
|
+
if (out.length >= MAX_WALK_FILES) return;
|
|
133
|
+
if (e.name.startsWith(".") && e.isDirectory()) continue;
|
|
134
|
+
if (WALK_SKIP.has(e.name)) continue;
|
|
135
|
+
const full = join(dir, e.name);
|
|
136
|
+
if (e.isDirectory()) walkFiles(full, out);
|
|
137
|
+
else out.push(full);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readSafe(full: string): string | undefined {
|
|
142
|
+
try {
|
|
143
|
+
return readFileSync(full, "utf-8");
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const CNRM = "cnrm.cloud.google.com";
|
|
150
|
+
|
|
151
|
+
/** True if any YAML doc is a Kubernetes manifest (excluding GCP Config Connector). */
|
|
152
|
+
function looksLikeK8s(content: string): boolean {
|
|
153
|
+
for (const doc of content.split(/\n---\n/)) {
|
|
154
|
+
const t = doc.trim();
|
|
155
|
+
if (!t) continue;
|
|
156
|
+
try {
|
|
157
|
+
const obj = parseYAML(t) as Record<string, unknown>;
|
|
158
|
+
const apiVersion = obj?.apiVersion;
|
|
159
|
+
if (typeof apiVersion === "string" && typeof obj.kind === "string" && !apiVersion.includes(CNRM)) return true;
|
|
160
|
+
} catch {
|
|
161
|
+
// not parseable as a single doc — skip
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
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
|
+
|
|
197
|
+
/** Discover Kubernetes manifest files under a repo root (content-detected). */
|
|
198
|
+
export function discoverManifests(root: string): AuditInput[] {
|
|
199
|
+
const files: string[] = [];
|
|
200
|
+
walkFiles(root, files);
|
|
201
|
+
const inputs: AuditInput[] = [];
|
|
202
|
+
for (const full of files) {
|
|
203
|
+
if (!isYaml(basename(full))) continue;
|
|
204
|
+
const content = readSafe(full);
|
|
205
|
+
if (content !== undefined && looksLikeK8s(content)) {
|
|
206
|
+
inputs.push({ path: relative(root, full), content, lexicon: "k8s" });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return inputs;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isDockerfileName(name: string): boolean {
|
|
213
|
+
return name === "Dockerfile" || name.startsWith("Dockerfile.") || name.endsWith(".Dockerfile") || name.endsWith(".dockerfile");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function looksLikeCompose(content: string): boolean {
|
|
217
|
+
try {
|
|
218
|
+
const obj = parseYAML(content) as Record<string, unknown>;
|
|
219
|
+
return Boolean(obj) && typeof obj === "object" && "services" in obj;
|
|
220
|
+
} catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
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
|
+
|
|
325
|
+
/** Discover Docker artifacts: Dockerfiles (by name) and Compose files (by `services:`). */
|
|
326
|
+
export function discoverDocker(root: string): AuditInput[] {
|
|
327
|
+
const files: string[] = [];
|
|
328
|
+
walkFiles(root, files);
|
|
329
|
+
const inputs: AuditInput[] = [];
|
|
330
|
+
for (const full of files) {
|
|
331
|
+
const name = basename(full);
|
|
332
|
+
const content = readSafe(full);
|
|
333
|
+
if (content === undefined) continue;
|
|
334
|
+
if (isDockerfileName(name) || (isYaml(name) && looksLikeCompose(content))) {
|
|
335
|
+
inputs.push({ path: relative(root, full), content, lexicon: "docker" });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return inputs;
|
|
339
|
+
}
|
|
340
|
+
|
|
118
341
|
function isMergeWorthy(f: AuditFinding): boolean {
|
|
119
342
|
return RULE_CATALOG[f.checkId]?.tier === "merge-worthy";
|
|
120
343
|
}
|
|
@@ -247,7 +470,20 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
|
|
|
247
470
|
if (!existsSync(options.path)) {
|
|
248
471
|
return { success: false, output: "", findings: [], scanned: [], exitCode: 1, error: `Path not found: ${options.path}` };
|
|
249
472
|
}
|
|
250
|
-
|
|
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];
|
|
251
487
|
}
|
|
252
488
|
|
|
253
489
|
const scanned = inputs.map((i) => i.path);
|