@intentius/chant-lexicon-k8s 0.0.15 → 0.0.18

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,121 @@
1
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
2
+ import * as ts from "typescript";
3
+
4
+ /**
5
+ * WK8002: Latest Image Tag
6
+ *
7
+ * Detects when a K8s workload resource uses the `:latest` image tag or no tag
8
+ * at all in a container image string literal. Untagged or `:latest` images are
9
+ * non-deterministic and can cause unexpected rollouts.
10
+ *
11
+ * Bad: new Deployment({ spec: { template: { spec: { containers: [{ image: "nginx:latest" }] } } } })
12
+ * Bad: new Deployment({ spec: { template: { spec: { containers: [{ image: "nginx" }] } } } })
13
+ * Good: new Deployment({ spec: { template: { spec: { containers: [{ image: "nginx:1.25" }] } } } })
14
+ */
15
+
16
+ const WORKLOAD_KINDS = new Set([
17
+ "Deployment",
18
+ "StatefulSet",
19
+ "DaemonSet",
20
+ "CronJob",
21
+ "Job",
22
+ "ReplicaSet",
23
+ "Pod",
24
+ ]);
25
+
26
+ /**
27
+ * Returns true if the string looks like a container image reference that is
28
+ * either untagged or using `:latest`.
29
+ *
30
+ * A string is considered a container image if it:
31
+ * - Contains at least one alphabetic character
32
+ * - Does not contain spaces
33
+ * - Is not a simple keyword like "true", "false", etc.
34
+ */
35
+ function isProblematicImage(value: string): boolean {
36
+ if (!value || value.includes(" ") || value.length === 0) return false;
37
+
38
+ // Skip values that are clearly not images
39
+ const nonImagePatterns = [
40
+ /^(true|false|null|undefined|yes|no)$/i,
41
+ /^\d+$/, // pure numbers
42
+ /^[.\/]/, // relative/absolute paths without image-like structure
43
+ ];
44
+ for (const pat of nonImagePatterns) {
45
+ if (pat.test(value)) return false;
46
+ }
47
+
48
+ // Check for :latest explicitly
49
+ if (value.endsWith(":latest")) return true;
50
+
51
+ // Check for untagged image: no colon at all, but looks like an image name
52
+ // Images contain alphanumeric chars and may have / for registry prefix
53
+ // Must have at least one alpha char and match image naming conventions
54
+ if (!value.includes(":") && !value.includes("@") && /^[a-zA-Z0-9._\-\/]+$/.test(value) && /[a-zA-Z]/.test(value)) {
55
+ return true;
56
+ }
57
+
58
+ return false;
59
+ }
60
+
61
+ export const latestImageTagRule: LintRule = {
62
+ id: "WK8002",
63
+ severity: "warning",
64
+ category: "security",
65
+ description:
66
+ "Detects :latest or untagged container images — use explicit version tags for reproducibility",
67
+
68
+ check(context: LintContext): LintDiagnostic[] {
69
+ const { sourceFile } = context;
70
+ const diagnostics: LintDiagnostic[] = [];
71
+
72
+ function isInsideWorkloadConstructor(node: ts.Node): boolean {
73
+ let current: ts.Node | undefined = node.parent;
74
+ while (current) {
75
+ if (
76
+ ts.isNewExpression(current) &&
77
+ ts.isIdentifier(current.expression) &&
78
+ WORKLOAD_KINDS.has(current.expression.text)
79
+ ) {
80
+ return true;
81
+ }
82
+ current = current.parent;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ function visit(node: ts.Node): void {
88
+ // Look for property assignments like `image: "nginx:latest"` or `image: "nginx"`
89
+ if (
90
+ ts.isPropertyAssignment(node) &&
91
+ ts.isIdentifier(node.name) &&
92
+ node.name.text === "image" &&
93
+ ts.isStringLiteral(node.initializer) &&
94
+ isInsideWorkloadConstructor(node)
95
+ ) {
96
+ const value = node.initializer.text;
97
+ if (isProblematicImage(value)) {
98
+ const { line, character } =
99
+ sourceFile.getLineAndCharacterOfPosition(
100
+ node.initializer.getStart(),
101
+ );
102
+ const isLatest = value.endsWith(":latest");
103
+ diagnostics.push({
104
+ file: sourceFile.fileName,
105
+ line: line + 1,
106
+ column: character + 1,
107
+ ruleId: "WK8002",
108
+ severity: "warning",
109
+ message: isLatest
110
+ ? `Container image "${value}" uses the :latest tag. Pin to a specific version for reproducibility.`
111
+ : `Container image "${value}" has no tag. Pin to a specific version for reproducibility.`,
112
+ });
113
+ }
114
+ }
115
+ ts.forEachChild(node, visit);
116
+ }
117
+
118
+ visit(sourceFile);
119
+ return diagnostics;
120
+ },
121
+ };
@@ -0,0 +1,111 @@
1
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
2
+ import * as ts from "typescript";
3
+
4
+ /**
5
+ * WK8003: Missing Resource Limits
6
+ *
7
+ * Detects when a container in a Deployment/StatefulSet spec doesn't have
8
+ * resource limits or requests. Without resource limits, a container can
9
+ * consume unbounded cluster resources and cause noisy-neighbour issues.
10
+ *
11
+ * Bad: new Deployment({ spec: { template: { spec: { containers: [{ name: "app", image: "app:1.0" }] } } } })
12
+ * Good: new Deployment({ spec: { template: { spec: { containers: [{ name: "app", image: "app:1.0", resources: { limits: { cpu: "500m", memory: "256Mi" } } }] } } } })
13
+ */
14
+
15
+ const WORKLOAD_KINDS = new Set([
16
+ "Deployment",
17
+ "StatefulSet",
18
+ "DaemonSet",
19
+ "CronJob",
20
+ "Job",
21
+ "ReplicaSet",
22
+ ]);
23
+
24
+ export const missingResourceLimitsRule: LintRule = {
25
+ id: "WK8003",
26
+ severity: "warning",
27
+ category: "correctness",
28
+ description:
29
+ "Detects containers without resource limits/requests — always set resource constraints",
30
+
31
+ check(context: LintContext): LintDiagnostic[] {
32
+ const { sourceFile } = context;
33
+ const diagnostics: LintDiagnostic[] = [];
34
+
35
+ function isInsideWorkloadConstructor(node: ts.Node): boolean {
36
+ let current: ts.Node | undefined = node.parent;
37
+ while (current) {
38
+ if (
39
+ ts.isNewExpression(current) &&
40
+ ts.isIdentifier(current.expression) &&
41
+ WORKLOAD_KINDS.has(current.expression.text)
42
+ ) {
43
+ return true;
44
+ }
45
+ current = current.parent;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function objectLiteralHasProperty(
51
+ obj: ts.ObjectLiteralExpression,
52
+ name: string,
53
+ ): boolean {
54
+ return obj.properties.some(
55
+ (p) =>
56
+ ts.isPropertyAssignment(p) &&
57
+ ts.isIdentifier(p.name) &&
58
+ p.name.text === name,
59
+ );
60
+ }
61
+
62
+ function visit(node: ts.Node): void {
63
+ // Look for object literals inside arrays that represent container specs.
64
+ // A container object literal typically has "name" and "image" properties.
65
+ // We flag it if it lacks a "resources" property.
66
+ if (
67
+ ts.isObjectLiteralExpression(node) &&
68
+ isInsideWorkloadConstructor(node)
69
+ ) {
70
+ const hasName = objectLiteralHasProperty(node, "name");
71
+ const hasImage = objectLiteralHasProperty(node, "image");
72
+ const hasResources = objectLiteralHasProperty(node, "resources");
73
+
74
+ if (hasName && hasImage && !hasResources) {
75
+ // Confirm we're inside an array literal (containers array)
76
+ if (node.parent && ts.isArrayLiteralExpression(node.parent)) {
77
+ const { line, character } =
78
+ sourceFile.getLineAndCharacterOfPosition(node.getStart());
79
+
80
+ // Try to extract the container name for a better message
81
+ let containerName = "unknown";
82
+ for (const prop of node.properties) {
83
+ if (
84
+ ts.isPropertyAssignment(prop) &&
85
+ ts.isIdentifier(prop.name) &&
86
+ prop.name.text === "name" &&
87
+ ts.isStringLiteral(prop.initializer)
88
+ ) {
89
+ containerName = prop.initializer.text;
90
+ break;
91
+ }
92
+ }
93
+
94
+ diagnostics.push({
95
+ file: sourceFile.fileName,
96
+ line: line + 1,
97
+ column: character + 1,
98
+ ruleId: "WK8003",
99
+ severity: "warning",
100
+ message: `Container "${containerName}" is missing resource limits/requests. Set resources.limits and resources.requests to prevent unbounded resource consumption.`,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ ts.forEachChild(node, visit);
106
+ }
107
+
108
+ visit(sourceFile);
109
+ return diagnostics;
110
+ },
111
+ };
@@ -1,5 +1,7 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { hardcodedNamespaceRule } from "./hardcoded-namespace";
3
+ import { latestImageTagRule } from "./latest-image-tag";
4
+ import { missingResourceLimitsRule } from "./missing-resource-limits";
3
5
  import * as ts from "typescript";
4
6
 
5
7
  function createContext(code: string) {
@@ -67,3 +69,193 @@ describe("WK8001: Hardcoded Namespace", () => {
67
69
  expect(diags.length).toBe(2);
68
70
  });
69
71
  });
72
+
73
+ // ── WK8002: Latest Image Tag ────────────────────────────────────────
74
+
75
+ describe("WK8002: Latest Image Tag", () => {
76
+ test("rule metadata", () => {
77
+ expect(latestImageTagRule.id).toBe("WK8002");
78
+ expect(latestImageTagRule.severity).toBe("warning");
79
+ expect(latestImageTagRule.category).toBe("security");
80
+ });
81
+
82
+ test("flags image: 'nginx:latest'", () => {
83
+ const ctx = createContext(
84
+ `new Deployment({ spec: { template: { spec: { containers: [{ name: "app", image: "nginx:latest" }] } } } });`,
85
+ );
86
+ const diags = latestImageTagRule.check(ctx);
87
+ expect(diags.length).toBe(1);
88
+ expect(diags[0].ruleId).toBe("WK8002");
89
+ expect(diags[0].message).toContain(":latest");
90
+ });
91
+
92
+ test("flags untagged image: 'nginx'", () => {
93
+ const ctx = createContext(
94
+ `new Deployment({ spec: { template: { spec: { containers: [{ name: "app", image: "nginx" }] } } } });`,
95
+ );
96
+ const diags = latestImageTagRule.check(ctx);
97
+ expect(diags.length).toBe(1);
98
+ expect(diags[0].ruleId).toBe("WK8002");
99
+ expect(diags[0].message).toContain("no tag");
100
+ });
101
+
102
+ test("flags registry-prefixed untagged image: 'ghcr.io/org/app'", () => {
103
+ const ctx = createContext(
104
+ `new Deployment({ spec: { template: { spec: { containers: [{ name: "app", image: "ghcr.io/org/app" }] } } } });`,
105
+ );
106
+ const diags = latestImageTagRule.check(ctx);
107
+ expect(diags.length).toBe(1);
108
+ });
109
+
110
+ test("does NOT flag explicitly tagged image: 'nginx:1.25'", () => {
111
+ const ctx = createContext(
112
+ `new Deployment({ spec: { template: { spec: { containers: [{ name: "app", image: "nginx:1.25" }] } } } });`,
113
+ );
114
+ const diags = latestImageTagRule.check(ctx);
115
+ expect(diags.length).toBe(0);
116
+ });
117
+
118
+ test("does NOT flag image with digest", () => {
119
+ const ctx = createContext(
120
+ `new Deployment({ spec: { template: { spec: { containers: [{ name: "app", image: "nginx@sha256:abc123" }] } } } });`,
121
+ );
122
+ const diags = latestImageTagRule.check(ctx);
123
+ expect(diags.length).toBe(0);
124
+ });
125
+
126
+ test("flags :latest in StatefulSet", () => {
127
+ const ctx = createContext(
128
+ `new StatefulSet({ spec: { template: { spec: { containers: [{ name: "db", image: "postgres:latest" }] } } } });`,
129
+ );
130
+ const diags = latestImageTagRule.check(ctx);
131
+ expect(diags.length).toBe(1);
132
+ });
133
+
134
+ test("flags :latest in DaemonSet", () => {
135
+ const ctx = createContext(
136
+ `new DaemonSet({ spec: { template: { spec: { containers: [{ name: "agent", image: "datadog:latest" }] } } } });`,
137
+ );
138
+ const diags = latestImageTagRule.check(ctx);
139
+ expect(diags.length).toBe(1);
140
+ });
141
+
142
+ test("flags :latest in CronJob", () => {
143
+ const ctx = createContext(
144
+ `new CronJob({ spec: { jobTemplate: { spec: { template: { spec: { containers: [{ name: "job", image: "worker:latest" }] } } } } } });`,
145
+ );
146
+ const diags = latestImageTagRule.check(ctx);
147
+ expect(diags.length).toBe(1);
148
+ });
149
+
150
+ test("does NOT flag image property outside workload constructor", () => {
151
+ const ctx = createContext(
152
+ `const config = { image: "nginx:latest" };`,
153
+ );
154
+ const diags = latestImageTagRule.check(ctx);
155
+ expect(diags.length).toBe(0);
156
+ });
157
+
158
+ test("flags multiple containers with bad images", () => {
159
+ const ctx = createContext(`
160
+ new Deployment({
161
+ spec: {
162
+ template: {
163
+ spec: {
164
+ containers: [
165
+ { name: "app", image: "nginx:latest" },
166
+ { name: "sidecar", image: "envoy" },
167
+ ],
168
+ },
169
+ },
170
+ },
171
+ });
172
+ `);
173
+ const diags = latestImageTagRule.check(ctx);
174
+ expect(diags.length).toBe(2);
175
+ });
176
+ });
177
+
178
+ // ── WK8003: Missing Resource Limits ─────────────────────────────────
179
+
180
+ describe("WK8003: Missing Resource Limits", () => {
181
+ test("rule metadata", () => {
182
+ expect(missingResourceLimitsRule.id).toBe("WK8003");
183
+ expect(missingResourceLimitsRule.severity).toBe("warning");
184
+ expect(missingResourceLimitsRule.category).toBe("correctness");
185
+ });
186
+
187
+ test("flags container without resources property", () => {
188
+ const ctx = createContext(
189
+ `new Deployment({ spec: { template: { spec: { containers: [{ name: "app", image: "app:1.0" }] } } } });`,
190
+ );
191
+ const diags = missingResourceLimitsRule.check(ctx);
192
+ expect(diags.length).toBe(1);
193
+ expect(diags[0].ruleId).toBe("WK8003");
194
+ expect(diags[0].message).toContain("app");
195
+ expect(diags[0].message).toContain("resource limits");
196
+ });
197
+
198
+ test("does NOT flag container with resources property", () => {
199
+ const ctx = createContext(
200
+ `new Deployment({ spec: { template: { spec: { containers: [{ name: "app", image: "app:1.0", resources: { limits: { cpu: "500m", memory: "256Mi" } } }] } } } });`,
201
+ );
202
+ const diags = missingResourceLimitsRule.check(ctx);
203
+ expect(diags.length).toBe(0);
204
+ });
205
+
206
+ test("flags container in StatefulSet", () => {
207
+ const ctx = createContext(
208
+ `new StatefulSet({ spec: { template: { spec: { containers: [{ name: "db", image: "postgres:15" }] } } } });`,
209
+ );
210
+ const diags = missingResourceLimitsRule.check(ctx);
211
+ expect(diags.length).toBe(1);
212
+ expect(diags[0].message).toContain("db");
213
+ });
214
+
215
+ test("flags only containers without resources in mixed array", () => {
216
+ const ctx = createContext(`
217
+ new Deployment({
218
+ spec: {
219
+ template: {
220
+ spec: {
221
+ containers: [
222
+ { name: "app", image: "app:1.0", resources: { limits: { cpu: "1" } } },
223
+ { name: "sidecar", image: "envoy:1.0" },
224
+ ],
225
+ },
226
+ },
227
+ },
228
+ });
229
+ `);
230
+ const diags = missingResourceLimitsRule.check(ctx);
231
+ expect(diags.length).toBe(1);
232
+ expect(diags[0].message).toContain("sidecar");
233
+ });
234
+
235
+ test("does NOT flag outside workload constructor", () => {
236
+ const ctx = createContext(
237
+ `const containers = [{ name: "app", image: "app:1.0" }];`,
238
+ );
239
+ const diags = missingResourceLimitsRule.check(ctx);
240
+ expect(diags.length).toBe(0);
241
+ });
242
+
243
+ test("flags multiple containers without resources", () => {
244
+ const ctx = createContext(`
245
+ new Deployment({
246
+ spec: {
247
+ template: {
248
+ spec: {
249
+ containers: [
250
+ { name: "web", image: "nginx:1.25" },
251
+ { name: "api", image: "api:2.0" },
252
+ ],
253
+ },
254
+ },
255
+ },
256
+ });
257
+ `);
258
+ const diags = missingResourceLimitsRule.check(ctx);
259
+ expect(diags.length).toBe(2);
260
+ });
261
+ });