@intentius/chant-lexicon-gitlab 0.1.23 → 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
@@ -0,0 +1,38 @@
1
+ /**
2
+ * WGL033: OIDC id_token Without a Scoped Audience
3
+ *
4
+ * Flags an `id_tokens:` declaration with no `aud:` or a wildcard audience. The
5
+ * audience binds the minted OIDC token to a specific relying party; without it
6
+ * (or with `*`), a leaked token is accepted anywhere, defeating the point of
7
+ * short-lived federated identity. Set `aud:` to the exact audience(s) needed.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractIdTokens } from "./yaml-helpers";
12
+
13
+ export const wgl033: PostSynthCheck = {
14
+ id: "WGL033",
15
+ description: "OIDC id_token without a scoped audience",
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, name, aud } of extractIdTokens(yaml)) {
23
+ const broad = aud.length === 0 || aud.some((a) => a === "*" || a === "" || a.includes("*"));
24
+ if (broad) {
25
+ diagnostics.push({
26
+ checkId: "WGL033",
27
+ severity: "warning",
28
+ message: `Job "${job}" id_token "${name}" has ${aud.length === 0 ? "no aud:" : "a wildcard audience"}. Scope it to the exact audience the relying party expects so a leaked token cannot be used elsewhere.`,
29
+ entity: job,
30
+ lexicon: "gitlab",
31
+ });
32
+ }
33
+ }
34
+ }
35
+
36
+ return diagnostics;
37
+ },
38
+ };
@@ -0,0 +1,60 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl034 } from "./wgl034";
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("WGL034: OIDC token mintable from a merge-request pipeline", () => {
20
+ test("flags an OIDC job reachable from merge requests", () => {
21
+ const yaml = `deploy:
22
+ rules:
23
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
24
+ id_tokens:
25
+ GCP_TOKEN:
26
+ aud: https://iam.googleapis.com
27
+ script:
28
+ - echo deploy
29
+ `;
30
+ const diags = wgl034.check(makeCtx(yaml));
31
+ expect(diags).toHaveLength(1);
32
+ expect(diags[0].checkId).toBe("WGL034");
33
+ expect(diags[0].entity).toBe("deploy");
34
+ });
35
+
36
+ test("does not flag an OIDC job gated to a protected branch", () => {
37
+ const yaml = `deploy:
38
+ rules:
39
+ - if: $CI_COMMIT_BRANCH == "main"
40
+ id_tokens:
41
+ GCP_TOKEN:
42
+ aud: https://iam.googleapis.com
43
+ script:
44
+ - echo deploy
45
+ `;
46
+ const diags = wgl034.check(makeCtx(yaml));
47
+ expect(diags).toHaveLength(0);
48
+ });
49
+
50
+ test("does not flag a merge-request job without OIDC", () => {
51
+ const yaml = `test:
52
+ rules:
53
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
54
+ script:
55
+ - echo test
56
+ `;
57
+ const diags = wgl034.check(makeCtx(yaml));
58
+ expect(diags).toHaveLength(0);
59
+ });
60
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * WGL034: OIDC id_token Mintable from a Merge-Request Pipeline
3
+ *
4
+ * Flags a job that declares an `id_tokens:` (OIDC) and is reachable from
5
+ * merge-request pipelines. MR pipelines can be triggered by outside
6
+ * contributors, so a job that mints a cloud-federated token there hands fork
7
+ * code a path to your cloud roles. Gate OIDC jobs to protected refs, or require
8
+ * approval before they run.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractIdTokens, extractJobSection, isMergeRequestReachable } from "./yaml-helpers";
13
+
14
+ export const wgl034: PostSynthCheck = {
15
+ id: "WGL034",
16
+ description: "OIDC id_token mintable from a merge-request pipeline",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+ const seen = new Set<string>();
24
+ for (const { job } of extractIdTokens(yaml)) {
25
+ if (seen.has(job)) continue;
26
+ const section = extractJobSection(yaml, job);
27
+ if (section && isMergeRequestReachable(section)) {
28
+ seen.add(job);
29
+ diagnostics.push({
30
+ checkId: "WGL034",
31
+ severity: "warning",
32
+ message: `Job "${job}" mints an OIDC id_token and is reachable from merge-request pipelines, which outside contributors can trigger. Restrict it to protected refs or require approval before it runs.`,
33
+ entity: job,
34
+ lexicon: "gitlab",
35
+ });
36
+ }
37
+ }
38
+ }
39
+
40
+ return diagnostics;
41
+ },
42
+ };
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl035 } from "./wgl035";
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("WGL035: untrusted CI variable in script", () => {
20
+ test("flags an untrusted ref name used in a script command", () => {
21
+ const yaml = `build:
22
+ stage: build
23
+ script:
24
+ - git checkout $CI_COMMIT_REF_NAME
25
+ `;
26
+ const diags = wgl035.check(makeCtx(yaml));
27
+ expect(diags).toHaveLength(1);
28
+ expect(diags[0].checkId).toBe("WGL035");
29
+ expect(diags[0].entity).toBe("build");
30
+ expect(diags[0].message).toContain("CI_COMMIT_REF_NAME");
31
+ });
32
+
33
+ test("does not flag a trusted variable", () => {
34
+ const yaml = `build:
35
+ stage: build
36
+ script:
37
+ - echo "$CI_COMMIT_SHA"
38
+ `;
39
+ const diags = wgl035.check(makeCtx(yaml));
40
+ expect(diags).toHaveLength(0);
41
+ });
42
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * WGL035: Untrusted CI Variable Interpolated into a Script
3
+ *
4
+ * Flags an attacker-controllable predefined variable (branch/tag name, commit or
5
+ * merge-request title/description, author) referenced in a `script:` command. A
6
+ * crafted branch name or commit title containing shell metacharacters can break
7
+ * out of the command. Quote the reference and avoid using it in sensitive
8
+ * commands, or sanitize it first.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractScriptCommands } from "./yaml-helpers";
13
+ import { UNTRUSTED_CI_VARIABLES, variableReferenced } from "../rules/data/untrusted-variables";
14
+
15
+ export const wgl035: PostSynthCheck = {
16
+ id: "WGL035",
17
+ description: "Untrusted CI variable interpolated into a script command",
18
+
19
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
20
+ const diagnostics: PostSynthDiagnostic[] = [];
21
+
22
+ for (const [, output] of ctx.outputs) {
23
+ const yaml = getPrimaryOutput(output);
24
+ const seen = new Set<string>();
25
+ for (const { job, command } of extractScriptCommands(yaml)) {
26
+ for (const name of UNTRUSTED_CI_VARIABLES) {
27
+ if (!variableReferenced(command, name)) continue;
28
+ const key = `${job}:${name}`;
29
+ if (seen.has(key)) continue;
30
+ seen.add(key);
31
+ diagnostics.push({
32
+ checkId: "WGL035",
33
+ severity: "warning",
34
+ message: `Job "${job}" uses the attacker-controllable variable $${name} in a script command. A crafted value can inject shell commands — quote it ("$${name}") and avoid using it in sensitive commands, or sanitize it first.`,
35
+ entity: job,
36
+ lexicon: "gitlab",
37
+ });
38
+ }
39
+ }
40
+ }
41
+
42
+ return diagnostics;
43
+ },
44
+ };
@@ -0,0 +1,49 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl036 } from "./wgl036";
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("WGL036: privileged DinD reachable from merge requests", () => {
20
+ test("flags DinD in a merge-request-reachable job", () => {
21
+ const yaml = `build:
22
+ rules:
23
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
24
+ image: docker:24
25
+ services:
26
+ - docker:24-dind
27
+ script:
28
+ - docker build .
29
+ `;
30
+ const diags = wgl036.check(makeCtx(yaml));
31
+ expect(diags).toHaveLength(1);
32
+ expect(diags[0].checkId).toBe("WGL036");
33
+ expect(diags[0].entity).toBe("build");
34
+ });
35
+
36
+ test("does not flag DinD gated to a protected branch", () => {
37
+ const yaml = `build:
38
+ rules:
39
+ - if: $CI_COMMIT_BRANCH == "main"
40
+ image: docker:24
41
+ services:
42
+ - docker:24-dind
43
+ script:
44
+ - docker build .
45
+ `;
46
+ const diags = wgl036.check(makeCtx(yaml));
47
+ expect(diags).toHaveLength(0);
48
+ });
49
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * WGL036: Privileged Service / Docker-in-Docker Reachable from Merge Requests
3
+ *
4
+ * Flags a job that uses Docker-in-Docker (a privileged service) and is reachable
5
+ * from merge-request pipelines. MR pipelines can run code from an outside
6
+ * contributor; a privileged DinD service shares the host daemon socket and can
7
+ * be used to escape the build and reach the runner. Restrict privileged services
8
+ * to protected refs, or require approval before such jobs run.
9
+ *
10
+ * Complements WGL026 (DinD without TLS) by adding the trust-boundary dimension.
11
+ */
12
+
13
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
14
+ import { getPrimaryOutput, extractJobs, extractJobSection, isMergeRequestReachable } from "./yaml-helpers";
15
+
16
+ const DIND_RE = /docker:[\w.-]*dind|dind|privileged:\s*true/;
17
+
18
+ export const wgl036: PostSynthCheck = {
19
+ id: "WGL036",
20
+ description: "Privileged service / DinD reachable from merge-request pipelines",
21
+
22
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
23
+ const diagnostics: PostSynthDiagnostic[] = [];
24
+
25
+ for (const [, output] of ctx.outputs) {
26
+ const yaml = getPrimaryOutput(output);
27
+ for (const [job] of extractJobs(yaml)) {
28
+ const section = extractJobSection(yaml, job);
29
+ if (!section) continue;
30
+ if (DIND_RE.test(section) && isMergeRequestReachable(section)) {
31
+ diagnostics.push({
32
+ checkId: "WGL036",
33
+ severity: "warning",
34
+ message: `Job "${job}" runs a privileged Docker-in-Docker service and is reachable from merge-request pipelines, which outside contributors can trigger. Restrict privileged services to protected refs or require approval.`,
35
+ entity: job,
36
+ lexicon: "gitlab",
37
+ });
38
+ }
39
+ }
40
+ }
41
+
42
+ return diagnostics;
43
+ },
44
+ };
@@ -0,0 +1,45 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl037 } from "./wgl037";
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("WGL037: regex gate on untrusted ref", () => {
20
+ test("flags a =~ gate on the ref name", () => {
21
+ const yaml = `deploy:
22
+ stage: deploy
23
+ rules:
24
+ - if: $CI_COMMIT_REF_NAME =~ /^release/
25
+ script:
26
+ - ./deploy.sh
27
+ `;
28
+ const diags = wgl037.check(makeCtx(yaml));
29
+ expect(diags).toHaveLength(1);
30
+ expect(diags[0].checkId).toBe("WGL037");
31
+ expect(diags[0].entity).toBe("deploy");
32
+ });
33
+
34
+ test("does not flag an exact == match", () => {
35
+ const yaml = `deploy:
36
+ stage: deploy
37
+ rules:
38
+ - if: $CI_COMMIT_REF_NAME == "main"
39
+ script:
40
+ - ./deploy.sh
41
+ `;
42
+ const diags = wgl037.check(makeCtx(yaml));
43
+ expect(diags).toHaveLength(0);
44
+ });
45
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * WGL037: Security Gate on a Regex Match of an Untrusted Ref
3
+ *
4
+ * Flags a `rules:if` that gates execution on a regex match (`=~`) over an
5
+ * attacker-controllable ref variable (e.g. `$CI_COMMIT_REF_NAME =~ /^release/`).
6
+ * An outside contributor can craft a branch name that satisfies the pattern
7
+ * (`release-evil`), passing a gate meant for trusted refs. Match the full ref
8
+ * with `==`, or gate on a protected condition instead.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
13
+ import { UNTRUSTED_CI_VARIABLES } from "../rules/data/untrusted-variables";
14
+
15
+ const REF_VARS = ["CI_COMMIT_REF_NAME", "CI_COMMIT_REF_SLUG", "CI_COMMIT_BRANCH", "CI_COMMIT_TAG", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]
16
+ .filter((v) => UNTRUSTED_CI_VARIABLES.includes(v));
17
+
18
+ export const wgl037: PostSynthCheck = {
19
+ id: "WGL037",
20
+ description: "Security gate on a regex match of an untrusted ref variable",
21
+
22
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
23
+ const diagnostics: PostSynthDiagnostic[] = [];
24
+
25
+ for (const [, output] of ctx.outputs) {
26
+ const yaml = getPrimaryOutput(output);
27
+ for (const [job] of extractJobs(yaml)) {
28
+ const section = extractJobSection(yaml, job);
29
+ if (!section) continue;
30
+ // Look at `if:` lines that use a regex match against an untrusted ref var.
31
+ const ifLines = section.split("\n").filter((l) => /if:\s/.test(l));
32
+ const flagged = ifLines.some((l) => /=~/.test(l) && REF_VARS.some((v) => l.includes(`$${v}`)));
33
+ if (flagged) {
34
+ diagnostics.push({
35
+ checkId: "WGL037",
36
+ severity: "warning",
37
+ message: `Job "${job}" gates on a regex match (=~) of 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.`,
38
+ entity: job,
39
+ lexicon: "gitlab",
40
+ });
41
+ }
42
+ }
43
+ }
44
+
45
+ return diagnostics;
46
+ },
47
+ };
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl038 } from "./wgl038";
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("WGL038: secret reachable from merge requests", () => {
20
+ test("flags a user secret used in a merge-request-reachable job", () => {
21
+ const yaml = `deploy:
22
+ rules:
23
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
24
+ script:
25
+ - curl -H "Auth: $DEPLOY_TOKEN" https://api.example.com
26
+ `;
27
+ const diags = wgl038.check(makeCtx(yaml));
28
+ expect(diags).toHaveLength(1);
29
+ expect(diags[0].checkId).toBe("WGL038");
30
+ expect(diags[0].message).toContain("DEPLOY_TOKEN");
31
+ });
32
+
33
+ test("does not flag a built-in CI_ token", () => {
34
+ const yaml = `deploy:
35
+ rules:
36
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
37
+ script:
38
+ - echo "$CI_JOB_TOKEN" | docker login --password-stdin
39
+ `;
40
+ const diags = wgl038.check(makeCtx(yaml));
41
+ expect(diags).toHaveLength(0);
42
+ });
43
+
44
+ test("does not flag a secret gated to a protected branch", () => {
45
+ const yaml = `deploy:
46
+ rules:
47
+ - if: $CI_COMMIT_BRANCH == "main"
48
+ script:
49
+ - curl -H "Auth: $DEPLOY_TOKEN" https://api.example.com
50
+ `;
51
+ const diags = wgl038.check(makeCtx(yaml));
52
+ expect(diags).toHaveLength(0);
53
+ });
54
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * WGL038: Secret Reachable from a Merge-Request Pipeline
3
+ *
4
+ * Flags a user-defined secret-like variable (`*_PASSWORD`, `*_SECRET`,
5
+ * `*_TOKEN`, `*_API_KEY`, …) referenced in a job reachable from merge-request
6
+ * pipelines. Those pipelines can run an outside contributor's code, so a secret
7
+ * they can read is a secret they can exfiltrate. Gate secret-using jobs to
8
+ * protected refs, or make the variable protected so MR pipelines can't see it.
9
+ *
10
+ * Built-in `CI_*` variables are project-managed and excluded.
11
+ */
12
+
13
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
14
+ import { getPrimaryOutput, extractJobs, extractJobSection, isMergeRequestReachable } from "./yaml-helpers";
15
+
16
+ const SECRET_VAR = /\$\{?((?!CI_)[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?))\}?/g;
17
+
18
+ export const wgl038: PostSynthCheck = {
19
+ id: "WGL038",
20
+ description: "Secret-like variable reachable from a merge-request pipeline",
21
+
22
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
23
+ const diagnostics: PostSynthDiagnostic[] = [];
24
+
25
+ for (const [, output] of ctx.outputs) {
26
+ const yaml = getPrimaryOutput(output);
27
+ for (const [job] of extractJobs(yaml)) {
28
+ const section = extractJobSection(yaml, job);
29
+ if (!section || !isMergeRequestReachable(section)) continue;
30
+ const found = new Set<string>();
31
+ let m: RegExpExecArray | null;
32
+ SECRET_VAR.lastIndex = 0;
33
+ while ((m = SECRET_VAR.exec(section)) !== null) found.add(m[1]);
34
+ if (found.size > 0) {
35
+ diagnostics.push({
36
+ checkId: "WGL038",
37
+ severity: "warning",
38
+ message: `Job "${job}" reads secret-like variable(s) ${[...found].map((v) => `$${v}`).join(", ")} and is reachable from merge-request pipelines, which can run untrusted code. Gate it to protected refs or mark the variable protected.`,
39
+ entity: job,
40
+ lexicon: "gitlab",
41
+ });
42
+ }
43
+ }
44
+ }
45
+
46
+ return diagnostics;
47
+ },
48
+ };
@@ -0,0 +1,39 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl039 } from "./wgl039";
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("WGL039: secret echoed to logs", () => {
20
+ test("flags echo of a secret variable", () => {
21
+ const yaml = `debug:
22
+ script:
23
+ - echo $API_TOKEN
24
+ `;
25
+ const diags = wgl039.check(makeCtx(yaml));
26
+ expect(diags).toHaveLength(1);
27
+ expect(diags[0].checkId).toBe("WGL039");
28
+ expect(diags[0].entity).toBe("debug");
29
+ });
30
+
31
+ test("does not flag a normal echo", () => {
32
+ const yaml = `build:
33
+ script:
34
+ - echo "building the project"
35
+ `;
36
+ const diags = wgl039.check(makeCtx(yaml));
37
+ expect(diags).toHaveLength(0);
38
+ });
39
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * WGL039: Secret Echoed to Job Logs
3
+ *
4
+ * Flags a `script:` command that prints a secret-like variable (`echo`,
5
+ * `printf`, `print`, `cat`) — even masked variables leak when transformed
6
+ * (base64, reversed) before printing, and job logs are broadly readable.
7
+ * Remove the debug print or pipe the value where it is needed without echoing it.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractScriptCommands } from "./yaml-helpers";
12
+
13
+ const PRINT_SECRET = /\b(echo|printf|print|cat)\b[^\n]*\$\{?[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?)\}?/i;
14
+
15
+ export const wgl039: PostSynthCheck = {
16
+ id: "WGL039",
17
+ description: "Secret-like variable printed to job logs",
18
+
19
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
20
+ const diagnostics: PostSynthDiagnostic[] = [];
21
+
22
+ for (const [, output] of ctx.outputs) {
23
+ const yaml = getPrimaryOutput(output);
24
+ const seen = new Set<string>();
25
+ for (const { job, command } of extractScriptCommands(yaml)) {
26
+ if (PRINT_SECRET.test(command) && !seen.has(job)) {
27
+ seen.add(job);
28
+ diagnostics.push({
29
+ checkId: "WGL039",
30
+ severity: "warning",
31
+ message: `Job "${job}" prints a secret-like variable to the job log. Logs are broadly readable and masking can be defeated by transforms — remove the print.`,
32
+ entity: job,
33
+ lexicon: "gitlab",
34
+ });
35
+ }
36
+ }
37
+ }
38
+
39
+ return diagnostics;
40
+ },
41
+ };
@@ -0,0 +1,48 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { wgl040 } from "./wgl040";
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("WGL040: hardcoded registry credential", () => {
20
+ test("flags docker login with a literal password", () => {
21
+ const yaml = `publish:
22
+ script:
23
+ - docker login -u ci -p hunter2 registry.example.com
24
+ `;
25
+ const diags = wgl040.check(makeCtx(yaml));
26
+ expect(diags).toHaveLength(1);
27
+ expect(diags[0].checkId).toBe("WGL040");
28
+ expect(diags[0].severity).toBe("error");
29
+ });
30
+
31
+ test("does not flag a variable password", () => {
32
+ const yaml = `publish:
33
+ script:
34
+ - docker login -u ci -p $CI_REGISTRY_PASSWORD registry.example.com
35
+ `;
36
+ const diags = wgl040.check(makeCtx(yaml));
37
+ expect(diags).toHaveLength(0);
38
+ });
39
+
40
+ test("does not flag --password-stdin", () => {
41
+ const yaml = `publish:
42
+ script:
43
+ - echo "$REG_PASS" | docker login -u ci --password-stdin registry.example.com
44
+ `;
45
+ const diags = wgl040.check(makeCtx(yaml));
46
+ expect(diags).toHaveLength(0);
47
+ });
48
+ });