@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 +1 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +51 -0
- package/src/audit/core.test.ts +47 -1
- package/src/audit/core.ts +0 -0
- package/src/audit/rules-doc.ts +8 -6
- 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-k8s/manifests/deploy.yaml +18 -0
- package/src/cli/commands/audit.test.ts +33 -2
- package/src/cli/commands/audit.ts +92 -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"]);
|
|
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,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. */
|
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,12 @@
|
|
|
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." },
|
|
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,
|
|
30
|
-
const blocks = ids.filter((id) => id.startsWith(
|
|
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,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 =
|
|
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);
|