@intentius/chant 0.5.0 → 0.6.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.5.0",
3
+ "version": "0.6.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"]);
7
+ const plugins = await loadPlugins(["github", "gitlab", "forgejo", "k8s", "docker"]);
8
8
  const ids = new Set<string>();
9
9
  for (const plugin of plugins) {
10
10
  for (const check of plugin.postSynthChecks?.() ?? []) {
@@ -74,6 +74,18 @@ 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
+ };
77
89
 
78
90
  function meta(
79
91
  id: string,
@@ -184,6 +196,45 @@ export const RULE_CATALOG: Record<string, RuleMeta> = {
184
196
  // ── Forgejo (WFJ) ──────────────────────────────────────────────────
185
197
  WFJ010: meta("WFJ010", M, G, "Unresolved action reference on Forgejo", "Use an action reference Forgejo can resolve (full URL or a mirrored action)."),
186
198
  WFJ011: meta("WFJ011", M, G, "GitHub-hosted runner label with no Forgejo equivalent", "Use a runner label your Forgejo instance provides."),
199
+
200
+ // ── Kubernetes (WK8 / ARGO) ────────────────────────────────────────
201
+ ARGO002: meta("ARGO002", M, G, "Argo Application references an undeclared AppProject", "Declare the named AppProject or reference an existing project."),
202
+ ARGO003: meta("ARGO003", M, G, "Argo Application targets an unregistered cluster", "Point spec.destination at a registered cluster or the in-cluster target."),
203
+ ARGO005: meta("ARGO005", R, G, "Argo source.path may not resolve", "Ensure the source path exists under the build root."),
204
+ WK8005: meta("WK8005", M, G, "Hardcoded secret in env var", "Use a secretKeyRef instead of a literal value, and rotate the secret.", [K8S_SECRETS]),
205
+ WK8006: meta("WK8006", M, G, "Image uses :latest or no tag", "Pin the image to an explicit version tag (ideally a digest).", [SCORECARD_PINNED]),
206
+ WK8041: meta("WK8041", M, G, "Hardcoded API key in env var", "Move the key to a Secret and rotate it.", [K8S_SECRETS]),
207
+ WK8042: meta("WK8042", M, G, "Private key stored in a ConfigMap", "Store private keys in a Secret, not a ConfigMap.", [K8S_SECRETS]),
208
+ WK8101: meta("WK8101", M, G, "Deployment selector does not match template labels", "Align spec.selector with the pod template labels."),
209
+ WK8102: meta("WK8102", R, G, "Resource missing metadata labels", "Add metadata labels for filtering and tooling."),
210
+ WK8103: meta("WK8103", M, G, "Container missing name", "Add the required container `name`."),
211
+ WK8104: meta("WK8104", R, G, "Container ports not named", "Name ports for clearer Service/NetworkPolicy config."),
212
+ WK8105: meta("WK8105", R, G, "imagePullPolicy not explicit", "Set imagePullPolicy explicitly to avoid surprising defaults."),
213
+ WK8201: meta("WK8201", R, G, "Container missing resource limits", "Set CPU and memory limits."),
214
+ WK8202: meta("WK8202", M, G, "Privileged container", "Remove privileged: true; grant only the specific capabilities needed.", [K8S_PSS]),
215
+ WK8203: meta("WK8203", M, G, "Root filesystem is writable", "Set readOnlyRootFilesystem: true.", [K8S_PSS]),
216
+ WK8204: meta("WK8204", M, G, "Container may run as root", "Set runAsNonRoot: true (and a non-zero runAsUser).", [K8S_PSS]),
217
+ WK8205: meta("WK8205", M, G, "Capabilities not dropped", "drop: [ALL] and add back only what is required.", [K8S_PSS]),
218
+ WK8207: meta("WK8207", M, G, "Pod uses host network", "Remove hostNetwork; it bypasses network isolation.", [K8S_PSS]),
219
+ WK8208: meta("WK8208", M, G, "Pod shares host PID namespace", "Remove hostPID.", [K8S_PSS]),
220
+ WK8209: meta("WK8209", M, G, "Pod shares host IPC namespace", "Remove hostIPC.", [K8S_PSS]),
221
+ WK8301: meta("WK8301", R, G, "Container missing probes", "Add liveness and readiness probes."),
222
+ WK8302: meta("WK8302", R, G, "Deployment has a single replica", "Use replicas >= 2 for availability."),
223
+ WK8303: meta("WK8303", R, G, "No PodDisruptionBudget for an HA Deployment", "Add a PDB to protect availability during disruptions."),
224
+ WK8304: meta("WK8304", R, G, "SSL redirect without a certificate", "Provide a certificate and HTTPS listen-ports for the ssl-redirect annotation."),
225
+ WK8305: meta("WK8305", M, G, "Ingress backend port does not match the Service", "Point the Ingress backend at a declared Service port."),
226
+ WK8306: meta("WK8306", M, G, "Container command starts with a flag", "The first command element should be a binary, not a flag."),
227
+ WK8401: meta("WK8401", M, G, "shmSize exceeds the container memory limit", "Lower shmSize or raise the memory limit so the pod can schedule."),
228
+ WK8402: meta("WK8402", R, G, "RayCluster missing spec.rayVersion", "Set spec.rayVersion so KubeRay picks the right autoscaler image."),
229
+ 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."),
230
+
231
+ // ── Docker (DKRD) ──────────────────────────────────────────────────
232
+ DKRD001: meta("DKRD001", M, G, "Service uses :latest or untagged image", "Pin the image to an explicit version tag (ideally a digest).", [SCORECARD_PINNED]),
233
+ DKRD002: meta("DKRD002", R, G, "Named volume declared but unused", "Remove the unused volume or mount it in a service."),
234
+ DKRD003: meta("DKRD003", M, G, "Service exposes SSH (port 22)", "Don't expose SSH from a container; use exec/ephemeral access instead.", [DOCKER_SEC]),
235
+ DKRD010: meta("DKRD010", R, G, "apt-get install without --no-install-recommends", "Add --no-install-recommends to keep images small."),
236
+ DKRD011: meta("DKRD011", R, G, "ADD used where COPY would do", "Prefer COPY unless fetching a URL or extracting an archive."),
237
+ DKRD012: meta("DKRD012", M, G, "No USER instruction — container runs as root", "Add a non-root USER instruction.", [DOCKER_SEC]),
187
238
  };
188
239
 
189
240
  /** Look up catalog metadata for a check id, if known. */
@@ -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
@@ -9,10 +9,12 @@
9
9
 
10
10
  import { RULE_CATALOG, type RuleMeta } from "./catalog";
11
11
 
12
- const GROUPS: Array<{ heading: string; prefix: string; blurb: string }> = [
13
- { heading: "GitHub Actions (GHA)", prefix: "GHA", blurb: "Also applied to Forgejo workflows, which are GitHub-dialect." },
14
- { heading: "GitLab CI (WGL)", prefix: "WGL", blurb: "" },
15
- { heading: "Forgejo (WFJ)", prefix: "WFJ", blurb: "" },
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." },
16
18
  ];
17
19
 
18
20
  function ruleBlock(m: RuleMeta): string {
@@ -26,8 +28,8 @@ function ruleBlock(m: RuleMeta): string {
26
28
  /** Render the full audit rules reference page (frontmatter + body). */
27
29
  export function renderRulesReference(): string {
28
30
  const ids = Object.keys(RULE_CATALOG).sort();
29
- const sections = GROUPS.map(({ heading, prefix, blurb }) => {
30
- const blocks = ids.filter((id) => id.startsWith(prefix)).map((id) => ruleBlock(RULE_CATALOG[id]));
31
+ const sections = GROUPS.map(({ heading, prefixes, blurb }) => {
32
+ const blocks = ids.filter((id) => prefixes.some((p) => id.startsWith(p))).map((id) => ruleBlock(RULE_CATALOG[id]));
31
33
  if (blocks.length === 0) return "";
32
34
  return `## ${heading}\n${blurb ? `\n${blurb}\n` : ""}\n${blocks.join("\n\n")}`;
33
35
  }).filter(Boolean);
@@ -0,0 +1,4 @@
1
+ FROM node:20
2
+ RUN apt-get update && apt-get install -y curl
3
+ COPY . /app
4
+ CMD ["node", "server.js"]
@@ -0,0 +1,5 @@
1
+ services:
2
+ web:
3
+ image: myapp:latest
4
+ ports:
5
+ - "22:22"
@@ -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, 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,34 @@ 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
+
33
61
  test("discovers CI files under a repo root", () => {
34
62
  const files = discoverCiFiles(REPO);
35
63
  expect(files.map((f) => f.path)).toContain(".github/workflows/ci.yml");
@@ -99,11 +127,14 @@ describe("auditCommand", () => {
99
127
  });
100
128
 
101
129
  test("a path with no CI files succeeds with a clear message", async () => {
102
- const tmp = fileURLToPath(new URL(".", import.meta.url)); // commands dir, no CI files
130
+ const tmp = join(tmpdir(), `chant-audit-empty-${process.pid}`);
131
+ const { mkdirSync } = await import("fs");
132
+ mkdirSync(tmp, { recursive: true });
103
133
  const result = await auditCommand({ path: tmp });
104
134
  expect(result.success).toBe(true);
105
135
  expect(result.exitCode).toBe(0);
106
136
  expect(result.output).toContain("No CI files found");
137
+ rmSync(tmp, { recursive: true, force: true });
107
138
  });
108
139
 
109
140
  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,95 @@ 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
+ /** True if any YAML document in the content is a Kubernetes manifest. */
150
+ function looksLikeK8s(content: string): boolean {
151
+ for (const doc of content.split(/\n---\n/)) {
152
+ const t = doc.trim();
153
+ if (!t) continue;
154
+ try {
155
+ const obj = parseYAML(t) as Record<string, unknown>;
156
+ if (obj && typeof obj.apiVersion === "string" && typeof obj.kind === "string") return true;
157
+ } catch {
158
+ // not parseable as a single doc — skip
159
+ }
160
+ }
161
+ return false;
162
+ }
163
+
164
+ /** Discover Kubernetes manifest files under a repo root (content-detected). */
165
+ export function discoverManifests(root: string): AuditInput[] {
166
+ const files: string[] = [];
167
+ walkFiles(root, files);
168
+ const inputs: AuditInput[] = [];
169
+ for (const full of files) {
170
+ if (!isYaml(basename(full))) continue;
171
+ const content = readSafe(full);
172
+ if (content !== undefined && looksLikeK8s(content)) {
173
+ inputs.push({ path: relative(root, full), content, lexicon: "k8s" });
174
+ }
175
+ }
176
+ return inputs;
177
+ }
178
+
179
+ function isDockerfileName(name: string): boolean {
180
+ return name === "Dockerfile" || name.startsWith("Dockerfile.") || name.endsWith(".Dockerfile") || name.endsWith(".dockerfile");
181
+ }
182
+
183
+ function looksLikeCompose(content: string): boolean {
184
+ try {
185
+ const obj = parseYAML(content) as Record<string, unknown>;
186
+ return Boolean(obj) && typeof obj === "object" && "services" in obj;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ /** Discover Docker artifacts: Dockerfiles (by name) and Compose files (by `services:`). */
193
+ export function discoverDocker(root: string): AuditInput[] {
194
+ const files: string[] = [];
195
+ walkFiles(root, files);
196
+ const inputs: AuditInput[] = [];
197
+ for (const full of files) {
198
+ const name = basename(full);
199
+ const content = readSafe(full);
200
+ if (content === undefined) continue;
201
+ if (isDockerfileName(name) || (isYaml(name) && looksLikeCompose(content))) {
202
+ inputs.push({ path: relative(root, full), content, lexicon: "docker" });
203
+ }
204
+ }
205
+ return inputs;
206
+ }
207
+
118
208
  function isMergeWorthy(f: AuditFinding): boolean {
119
209
  return RULE_CATALOG[f.checkId]?.tier === "merge-worthy";
120
210
  }
@@ -247,7 +337,7 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
247
337
  if (!existsSync(options.path)) {
248
338
  return { success: false, output: "", findings: [], scanned: [], exitCode: 1, error: `Path not found: ${options.path}` };
249
339
  }
250
- inputs = discoverCiFiles(options.path);
340
+ inputs = [...discoverCiFiles(options.path), ...discoverManifests(options.path), ...discoverDocker(options.path)];
251
341
  }
252
342
 
253
343
  const scanned = inputs.map((i) => i.path);