@intentius/chant-lexicon-gitlab 0.1.22 → 0.2.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.
Files changed (77) hide show
  1. package/dist/integrity.json +24 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/wgl029.ts +59 -0
  4. package/dist/rules/wgl030.ts +48 -0
  5. package/dist/rules/wgl031.ts +37 -0
  6. package/dist/rules/wgl032.ts +68 -0
  7. package/dist/rules/wgl033.ts +38 -0
  8. package/dist/rules/wgl034.ts +42 -0
  9. package/dist/rules/wgl035.ts +44 -0
  10. package/dist/rules/wgl036.ts +44 -0
  11. package/dist/rules/wgl037.ts +47 -0
  12. package/dist/rules/wgl038.ts +48 -0
  13. package/dist/rules/wgl039.ts +41 -0
  14. package/dist/rules/wgl040.ts +42 -0
  15. package/dist/rules/wgl041.ts +57 -0
  16. package/dist/rules/wgl042.ts +46 -0
  17. package/dist/rules/wgl043.ts +45 -0
  18. package/dist/rules/wgl044.ts +41 -0
  19. package/dist/rules/wgl045.ts +57 -0
  20. package/dist/rules/wgl046.ts +43 -0
  21. package/dist/rules/wgl047.ts +41 -0
  22. package/dist/rules/wgl048.ts +37 -0
  23. package/dist/rules/yaml-helpers.ts +214 -0
  24. package/dist/skills/chant-gitlab-migrate.md +10 -0
  25. package/package.json +1 -1
  26. package/src/codegen/docs.ts +130 -0
  27. package/src/lint/post-synth/wgl029.test.ts +77 -0
  28. package/src/lint/post-synth/wgl029.ts +59 -0
  29. package/src/lint/post-synth/wgl030.test.ts +57 -0
  30. package/src/lint/post-synth/wgl030.ts +48 -0
  31. package/src/lint/post-synth/wgl031.test.ts +66 -0
  32. package/src/lint/post-synth/wgl031.ts +37 -0
  33. package/src/lint/post-synth/wgl032.test.ts +53 -0
  34. package/src/lint/post-synth/wgl032.ts +68 -0
  35. package/src/lint/post-synth/wgl033.test.ts +58 -0
  36. package/src/lint/post-synth/wgl033.ts +38 -0
  37. package/src/lint/post-synth/wgl034.test.ts +60 -0
  38. package/src/lint/post-synth/wgl034.ts +42 -0
  39. package/src/lint/post-synth/wgl035.test.ts +42 -0
  40. package/src/lint/post-synth/wgl035.ts +44 -0
  41. package/src/lint/post-synth/wgl036.test.ts +49 -0
  42. package/src/lint/post-synth/wgl036.ts +44 -0
  43. package/src/lint/post-synth/wgl037.test.ts +45 -0
  44. package/src/lint/post-synth/wgl037.ts +47 -0
  45. package/src/lint/post-synth/wgl038.test.ts +54 -0
  46. package/src/lint/post-synth/wgl038.ts +48 -0
  47. package/src/lint/post-synth/wgl039.test.ts +39 -0
  48. package/src/lint/post-synth/wgl039.ts +41 -0
  49. package/src/lint/post-synth/wgl040.test.ts +48 -0
  50. package/src/lint/post-synth/wgl040.ts +42 -0
  51. package/src/lint/post-synth/wgl041.test.ts +49 -0
  52. package/src/lint/post-synth/wgl041.ts +57 -0
  53. package/src/lint/post-synth/wgl042.test.ts +45 -0
  54. package/src/lint/post-synth/wgl042.ts +46 -0
  55. package/src/lint/post-synth/wgl043.test.ts +43 -0
  56. package/src/lint/post-synth/wgl043.ts +45 -0
  57. package/src/lint/post-synth/wgl044.test.ts +46 -0
  58. package/src/lint/post-synth/wgl044.ts +41 -0
  59. package/src/lint/post-synth/wgl045.test.ts +48 -0
  60. package/src/lint/post-synth/wgl045.ts +57 -0
  61. package/src/lint/post-synth/wgl046.test.ts +67 -0
  62. package/src/lint/post-synth/wgl046.ts +43 -0
  63. package/src/lint/post-synth/wgl047.test.ts +40 -0
  64. package/src/lint/post-synth/wgl047.ts +41 -0
  65. package/src/lint/post-synth/wgl048.test.ts +56 -0
  66. package/src/lint/post-synth/wgl048.ts +37 -0
  67. package/src/lint/post-synth/yaml-helpers.ts +214 -0
  68. package/src/lint/rules/data/known-include-sources.ts +41 -0
  69. package/src/lint/rules/data/untrusted-variables.ts +31 -0
  70. package/src/migrate/from-github/diagnostics.ts +18 -0
  71. package/src/migrate/from-github/index.ts +20 -1
  72. package/src/migrate/from-github/provenance.ts +28 -0
  73. package/src/migrate/from-github/rules.ts +7 -0
  74. package/src/migrate/from-github/security.test.ts +98 -0
  75. package/src/migrate/from-github/security.ts +208 -0
  76. package/src/plugin.test.ts +21 -1
  77. package/src/skills/chant-gitlab-migrate.md +10 -0
@@ -435,6 +435,136 @@ Flags jobs whose \`extends:\` references a template or hidden job not defined in
435
435
 
436
436
  Detects cycles in the \`needs:\` dependency graph. If job A needs B and B needs A (directly or transitively), GitLab rejects the pipeline. Reports the full cycle chain in the diagnostic message.
437
437
 
438
+ ### WGL029 — Unpinned include:project / component
439
+
440
+ **Severity:** warning
441
+
442
+ Flags an \`include:project\` or CI/CD \`component:\` resolved by a moving ref — a branch, a missing \`ref:\` (defaults to the default branch), or a floating component version — instead of a pinned tag or commit SHA.
443
+
444
+ ### WGL030 — Mutable or insecure include:remote
445
+
446
+ **Severity:** error (HTTP) / warning (HTTPS)
447
+
448
+ Flags \`include:remote\` URLs fetched over HTTP (no transport integrity) or over HTTPS but inherently mutable. Prefer a pinned \`include:project\` or component. Generalizes WGL017 to includes.
449
+
450
+ ### WGL031 — Container image without a digest
451
+
452
+ **Severity:** warning
453
+
454
+ Flags \`image:\` and \`services:\` references not pinned to an immutable \`@sha256:\` digest. Variable-based references (e.g. \`$CI_REGISTRY_IMAGE:tag\`) are skipped.
455
+
456
+ ### WGL032 — Look-alike include/component source
457
+
458
+ **Severity:** warning
459
+
460
+ Flags an \`include:project\` / \`component:\` source that is a near-miss (edit distance 1–2) of a well-known GitLab CI source but not an exact match — a likely typo or impersonation. Backed by a vendored reference list.
461
+
462
+ ### WGL033 — OIDC id_token without a scoped audience
463
+
464
+ **Severity:** warning
465
+
466
+ Flags an \`id_tokens:\` declaration with no \`aud:\` or a wildcard audience. The audience binds the minted OIDC token to a relying party; without it a leaked token is accepted anywhere.
467
+
468
+ ### WGL034 — OIDC id_token mintable from a merge-request pipeline
469
+
470
+ **Severity:** warning
471
+
472
+ Flags a job that declares \`id_tokens:\` and is reachable from merge-request pipelines, which outside contributors can trigger. Restrict OIDC jobs to protected refs or require approval.
473
+
474
+ > The project-level \`CI_JOB_TOKEN\` allowlist and a variable's protected status are project settings, not emitted pipeline YAML, so they are out of scope for these post-synth checks (see issue #298's caveat).
475
+
476
+ ### WGL035 — Untrusted CI variable in a script
477
+
478
+ **Severity:** warning
479
+
480
+ Flags an attacker-controllable predefined variable (branch/tag name, commit or MR title/description, author) referenced in a \`script:\` command, where a crafted value can inject shell commands. Quote it and avoid using it in sensitive commands.
481
+
482
+ ### WGL036 — Privileged DinD reachable from merge requests
483
+
484
+ **Severity:** warning
485
+
486
+ Flags a Docker-in-Docker (privileged) job reachable from merge-request pipelines, which outside contributors can trigger. Restrict privileged services to protected refs. Complements WGL026.
487
+
488
+ ### WGL037 — Regex gate on an untrusted ref
489
+
490
+ **Severity:** warning
491
+
492
+ Flags a \`rules:if\` that gates on a regex match (\`=~\`) over an attacker-controllable ref variable — a crafted branch/tag name can satisfy the pattern. Match the full ref with \`==\` or gate on a protected condition.
493
+
494
+ ### WGL038 — Secret reachable from a merge-request pipeline
495
+
496
+ **Severity:** warning
497
+
498
+ Flags a user-defined secret-like variable read by a job reachable from merge-request pipelines, which can run untrusted code. Gate the job to protected refs or mark the variable protected. (Built-in \`CI_*\` variables are excluded.)
499
+
500
+ ### WGL039 — Secret echoed to job logs
501
+
502
+ **Severity:** warning
503
+
504
+ Flags a \`script:\` command that prints a secret-like variable (\`echo\`/\`printf\`/\`cat\`). Logs are broadly readable and masking can be defeated by transforms.
505
+
506
+ ### WGL040 — Hardcoded registry credential
507
+
508
+ **Severity:** error
509
+
510
+ Flags a \`docker login\` (or compatible) passing a literal password via \`-p\`/\`--password\` instead of a variable or \`--password-stdin\`. Extends WGL016 to scripts.
511
+
512
+ > Variable *masking* and *protected* status are GitLab project/CI-settings, not emitted YAML — WGL016 already nudges toward masked variables; this group covers the exposure paths visible in the pipeline.
513
+
514
+ ### WGL041 — Unsound rules:if condition
515
+
516
+ **Severity:** warning
517
+
518
+ Flags a tautological \`rules:if\` where both sides of \`==\`/\`!=\` are identical, so the condition is always true or always false. Generalizes WGL011 to conditions presented as gates.
519
+
520
+ ### WGL042 — Unreachable rules after an unconditional match
521
+
522
+ **Severity:** warning
523
+
524
+ Flags rules listed after an unconditional rule (no \`if:\`, not \`when: never\`). GitLab takes the first matching rule, so a catch-all makes everything after it dead. Put specific rules first.
525
+
526
+ ### WGL043 — Match-anything regex gate
527
+
528
+ **Severity:** warning
529
+
530
+ Flags a \`rules:if\` whose \`=~\` regex matches every value (empty, dot-star, anchored dot-star) — a filter that admits everything. Tighten the pattern or remove the gate.
531
+
532
+ > The auto-fix acceptance item is N/A at the post-synth layer (PostSynthDiagnostic has no fix channel); it belongs to the declarative source-lint rules.
533
+
534
+ ### WGL044 — Public artifacts
535
+
536
+ **Severity:** warning
537
+
538
+ Flags \`artifacts:public: true\`, which makes build output downloadable by anyone. Keep artifacts private unless they are meant to be world-readable.
539
+
540
+ ### WGL045 — Credential-bearing artifact path
541
+
542
+ **Severity:** error
543
+
544
+ Flags an \`artifacts:paths:\` entry that looks like a credential or sensitive file (\`.env\`, \`*.pem\`, \`id_rsa\`, \`*.key\`, \`.npmrc\`, \`.netrc\`, \`credentials\`). Artifacts flow downstream and are downloadable — exclude the file.
545
+
546
+ ### WGL046 — Cache poisoning from a merge-request pipeline
547
+
548
+ **Severity:** warning
549
+
550
+ Flags a job that writes a cache (push policy) and is reachable from merge-request pipelines. An MR can poison the cache for a later protected run that restores the same key. Restrict cache writes to protected refs or scope the key.
551
+
552
+ > Artifact / \`dependencies:\` flow across a *protected* boundary depends on a ref's protected status, a project setting not present in emitted YAML — out of scope here.
553
+
554
+ ### WGL047 — Software fetched and piped to a shell
555
+
556
+ **Severity:** warning
557
+
558
+ Flags a \`script:\` command that pipes a network download straight into a shell (\`curl ... | bash\`). The fetched code is unpinned and unverified — download to a file, verify a checksum/signature, then run it.
559
+
560
+ ### WGL048 — Pipeline without a name
561
+
562
+ **Severity:** info
563
+
564
+ Flags a pipeline that defines a \`workflow:\` block but no \`workflow:name\`. A pipeline name aids identification in the GitLab UI and audit output.
565
+
566
+ > Two #304 items stay out of scope: a broad privileged-service / DinD check (already covered by WGL026 for DinD-TLS and WGL036 for MR-reachable DinD; a runner's privileged isolation is not in emitted YAML) and an optional include/component allowlist policy (configuration, overlapping WGL032's vendored source list).
567
+
438
568
  ## Running lint
439
569
 
440
570
  \`\`\`bash
@@ -0,0 +1,77 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl029 } from "./wgl029";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: {
10
+ outputs: new Map([["gitlab", yaml]]),
11
+ entities: new Map(),
12
+ warnings: [],
13
+ errors: [],
14
+ sourceFileCount: 1,
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("WGL029: unpinned include:project / component", () => {
20
+ test("flags include:project on a branch ref", () => {
21
+ const yaml = `include:
22
+ - project: my-group/ci-templates
23
+ ref: main
24
+ file: /templates/build.yml
25
+
26
+ stages:
27
+ - build
28
+ `;
29
+ const diags = wgl029.check(makeCtx(yaml));
30
+ expect(diags).toHaveLength(1);
31
+ expect(diags[0].checkId).toBe("WGL029");
32
+ expect(diags[0].message).toContain("main");
33
+ });
34
+
35
+ test("flags include:project with no ref", () => {
36
+ const yaml = `include:
37
+ - project: my-group/ci-templates
38
+ file: /templates/build.yml
39
+
40
+ stages:
41
+ - build
42
+ `;
43
+ const diags = wgl029.check(makeCtx(yaml));
44
+ expect(diags).toHaveLength(1);
45
+ expect(diags[0].message).toContain("default branch");
46
+ });
47
+
48
+ test("does not flag a pinned tag ref", () => {
49
+ const yaml = `include:
50
+ - project: my-group/ci-templates
51
+ ref: v1.2.3
52
+ file: /templates/build.yml
53
+
54
+ stages:
55
+ - build
56
+ `;
57
+ const diags = wgl029.check(makeCtx(yaml));
58
+ expect(diags).toHaveLength(0);
59
+ });
60
+
61
+ test("flags a floating component version and not a pinned one", () => {
62
+ const floating = `include:
63
+ - component: gitlab.example.com/my-group/my-comp@main
64
+
65
+ stages:
66
+ - build
67
+ `;
68
+ const pinned = `include:
69
+ - component: gitlab.example.com/my-group/my-comp@1.0.0
70
+
71
+ stages:
72
+ - build
73
+ `;
74
+ expect(wgl029.check(makeCtx(floating))).toHaveLength(1);
75
+ expect(wgl029.check(makeCtx(pinned))).toHaveLength(0);
76
+ });
77
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * WGL029: Unpinned include:project / component Reference
3
+ *
4
+ * Flags an `include:project` or CI/CD `component:` resolved by a moving ref — a
5
+ * branch, no `ref:` at all (defaults to the project's default branch), or a
6
+ * floating component version — instead of a pinned tag or commit SHA. A
7
+ * repointed ref turns the included configuration into a supply-chain entry point.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractIncludes, isPinnedRef } from "./yaml-helpers";
12
+
13
+ export const wgl029: PostSynthCheck = {
14
+ id: "WGL029",
15
+ description: "include:project / component resolved by a moving ref instead of a pinned tag or SHA",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [, output] of ctx.outputs) {
21
+ const yaml = getPrimaryOutput(output);
22
+ for (const entry of extractIncludes(yaml)) {
23
+ if (entry.kind === "project") {
24
+ if (!entry.ref) {
25
+ diagnostics.push({
26
+ checkId: "WGL029",
27
+ severity: "warning",
28
+ message: `include:project "${entry.value}" has no ref: and resolves to the project's default branch (a moving target). Pin it to a tag or commit SHA.`,
29
+ entity: entry.value,
30
+ lexicon: "gitlab",
31
+ });
32
+ } else if (!isPinnedRef(entry.ref)) {
33
+ diagnostics.push({
34
+ checkId: "WGL029",
35
+ severity: "warning",
36
+ message: `include:project "${entry.value}" is pinned to the moving ref "${entry.ref}". Pin it to a tag or commit SHA.`,
37
+ entity: entry.value,
38
+ lexicon: "gitlab",
39
+ });
40
+ }
41
+ } else if (entry.kind === "component") {
42
+ const at = entry.value.lastIndexOf("@");
43
+ const version = at === -1 ? "" : entry.value.slice(at + 1);
44
+ if (!version || !isPinnedRef(version)) {
45
+ diagnostics.push({
46
+ checkId: "WGL029",
47
+ severity: "warning",
48
+ message: `component "${entry.value}" uses ${version ? `the moving version "${version}"` : "no pinned version"}. Pin it to a released version tag or commit SHA.`,
49
+ entity: entry.value,
50
+ lexicon: "gitlab",
51
+ });
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ return diagnostics;
58
+ },
59
+ };
@@ -0,0 +1,57 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl030 } from "./wgl030";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: {
10
+ outputs: new Map([["gitlab", yaml]]),
11
+ entities: new Map(),
12
+ warnings: [],
13
+ errors: [],
14
+ sourceFileCount: 1,
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("WGL030: mutable or insecure include:remote", () => {
20
+ test("errors on an HTTP remote include", () => {
21
+ const yaml = `include:
22
+ - remote: http://example.com/ci.yml
23
+
24
+ stages:
25
+ - build
26
+ `;
27
+ const diags = wgl030.check(makeCtx(yaml));
28
+ expect(diags).toHaveLength(1);
29
+ expect(diags[0].checkId).toBe("WGL030");
30
+ expect(diags[0].severity).toBe("error");
31
+ });
32
+
33
+ test("warns on an HTTPS remote include", () => {
34
+ const yaml = `include:
35
+ - remote: https://example.com/ci.yml
36
+
37
+ stages:
38
+ - build
39
+ `;
40
+ const diags = wgl030.check(makeCtx(yaml));
41
+ expect(diags).toHaveLength(1);
42
+ expect(diags[0].severity).toBe("warning");
43
+ });
44
+
45
+ test("does not flag a project include", () => {
46
+ const yaml = `include:
47
+ - project: my-group/ci-templates
48
+ ref: v1.0.0
49
+ file: /x.yml
50
+
51
+ stages:
52
+ - build
53
+ `;
54
+ const diags = wgl030.check(makeCtx(yaml));
55
+ expect(diags).toHaveLength(0);
56
+ });
57
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * WGL030: Mutable or Insecure include:remote
3
+ *
4
+ * Flags `include:remote` URLs that are fetched over HTTP (no transport
5
+ * integrity) or over HTTPS but inherently mutable (a raw URL can be repointed
6
+ * at any time). Prefer `include:project` pinned to a tag/SHA, or a pinned
7
+ * component, over a remote URL. Generalizes WGL017 (non-HTTPS) to includes.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractIncludes } from "./yaml-helpers";
12
+
13
+ export const wgl030: PostSynthCheck = {
14
+ id: "WGL030",
15
+ description: "include:remote is insecure (HTTP) or mutable",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [, output] of ctx.outputs) {
21
+ const yaml = getPrimaryOutput(output);
22
+ for (const entry of extractIncludes(yaml)) {
23
+ const isRemote = entry.kind === "remote" || (entry.kind === "string" && /^https?:\/\//.test(entry.value));
24
+ if (!isRemote) continue;
25
+
26
+ if (/^http:\/\//.test(entry.value)) {
27
+ diagnostics.push({
28
+ checkId: "WGL030",
29
+ severity: "error",
30
+ message: `include:remote "${entry.value}" is fetched over HTTP — an attacker on the network can swap the included config. Use HTTPS, and prefer a pinned include:project or component.`,
31
+ entity: entry.value,
32
+ lexicon: "gitlab",
33
+ });
34
+ } else {
35
+ diagnostics.push({
36
+ checkId: "WGL030",
37
+ severity: "warning",
38
+ message: `include:remote "${entry.value}" is a mutable URL — its contents can change after review. Prefer include:project pinned to a tag/SHA, or a pinned component.`,
39
+ entity: entry.value,
40
+ lexicon: "gitlab",
41
+ });
42
+ }
43
+ }
44
+ }
45
+
46
+ return diagnostics;
47
+ },
48
+ };
@@ -0,0 +1,66 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl031 } from "./wgl031";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: {
10
+ outputs: new Map([["gitlab", yaml]]),
11
+ entities: new Map(),
12
+ warnings: [],
13
+ errors: [],
14
+ sourceFileCount: 1,
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("WGL031: container image without digest", () => {
20
+ test("flags an image pinned to a tag", () => {
21
+ const yaml = `build-job:
22
+ image:
23
+ name: node:20
24
+ script:
25
+ - echo build
26
+ `;
27
+ const diags = wgl031.check(makeCtx(yaml));
28
+ expect(diags).toHaveLength(1);
29
+ expect(diags[0].checkId).toBe("WGL031");
30
+ expect(diags[0].message).toContain("node:20");
31
+ });
32
+
33
+ test("flags a service image pinned to a tag", () => {
34
+ const yaml = `build-job:
35
+ services:
36
+ - name: postgres:16
37
+ script:
38
+ - echo build
39
+ `;
40
+ const diags = wgl031.check(makeCtx(yaml));
41
+ expect(diags).toHaveLength(1);
42
+ expect(diags[0].message).toContain("postgres:16");
43
+ });
44
+
45
+ test("does not flag an image pinned to a digest", () => {
46
+ const yaml = `build-job:
47
+ image:
48
+ name: node@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
49
+ script:
50
+ - echo build
51
+ `;
52
+ const diags = wgl031.check(makeCtx(yaml));
53
+ expect(diags).toHaveLength(0);
54
+ });
55
+
56
+ test("does not flag a variable-based image reference", () => {
57
+ const yaml = `build-job:
58
+ image:
59
+ name: $CI_REGISTRY_IMAGE:latest
60
+ script:
61
+ - echo build
62
+ `;
63
+ const diags = wgl031.check(makeCtx(yaml));
64
+ expect(diags).toHaveLength(0);
65
+ });
66
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * WGL031: Container Image Without an Immutable Digest
3
+ *
4
+ * Flags `image:` and `services:` references that are not pinned to an immutable
5
+ * `@sha256:` digest. A mutable tag can be repointed to a different image after
6
+ * review. Images built from a variable (e.g. `$CI_REGISTRY_IMAGE:tag`) are
7
+ * skipped — their concrete reference is not knowable from the static config.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractImageRefs } from "./yaml-helpers";
12
+
13
+ export const wgl031: PostSynthCheck = {
14
+ id: "WGL031",
15
+ description: "Container image not pinned to an immutable digest",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [, output] of ctx.outputs) {
21
+ const yaml = getPrimaryOutput(output);
22
+ for (const { job, image, source } of extractImageRefs(yaml)) {
23
+ if (image.includes("@sha256:")) continue;
24
+ if (image.includes("$")) continue; // variable-based reference — not statically knowable
25
+ diagnostics.push({
26
+ checkId: "WGL031",
27
+ severity: "warning",
28
+ message: `Job "${job}" ${source === "service" ? "service image" : "image"} "${image}" is not pinned to a digest — reference it by @sha256:... so the image cannot change after review.`,
29
+ entity: job,
30
+ lexicon: "gitlab",
31
+ });
32
+ }
33
+ }
34
+
35
+ return diagnostics;
36
+ },
37
+ };
@@ -0,0 +1,53 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl032, nearestKnownSource } from "./wgl032";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: {
10
+ outputs: new Map([["gitlab", yaml]]),
11
+ entities: new Map(),
12
+ warnings: [],
13
+ errors: [],
14
+ sourceFileCount: 1,
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("WGL032: look-alike include source", () => {
20
+ test("nearestKnownSource flags a near-miss", () => {
21
+ expect(nearestKnownSource("gitlab-org/gitlab")).toBeUndefined();
22
+ expect(nearestKnownSource("gitlab-org/gitlabb")).toBe("gitlab-org/gitlab");
23
+ expect(nearestKnownSource("totally/unrelated")).toBeUndefined();
24
+ });
25
+
26
+ test("flags a typo-squatted include:project", () => {
27
+ const yaml = `include:
28
+ - project: gitlab-org/gitlabb
29
+ ref: v1.0.0
30
+ file: /x.yml
31
+
32
+ stages:
33
+ - build
34
+ `;
35
+ const diags = wgl032.check(makeCtx(yaml));
36
+ expect(diags).toHaveLength(1);
37
+ expect(diags[0].checkId).toBe("WGL032");
38
+ expect(diags[0].message).toContain("gitlab-org/gitlab");
39
+ });
40
+
41
+ test("does not flag an exact known source", () => {
42
+ const yaml = `include:
43
+ - project: gitlab-org/gitlab
44
+ ref: v1.0.0
45
+ file: /x.yml
46
+
47
+ stages:
48
+ - build
49
+ `;
50
+ const diags = wgl032.check(makeCtx(yaml));
51
+ expect(diags).toHaveLength(0);
52
+ });
53
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * WGL032: Include/Component Source Resembling a Well-Known Project
3
+ *
4
+ * Flags an `include:project` path or `component:` source that is a near-miss
5
+ * (edit distance 1–2) of a well-known GitLab CI source but not an exact match —
6
+ * a likely typo or an impersonation of the trusted project. Advisory; backed by
7
+ * a vendored reference list.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractIncludes } from "./yaml-helpers";
12
+ import { KNOWN_INCLUDE_SOURCES, editDistance } from "../rules/data/known-include-sources";
13
+
14
+ const KNOWN = new Set(KNOWN_INCLUDE_SOURCES);
15
+
16
+ /** Reduce a component address (`host/group/name@ver`) or project path to its group/name path. */
17
+ function sourcePath(value: string, kind: string): string {
18
+ let v = value;
19
+ if (kind === "component") {
20
+ const at = v.lastIndexOf("@");
21
+ if (at !== -1) v = v.slice(0, at);
22
+ const slash = v.indexOf("/");
23
+ if (slash !== -1 && v.slice(0, slash).includes(".")) v = v.slice(slash + 1); // drop host
24
+ }
25
+ return v;
26
+ }
27
+
28
+ export function nearestKnownSource(path: string): string | undefined {
29
+ if (KNOWN.has(path)) return undefined;
30
+ let best: string | undefined;
31
+ let bestDist = 3;
32
+ for (const known of KNOWN) {
33
+ const d = editDistance(path, known, 2);
34
+ if (d >= 1 && d <= 2 && d < bestDist) {
35
+ best = known;
36
+ bestDist = d;
37
+ }
38
+ }
39
+ return best;
40
+ }
41
+
42
+ export const wgl032: PostSynthCheck = {
43
+ id: "WGL032",
44
+ description: "Include/component source resembles a well-known project (possible impersonation)",
45
+
46
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
47
+ const diagnostics: PostSynthDiagnostic[] = [];
48
+
49
+ for (const [, output] of ctx.outputs) {
50
+ const yaml = getPrimaryOutput(output);
51
+ for (const entry of extractIncludes(yaml)) {
52
+ if (entry.kind !== "project" && entry.kind !== "component") continue;
53
+ const path = sourcePath(entry.value, entry.kind);
54
+ const lookAlike = nearestKnownSource(path);
55
+ if (!lookAlike) continue;
56
+ diagnostics.push({
57
+ checkId: "WGL032",
58
+ severity: "warning",
59
+ message: `Include source "${path}" closely resembles the well-known "${lookAlike}". Confirm the source is who you intend — this may be a typo or impersonation.`,
60
+ entity: entry.value,
61
+ lexicon: "gitlab",
62
+ });
63
+ }
64
+ }
65
+
66
+ return diagnostics;
67
+ },
68
+ };
@@ -0,0 +1,58 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl033 } from "./wgl033";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["gitlab", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: {
10
+ outputs: new Map([["gitlab", yaml]]),
11
+ entities: new Map(),
12
+ warnings: [],
13
+ errors: [],
14
+ sourceFileCount: 1,
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("WGL033: OIDC id_token audience scoping", () => {
20
+ test("flags a wildcard audience", () => {
21
+ const yaml = `deploy:
22
+ stage: deploy
23
+ id_tokens:
24
+ GCP_TOKEN:
25
+ aud: '*'
26
+ script:
27
+ - echo deploy
28
+ `;
29
+ const diags = wgl033.check(makeCtx(yaml));
30
+ expect(diags).toHaveLength(1);
31
+ expect(diags[0].checkId).toBe("WGL033");
32
+ expect(diags[0].entity).toBe("deploy");
33
+ });
34
+
35
+ test("flags an empty audience", () => {
36
+ const yaml = `deploy:
37
+ id_tokens:
38
+ GCP_TOKEN:
39
+ aud: ''
40
+ script:
41
+ - echo deploy
42
+ `;
43
+ const diags = wgl033.check(makeCtx(yaml));
44
+ expect(diags).toHaveLength(1);
45
+ });
46
+
47
+ test("does not flag a scoped audience", () => {
48
+ const yaml = `deploy:
49
+ id_tokens:
50
+ GCP_TOKEN:
51
+ aud: https://iam.googleapis.com
52
+ script:
53
+ - echo deploy
54
+ `;
55
+ const diags = wgl033.check(makeCtx(yaml));
56
+ expect(diags).toHaveLength(0);
57
+ });
58
+ });