@intentius/chant-lexicon-gitlab 0.0.16 → 0.0.22

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.
Files changed (65) hide show
  1. package/dist/integrity.json +17 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/wgl016.ts +82 -0
  4. package/dist/rules/wgl017.ts +54 -0
  5. package/dist/rules/wgl018.ts +39 -0
  6. package/dist/rules/wgl019.ts +44 -0
  7. package/dist/rules/wgl020.ts +56 -0
  8. package/dist/rules/wgl021.ts +62 -0
  9. package/dist/rules/wgl022.ts +44 -0
  10. package/dist/rules/wgl023.ts +51 -0
  11. package/dist/rules/wgl024.ts +46 -0
  12. package/dist/rules/wgl025.ts +49 -0
  13. package/dist/rules/wgl026.ts +67 -0
  14. package/dist/rules/wgl027.ts +54 -0
  15. package/dist/rules/wgl028.ts +67 -0
  16. package/dist/rules/yaml-helpers.ts +82 -0
  17. package/dist/skills/chant-gitlab.md +2 -2
  18. package/package.json +20 -2
  19. package/src/codegen/fetch.test.ts +30 -0
  20. package/src/codegen/generate.test.ts +65 -0
  21. package/src/codegen/idempotency.test.ts +28 -0
  22. package/src/codegen/naming.test.ts +93 -0
  23. package/src/codegen/snapshot.test.ts +28 -19
  24. package/src/composites/composites.test.ts +160 -0
  25. package/src/coverage.test.ts +15 -7
  26. package/src/import/roundtrip.test.ts +132 -0
  27. package/src/lint/post-synth/wgl016.test.ts +72 -0
  28. package/src/lint/post-synth/wgl016.ts +82 -0
  29. package/src/lint/post-synth/wgl017.test.ts +53 -0
  30. package/src/lint/post-synth/wgl017.ts +54 -0
  31. package/src/lint/post-synth/wgl018.test.ts +69 -0
  32. package/src/lint/post-synth/wgl018.ts +39 -0
  33. package/src/lint/post-synth/wgl019.test.ts +76 -0
  34. package/src/lint/post-synth/wgl019.ts +44 -0
  35. package/src/lint/post-synth/wgl020.test.ts +54 -0
  36. package/src/lint/post-synth/wgl020.ts +56 -0
  37. package/src/lint/post-synth/wgl021.test.ts +62 -0
  38. package/src/lint/post-synth/wgl021.ts +62 -0
  39. package/src/lint/post-synth/wgl022.test.ts +86 -0
  40. package/src/lint/post-synth/wgl022.ts +44 -0
  41. package/src/lint/post-synth/wgl023.test.ts +88 -0
  42. package/src/lint/post-synth/wgl023.ts +51 -0
  43. package/src/lint/post-synth/wgl024.test.ts +77 -0
  44. package/src/lint/post-synth/wgl024.ts +46 -0
  45. package/src/lint/post-synth/wgl025.test.ts +85 -0
  46. package/src/lint/post-synth/wgl025.ts +49 -0
  47. package/src/lint/post-synth/wgl026.test.ts +87 -0
  48. package/src/lint/post-synth/wgl026.ts +67 -0
  49. package/src/lint/post-synth/wgl027.test.ts +84 -0
  50. package/src/lint/post-synth/wgl027.ts +54 -0
  51. package/src/lint/post-synth/wgl028.test.ts +95 -0
  52. package/src/lint/post-synth/wgl028.ts +67 -0
  53. package/src/lint/post-synth/yaml-helpers.ts +82 -0
  54. package/src/lsp/completions.test.ts +16 -6
  55. package/src/lsp/hover.test.ts +18 -7
  56. package/src/plugin.test.ts +14 -1
  57. package/src/plugin.ts +20 -3
  58. package/src/testdata/pipelines/deploy-envs.gitlab-ci.yml +60 -0
  59. package/src/testdata/pipelines/docker-build.gitlab-ci.yml +41 -0
  60. package/src/testdata/pipelines/includes-templates.gitlab-ci.yml +52 -0
  61. package/src/testdata/pipelines/monorepo.gitlab-ci.yml +51 -0
  62. package/src/testdata/pipelines/multi-stage.gitlab-ci.yml +56 -0
  63. package/src/testdata/pipelines/simple.gitlab-ci.yml +9 -0
  64. package/src/validate.test.ts +12 -6
  65. package/src/variables.test.ts +58 -0
@@ -98,6 +98,46 @@ describe("DockerBuild", () => {
98
98
  expect(expanded.has("dockerBuild")).toBe(true);
99
99
  expect(expanded.size).toBe(1);
100
100
  });
101
+
102
+ test("default dockerfile and context", () => {
103
+ const instance = DockerBuild({});
104
+ const props = (instance.build as any).props;
105
+ expect(props.script[0]).toContain("-f Dockerfile");
106
+ expect(props.script[0]).toMatch(/\.\s*$/);
107
+ });
108
+
109
+ test("custom dockerfile path", () => {
110
+ const instance = DockerBuild({ dockerfile: "docker/app.Dockerfile" });
111
+ const props = (instance.build as any).props;
112
+ expect(props.script[0]).toContain("-f docker/app.Dockerfile");
113
+ });
114
+
115
+ test("custom context directory", () => {
116
+ const instance = DockerBuild({ context: "./app" });
117
+ const props = (instance.build as any).props;
118
+ expect(props.script[0]).toContain("./app");
119
+ });
120
+
121
+ test("empty buildArgs produces no --build-arg flags", () => {
122
+ const instance = DockerBuild({ buildArgs: {} });
123
+ const props = (instance.build as any).props;
124
+ expect(props.script[0]).not.toContain("--build-arg");
125
+ });
126
+
127
+ test("multiple buildArgs are all present", () => {
128
+ const instance = DockerBuild({
129
+ buildArgs: { NODE_ENV: "production", VERSION: "1.0" },
130
+ });
131
+ const props = (instance.build as any).props;
132
+ expect(props.script[0]).toContain("--build-arg NODE_ENV=production");
133
+ expect(props.script[0]).toContain("--build-arg VERSION=1.0");
134
+ });
135
+
136
+ test("no rules by default", () => {
137
+ const instance = DockerBuild({});
138
+ const props = (instance.build as any).props;
139
+ expect(props.rules).toBeUndefined();
140
+ });
101
141
  });
102
142
 
103
143
  // ---------------------------------------------------------------------------
@@ -201,6 +241,52 @@ describe("NodePipeline", () => {
201
241
  expect(expanded.has("appBuild")).toBe(true);
202
242
  expect(expanded.has("appTest")).toBe(true);
203
243
  });
244
+
245
+ test("custom artifact paths", () => {
246
+ const instance = NodePipeline({ buildArtifactPaths: ["build/", "out/"] });
247
+ const props = (instance.build as any).props;
248
+ const artProps = (props.artifacts as any).props;
249
+ expect(artProps.paths).toEqual(["build/", "out/"]);
250
+ });
251
+
252
+ test("custom artifact expiry", () => {
253
+ const instance = NodePipeline({ artifactExpiry: "30 minutes" });
254
+ const props = (instance.build as any).props;
255
+ const artProps = (props.artifacts as any).props;
256
+ expect(artProps.expire_in).toBe("30 minutes");
257
+ });
258
+
259
+ test("cache has pull-push policy", () => {
260
+ const instance = NodePipeline({});
261
+ const defaultProps = (instance.defaults as any).props;
262
+ const cacheProps = (defaultProps.cache[0] as any).props;
263
+ expect(cacheProps.policy).toBe("pull-push");
264
+ });
265
+
266
+ test("pnpm install command", () => {
267
+ const instance = NodePipeline({ packageManager: "pnpm" });
268
+ const buildProps = (instance.build as any).props;
269
+ expect(buildProps.script[0]).toBe("pnpm install --frozen-lockfile");
270
+ });
271
+
272
+ test("bun install command", () => {
273
+ const instance = NodePipeline({ packageManager: "bun" });
274
+ const buildProps = (instance.build as any).props;
275
+ expect(buildProps.script[0]).toBe("bun install --frozen-lockfile");
276
+ });
277
+
278
+ test("test job stage is test", () => {
279
+ const instance = NodePipeline({});
280
+ const props = (instance.test as any).props;
281
+ expect(props.stage).toBe("test");
282
+ });
283
+
284
+ test("test artifacts always reported", () => {
285
+ const instance = NodePipeline({});
286
+ const props = (instance.test as any).props;
287
+ const artProps = (props.artifacts as any).props;
288
+ expect(artProps.when).toBe("always");
289
+ });
204
290
  });
205
291
 
206
292
  // ---------------------------------------------------------------------------
@@ -329,6 +415,42 @@ describe("PythonPipeline", () => {
329
415
  expect(expanded.has("pyTest")).toBe(true);
330
416
  expect(expanded.has("pyLint")).toBe(false);
331
417
  });
418
+
419
+ test("custom requirements file", () => {
420
+ const instance = PythonPipeline({ requirementsFile: "requirements-dev.txt" });
421
+ const defaultProps = (instance.defaults as any).props;
422
+ const cacheProps = (defaultProps.cache[0] as any).props;
423
+ expect(cacheProps.key).toEqual({ files: ["requirements-dev.txt"] });
424
+ expect(defaultProps.before_script).toContain("pip install -r requirements-dev.txt");
425
+ });
426
+
427
+ test("poetry mode sets virtualenvs.in-project config", () => {
428
+ const instance = PythonPipeline({ usePoetry: true });
429
+ const defaultProps = (instance.defaults as any).props;
430
+ expect(defaultProps.before_script).toContain("poetry config virtualenvs.in-project true");
431
+ });
432
+
433
+ test("custom lint command", () => {
434
+ const instance = PythonPipeline({ lintCommand: "flake8 src/" });
435
+ const members = instance.members as any;
436
+ const props = (members.lint as any).props;
437
+ expect(props.script).toContain("flake8 src/");
438
+ });
439
+
440
+ test("test and lint jobs share the test stage", () => {
441
+ const instance = PythonPipeline({});
442
+ const testProps = (instance.test as any).props;
443
+ const lintProps = ((instance.members as any).lint as any).props;
444
+ expect(testProps.stage).toBe("test");
445
+ expect(lintProps.stage).toBe("test");
446
+ });
447
+
448
+ test("cache has pull-push policy", () => {
449
+ const instance = PythonPipeline({});
450
+ const defaultProps = (instance.defaults as any).props;
451
+ const cacheProps = (defaultProps.cache[0] as any).props;
452
+ expect(cacheProps.policy).toBe("pull-push");
453
+ });
332
454
  });
333
455
 
334
456
  // ---------------------------------------------------------------------------
@@ -449,4 +571,42 @@ describe("ReviewApp", () => {
449
571
  // which the serializer converts to "staging-stop" in YAML
450
572
  expect(envProps.on_stop).toBe("staging-stop");
451
573
  });
574
+
575
+ test("default auto_stop_in is 1 week", () => {
576
+ const instance = ReviewApp(baseProps);
577
+ const props = (instance.deploy as any).props;
578
+ const envProps = (props.environment as any).props;
579
+ expect(envProps.auto_stop_in).toBe("1 week");
580
+ });
581
+
582
+ test("custom auto_stop_in", () => {
583
+ const instance = ReviewApp({ ...baseProps, autoStopIn: "2 days" });
584
+ const props = (instance.deploy as any).props;
585
+ const envProps = (props.environment as any).props;
586
+ expect(envProps.auto_stop_in).toBe("2 days");
587
+ });
588
+
589
+ test("default stage is deploy for both jobs", () => {
590
+ const instance = ReviewApp(baseProps);
591
+ const deployProps = (instance.deploy as any).props;
592
+ const stopProps = (instance.stop as any).props;
593
+ expect(deployProps.stage).toBe("deploy");
594
+ expect(stopProps.stage).toBe("deploy");
595
+ });
596
+
597
+ test("stop script as array", () => {
598
+ const instance = ReviewApp({
599
+ ...baseProps,
600
+ stopScript: ["helm uninstall app", "kubectl delete ns review"],
601
+ });
602
+ const props = (instance.stop as any).props;
603
+ expect(props.script).toEqual(["helm uninstall app", "kubectl delete ns review"]);
604
+ });
605
+
606
+ test("deploy environment url defaults to CI_ENVIRONMENT_SLUG pattern", () => {
607
+ const instance = ReviewApp(baseProps);
608
+ const props = (instance.deploy as any).props;
609
+ const envProps = (props.environment as any).props;
610
+ expect(envProps.url).toContain("$CI_ENVIRONMENT_SLUG");
611
+ });
452
612
  });
@@ -1,20 +1,24 @@
1
1
  import { describe, test, expect } from "bun:test";
2
- import { computeCoverage, overallPct, formatSummary } from "./coverage";
3
- import { readFileSync } from "fs";
2
+ import { existsSync, readFileSync } from "fs";
4
3
  import { join, dirname } from "path";
5
4
  import { fileURLToPath } from "url";
6
5
 
7
6
  const basePath = dirname(dirname(fileURLToPath(import.meta.url)));
8
- const lexiconJSON = readFileSync(join(basePath, "src", "generated", "lexicon-gitlab.json"), "utf-8");
7
+ const lexiconPath = join(basePath, "src", "generated", "lexicon-gitlab.json");
8
+ const hasGenerated = existsSync(lexiconPath);
9
9
 
10
10
  describe("coverage analysis", () => {
11
- test("computes coverage for GitLab lexicon", () => {
11
+ test.skipIf(!hasGenerated)("computes coverage for GitLab lexicon", async () => {
12
+ const { computeCoverage } = await import("./coverage");
13
+ const lexiconJSON = readFileSync(lexiconPath, "utf-8");
12
14
  const report = computeCoverage(lexiconJSON);
13
15
  expect(report.resourceCount).toBe(3); // Job, Default, Workflow
14
16
  expect(report.resources).toHaveLength(3);
15
17
  });
16
18
 
17
- test("reports resource names correctly", () => {
19
+ test.skipIf(!hasGenerated)("reports resource names correctly", async () => {
20
+ const { computeCoverage } = await import("./coverage");
21
+ const lexiconJSON = readFileSync(lexiconPath, "utf-8");
18
22
  const report = computeCoverage(lexiconJSON);
19
23
  const names = report.resources.map((r) => r.name);
20
24
  expect(names).toContain("Job");
@@ -22,7 +26,9 @@ describe("coverage analysis", () => {
22
26
  expect(names).toContain("Workflow");
23
27
  });
24
28
 
25
- test("overallPct returns a number", () => {
29
+ test.skipIf(!hasGenerated)("overallPct returns a number", async () => {
30
+ const { computeCoverage, overallPct } = await import("./coverage");
31
+ const lexiconJSON = readFileSync(lexiconPath, "utf-8");
26
32
  const report = computeCoverage(lexiconJSON);
27
33
  const pct = overallPct(report);
28
34
  expect(typeof pct).toBe("number");
@@ -30,7 +36,9 @@ describe("coverage analysis", () => {
30
36
  expect(pct).toBeLessThanOrEqual(100);
31
37
  });
32
38
 
33
- test("formatSummary returns readable string", () => {
39
+ test.skipIf(!hasGenerated)("formatSummary returns readable string", async () => {
40
+ const { computeCoverage, formatSummary } = await import("./coverage");
41
+ const lexiconJSON = readFileSync(lexiconPath, "utf-8");
34
42
  const report = computeCoverage(lexiconJSON);
35
43
  const summary = formatSummary(report);
36
44
  expect(summary).toContain("Coverage Report");
@@ -1,11 +1,22 @@
1
1
  import { describe, test, expect } from "bun:test";
2
+ import { readFileSync, readdirSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
2
5
  import { GitLabParser } from "./parser";
3
6
  import { GitLabGenerator } from "./generator";
4
7
 
5
8
  const parser = new GitLabParser();
6
9
  const generator = new GitLabGenerator();
7
10
 
11
+ const pipelinesDir = join(
12
+ dirname(dirname(fileURLToPath(import.meta.url))),
13
+ "testdata",
14
+ "pipelines",
15
+ );
16
+
8
17
  describe("roundtrip: parse → generate", () => {
18
+ // ---- inline tests (existing smoke tests) ----
19
+
9
20
  test("simple pipeline roundtrip", () => {
10
21
  const yaml = `
11
22
  stages:
@@ -86,4 +97,125 @@ test-job:
86
97
  expect(content).toContain("new Workflow(");
87
98
  expect(content).toContain("new Job(");
88
99
  });
100
+
101
+ // ---- fixture-based tests ----
102
+
103
+ test("simple.gitlab-ci.yml fixture roundtrip", () => {
104
+ const yaml = readFileSync(join(pipelinesDir, "simple.gitlab-ci.yml"), "utf-8");
105
+ const ir = parser.parse(yaml);
106
+ const files = generator.generate(ir);
107
+
108
+ expect(files).toHaveLength(1);
109
+ const content = files[0].content;
110
+
111
+ expect(ir.resources).toHaveLength(1);
112
+ expect(content).toContain("new Job(");
113
+ expect(content).toContain("npm ci");
114
+ expect(content).toContain("npm test");
115
+ expect(content).toContain("node:22-alpine");
116
+ });
117
+
118
+ test("multi-stage.gitlab-ci.yml fixture roundtrip", () => {
119
+ const yaml = readFileSync(join(pipelinesDir, "multi-stage.gitlab-ci.yml"), "utf-8");
120
+ const ir = parser.parse(yaml);
121
+ const files = generator.generate(ir);
122
+
123
+ const content = files[0].content;
124
+
125
+ // 4 jobs: build-app, lint, unit-tests, deploy-staging
126
+ expect(ir.resources).toHaveLength(4);
127
+ expect(content).toContain("buildApp");
128
+ expect(content).toContain("lint");
129
+ expect(content).toContain("unitTests");
130
+ expect(content).toContain("deployStaging");
131
+ expect(content).toContain("Pipeline stages: build, test, deploy");
132
+ // Artifacts and cache references
133
+ expect(content).toContain("dist/");
134
+ expect(content).toContain("package-lock.json");
135
+ });
136
+
137
+ test("docker-build.gitlab-ci.yml fixture roundtrip", () => {
138
+ const yaml = readFileSync(join(pipelinesDir, "docker-build.gitlab-ci.yml"), "utf-8");
139
+ const ir = parser.parse(yaml);
140
+ const files = generator.generate(ir);
141
+
142
+ const content = files[0].content;
143
+
144
+ expect(ir.resources).toHaveLength(2);
145
+ expect(content).toContain("buildImage");
146
+ expect(content).toContain("pushLatest");
147
+ expect(content).toContain("docker:27-cli");
148
+ expect(content).toContain("docker:27-dind");
149
+ expect(content).toContain("docker build");
150
+ expect(content).toContain("docker push");
151
+ });
152
+
153
+ test("deploy-envs.gitlab-ci.yml fixture roundtrip", () => {
154
+ const yaml = readFileSync(join(pipelinesDir, "deploy-envs.gitlab-ci.yml"), "utf-8");
155
+ const ir = parser.parse(yaml);
156
+ const files = generator.generate(ir);
157
+
158
+ const content = files[0].content;
159
+
160
+ // 5 jobs: build, deploy-review, stop-review, deploy-staging, deploy-production
161
+ expect(ir.resources).toHaveLength(5);
162
+ expect(content).toContain("deployReview");
163
+ expect(content).toContain("stopReview");
164
+ expect(content).toContain("deployStaging");
165
+ expect(content).toContain("deployProduction");
166
+ // Environment constructs
167
+ expect(content).toContain("review/$CI_COMMIT_REF_SLUG");
168
+ expect(content).toContain("staging");
169
+ expect(content).toContain("production");
170
+ });
171
+
172
+ test("includes-templates.gitlab-ci.yml fixture roundtrip", () => {
173
+ const yaml = readFileSync(join(pipelinesDir, "includes-templates.gitlab-ci.yml"), "utf-8");
174
+ const ir = parser.parse(yaml);
175
+ const files = generator.generate(ir);
176
+
177
+ const content = files[0].content;
178
+
179
+ // default + build + unit-test + integration-test + deploy
180
+ expect(ir.resources.length).toBeGreaterThanOrEqual(3);
181
+ expect(content).toContain("new Default(");
182
+ // Include references should be captured as comments
183
+ expect(content).toContain("Auto-DevOps.gitlab-ci.yml");
184
+ expect(content).toContain("interruptible");
185
+ });
186
+
187
+ test("monorepo.gitlab-ci.yml fixture roundtrip", () => {
188
+ const yaml = readFileSync(join(pipelinesDir, "monorepo.gitlab-ci.yml"), "utf-8");
189
+ const ir = parser.parse(yaml);
190
+ const files = generator.generate(ir);
191
+
192
+ const content = files[0].content;
193
+
194
+ // frontend, backend, e2e-matrix, deploy-all
195
+ expect(ir.resources).toHaveLength(4);
196
+ expect(content).toContain("frontend");
197
+ expect(content).toContain("backend");
198
+ expect(content).toContain("e2eMatrix");
199
+ expect(content).toContain("deployAll");
200
+ // Monorepo-specific constructs
201
+ expect(content).toContain("cypress");
202
+ });
203
+
204
+ // ---- fixture directory sweep ----
205
+
206
+ test("all pipeline fixtures parse and generate without error", () => {
207
+ const fixtures = readdirSync(pipelinesDir).filter((f) => f.endsWith(".yml"));
208
+ expect(fixtures.length).toBeGreaterThanOrEqual(6);
209
+
210
+ for (const fixture of fixtures) {
211
+ const yaml = readFileSync(join(pipelinesDir, fixture), "utf-8");
212
+ const ir = parser.parse(yaml);
213
+ expect(ir.resources.length).toBeGreaterThan(0);
214
+
215
+ const files = generator.generate(ir);
216
+ expect(files.length).toBeGreaterThan(0);
217
+ expect(files[0].content).toContain("import");
218
+ expect(files[0].content).toContain("export const");
219
+ }
220
+ });
89
221
  });
@@ -0,0 +1,72 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { wgl016, checkSecretsInVariables } from "./wgl016";
3
+
4
+ describe("WGL016: Secrets in Variables", () => {
5
+ test("check metadata", () => {
6
+ expect(wgl016.id).toBe("WGL016");
7
+ expect(wgl016.description).toContain("Secrets");
8
+ });
9
+
10
+ test("flags hardcoded password variable", () => {
11
+ const yaml = `variables:
12
+ DB_PASSWORD: my-secret-pass123
13
+ DB_HOST: postgres
14
+ `;
15
+ const diags = checkSecretsInVariables(yaml);
16
+ expect(diags).toHaveLength(1);
17
+ expect(diags[0].checkId).toBe("WGL016");
18
+ expect(diags[0].severity).toBe("error");
19
+ expect(diags[0].message).toContain("DB_PASSWORD");
20
+ });
21
+
22
+ test("flags hardcoded token variable", () => {
23
+ const yaml = `variables:
24
+ API_TOKEN: abc123xyz
25
+ `;
26
+ const diags = checkSecretsInVariables(yaml);
27
+ expect(diags).toHaveLength(1);
28
+ expect(diags[0].message).toContain("API_TOKEN");
29
+ });
30
+
31
+ test("flags hardcoded secret variable", () => {
32
+ const yaml = `variables:
33
+ APP_SECRET: supersecretvalue
34
+ `;
35
+ const diags = checkSecretsInVariables(yaml);
36
+ expect(diags).toHaveLength(1);
37
+ expect(diags[0].message).toContain("APP_SECRET");
38
+ });
39
+
40
+ test("does not flag variable references", () => {
41
+ const yaml = `variables:
42
+ DB_PASSWORD: $CI_DB_PASSWORD
43
+ API_TOKEN: \${SECRET_TOKEN}
44
+ `;
45
+ const diags = checkSecretsInVariables(yaml);
46
+ expect(diags).toHaveLength(0);
47
+ });
48
+
49
+ test("does not flag non-secret variables", () => {
50
+ const yaml = `variables:
51
+ NODE_ENV: production
52
+ DB_HOST: postgres
53
+ STAGE: deploy
54
+ `;
55
+ const diags = checkSecretsInVariables(yaml);
56
+ expect(diags).toHaveLength(0);
57
+ });
58
+
59
+ test("flags multiple secret variables", () => {
60
+ const yaml = `variables:
61
+ DB_PASSWORD: pass123
62
+ API_SECRET: secret456
63
+ `;
64
+ const diags = checkSecretsInVariables(yaml);
65
+ expect(diags).toHaveLength(2);
66
+ });
67
+
68
+ test("no diagnostics on empty yaml", () => {
69
+ const diags = checkSecretsInVariables("");
70
+ expect(diags).toHaveLength(0);
71
+ });
72
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * WGL016: Secrets in Variables
3
+ *
4
+ * Detects hardcoded passwords, tokens, or secrets in `variables:` blocks.
5
+ * These should use CI/CD masked variables instead.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { getPrimaryOutput } from "./yaml-helpers";
10
+
11
+ const SECRET_PATTERNS = [
12
+ /password\s*[:=]\s*['"]?[^\s'"]+/i,
13
+ /secret\s*[:=]\s*['"]?[^\s'"]+/i,
14
+ /token\s*[:=]\s*['"]?[^\s'"]+/i,
15
+ /api[_-]?key\s*[:=]\s*['"]?[^\s'"]+/i,
16
+ /private[_-]?key\s*[:=]\s*['"]?[^\s'"]+/i,
17
+ ];
18
+
19
+ /** Variable name patterns that indicate credentials. */
20
+ const SECRET_VAR_NAMES = [
21
+ /password/i,
22
+ /secret/i,
23
+ /token/i,
24
+ /api[_-]?key/i,
25
+ /private[_-]?key/i,
26
+ /credentials?/i,
27
+ ];
28
+
29
+ /** Values that are clearly references (not hardcoded secrets). */
30
+ function isReference(value: string): boolean {
31
+ return value.startsWith("$") || value.startsWith("${");
32
+ }
33
+
34
+ export function checkSecretsInVariables(yaml: string): PostSynthDiagnostic[] {
35
+ const diagnostics: PostSynthDiagnostic[] = [];
36
+
37
+ // Extract variables blocks (global and per-job)
38
+ const varBlocks = yaml.matchAll(/^(\s*)variables:\n((?:\1\s+.+\n?)+)/gm);
39
+
40
+ for (const block of varBlocks) {
41
+ const lines = block[2].split("\n");
42
+ for (const line of lines) {
43
+ const kv = line.match(/^\s+(\w+):\s+(.+)$/);
44
+ if (!kv) continue;
45
+
46
+ const [, varName, rawValue] = kv;
47
+ const value = rawValue.trim().replace(/^['"]|['"]$/g, "");
48
+
49
+ if (isReference(value)) continue;
50
+
51
+ // Check if variable name suggests a secret
52
+ for (const pattern of SECRET_VAR_NAMES) {
53
+ if (pattern.test(varName)) {
54
+ diagnostics.push({
55
+ checkId: "WGL016",
56
+ severity: "error",
57
+ message: `Variable "${varName}" appears to contain a hardcoded secret — use a CI/CD masked variable instead`,
58
+ entity: varName,
59
+ lexicon: "gitlab",
60
+ });
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ return diagnostics;
68
+ }
69
+
70
+ export const wgl016: PostSynthCheck = {
71
+ id: "WGL016",
72
+ description: "Secrets in variables — hardcoded passwords or tokens in variables blocks",
73
+
74
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
75
+ const diagnostics: PostSynthDiagnostic[] = [];
76
+ for (const [, output] of ctx.outputs) {
77
+ const yaml = getPrimaryOutput(output);
78
+ diagnostics.push(...checkSecretsInVariables(yaml));
79
+ }
80
+ return diagnostics;
81
+ },
82
+ };
@@ -0,0 +1,53 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { wgl017, checkInsecureRegistry } from "./wgl017";
3
+
4
+ describe("WGL017: Insecure Registry", () => {
5
+ test("check metadata", () => {
6
+ expect(wgl017.id).toBe("WGL017");
7
+ expect(wgl017.description).toContain("Insecure");
8
+ });
9
+
10
+ test("flags docker push to HTTP registry", () => {
11
+ const yaml = `build-image:
12
+ script:
13
+ - docker push http://registry.local/myimage:latest
14
+ `;
15
+ const diags = checkInsecureRegistry(yaml);
16
+ expect(diags).toHaveLength(1);
17
+ expect(diags[0].severity).toBe("warning");
18
+ expect(diags[0].message).toContain("build-image");
19
+ expect(diags[0].message).toContain("insecure");
20
+ });
21
+
22
+ test("flags docker pull from HTTP registry", () => {
23
+ const yaml = `test-job:
24
+ script:
25
+ - docker pull http://insecure-registry.com/myimage
26
+ `;
27
+ const diags = checkInsecureRegistry(yaml);
28
+ expect(diags).toHaveLength(1);
29
+ });
30
+
31
+ test("does not flag HTTPS registry", () => {
32
+ const yaml = `build-image:
33
+ script:
34
+ - docker push https://registry.gitlab.com/myimage:latest
35
+ `;
36
+ const diags = checkInsecureRegistry(yaml);
37
+ expect(diags).toHaveLength(0);
38
+ });
39
+
40
+ test("does not flag registry without protocol", () => {
41
+ const yaml = `build-image:
42
+ script:
43
+ - docker push $CI_REGISTRY_IMAGE:latest
44
+ `;
45
+ const diags = checkInsecureRegistry(yaml);
46
+ expect(diags).toHaveLength(0);
47
+ });
48
+
49
+ test("no diagnostics on empty yaml", () => {
50
+ const diags = checkInsecureRegistry("");
51
+ expect(diags).toHaveLength(0);
52
+ });
53
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * WGL017: Insecure Registry
3
+ *
4
+ * Detects Docker push/pull to non-HTTPS registries in job scripts.
5
+ * Using HTTP for container registries is a security risk.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { getPrimaryOutput, extractJobs } from "./yaml-helpers";
10
+
11
+ const INSECURE_REGISTRY_PATTERN = /docker\s+(push|pull|tag|login)\s+.*http:\/\/[^\s]+/;
12
+
13
+ export function checkInsecureRegistry(yaml: string): PostSynthDiagnostic[] {
14
+ const diagnostics: PostSynthDiagnostic[] = [];
15
+
16
+ const sections = yaml.split("\n\n");
17
+ for (const section of sections) {
18
+ const lines = section.split("\n");
19
+ if (lines.length === 0) continue;
20
+
21
+ const topMatch = lines[0].match(/^(\.?[a-z][a-z0-9_.-]*):/);
22
+ if (!topMatch) continue;
23
+ const jobName = topMatch[1];
24
+
25
+ for (const line of lines) {
26
+ if (INSECURE_REGISTRY_PATTERN.test(line)) {
27
+ diagnostics.push({
28
+ checkId: "WGL017",
29
+ severity: "warning",
30
+ message: `Job "${jobName}" uses an insecure (HTTP) container registry — use HTTPS instead`,
31
+ entity: jobName,
32
+ lexicon: "gitlab",
33
+ });
34
+ break;
35
+ }
36
+ }
37
+ }
38
+
39
+ return diagnostics;
40
+ }
41
+
42
+ export const wgl017: PostSynthCheck = {
43
+ id: "WGL017",
44
+ description: "Insecure registry — Docker push/pull to non-HTTPS registry",
45
+
46
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
47
+ const diagnostics: PostSynthDiagnostic[] = [];
48
+ for (const [, output] of ctx.outputs) {
49
+ const yaml = getPrimaryOutput(output);
50
+ diagnostics.push(...checkInsecureRegistry(yaml));
51
+ }
52
+ return diagnostics;
53
+ },
54
+ };