@intentius/chant-lexicon-gitlab 0.1.23 → 0.3.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 (86) hide show
  1. package/dist/integrity.json +25 -5
  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/dist/skills/chant-gitlab-patterns.md +15 -0
  26. package/package.json +1 -1
  27. package/src/codegen/docs.ts +251 -2
  28. package/src/composites/composites.test.ts +58 -0
  29. package/src/composites/index.ts +2 -0
  30. package/src/composites/mr-plan-report.ts +82 -0
  31. package/src/index.ts +2 -2
  32. package/src/lint/post-synth/wgl029.test.ts +77 -0
  33. package/src/lint/post-synth/wgl029.ts +59 -0
  34. package/src/lint/post-synth/wgl030.test.ts +57 -0
  35. package/src/lint/post-synth/wgl030.ts +48 -0
  36. package/src/lint/post-synth/wgl031.test.ts +66 -0
  37. package/src/lint/post-synth/wgl031.ts +37 -0
  38. package/src/lint/post-synth/wgl032.test.ts +53 -0
  39. package/src/lint/post-synth/wgl032.ts +68 -0
  40. package/src/lint/post-synth/wgl033.test.ts +58 -0
  41. package/src/lint/post-synth/wgl033.ts +38 -0
  42. package/src/lint/post-synth/wgl034.test.ts +60 -0
  43. package/src/lint/post-synth/wgl034.ts +42 -0
  44. package/src/lint/post-synth/wgl035.test.ts +42 -0
  45. package/src/lint/post-synth/wgl035.ts +44 -0
  46. package/src/lint/post-synth/wgl036.test.ts +49 -0
  47. package/src/lint/post-synth/wgl036.ts +44 -0
  48. package/src/lint/post-synth/wgl037.test.ts +45 -0
  49. package/src/lint/post-synth/wgl037.ts +47 -0
  50. package/src/lint/post-synth/wgl038.test.ts +54 -0
  51. package/src/lint/post-synth/wgl038.ts +48 -0
  52. package/src/lint/post-synth/wgl039.test.ts +39 -0
  53. package/src/lint/post-synth/wgl039.ts +41 -0
  54. package/src/lint/post-synth/wgl040.test.ts +48 -0
  55. package/src/lint/post-synth/wgl040.ts +42 -0
  56. package/src/lint/post-synth/wgl041.test.ts +49 -0
  57. package/src/lint/post-synth/wgl041.ts +57 -0
  58. package/src/lint/post-synth/wgl042.test.ts +45 -0
  59. package/src/lint/post-synth/wgl042.ts +46 -0
  60. package/src/lint/post-synth/wgl043.test.ts +43 -0
  61. package/src/lint/post-synth/wgl043.ts +45 -0
  62. package/src/lint/post-synth/wgl044.test.ts +46 -0
  63. package/src/lint/post-synth/wgl044.ts +41 -0
  64. package/src/lint/post-synth/wgl045.test.ts +48 -0
  65. package/src/lint/post-synth/wgl045.ts +57 -0
  66. package/src/lint/post-synth/wgl046.test.ts +67 -0
  67. package/src/lint/post-synth/wgl046.ts +43 -0
  68. package/src/lint/post-synth/wgl047.test.ts +40 -0
  69. package/src/lint/post-synth/wgl047.ts +41 -0
  70. package/src/lint/post-synth/wgl048.test.ts +56 -0
  71. package/src/lint/post-synth/wgl048.ts +37 -0
  72. package/src/lint/post-synth/yaml-helpers.ts +214 -0
  73. package/src/lint/rules/data/known-include-sources.ts +41 -0
  74. package/src/lint/rules/data/untrusted-variables.ts +31 -0
  75. package/src/mcp/context-tools.test.ts +178 -0
  76. package/src/mcp/context-tools.ts +282 -0
  77. package/src/migrate/from-github/diagnostics.ts +18 -0
  78. package/src/migrate/from-github/index.ts +20 -1
  79. package/src/migrate/from-github/provenance.ts +28 -0
  80. package/src/migrate/from-github/rules.ts +7 -0
  81. package/src/migrate/from-github/security.test.ts +98 -0
  82. package/src/migrate/from-github/security.ts +208 -0
  83. package/src/plugin.test.ts +30 -1
  84. package/src/plugin.ts +4 -0
  85. package/src/skills/chant-gitlab-migrate.md +10 -0
  86. package/src/skills/chant-gitlab-patterns.md +15 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * WGL041: Logically Unsound rules:if Condition
3
+ *
4
+ * Flags a `rules:if` that reads like a gate but is a constant — both sides of an
5
+ * `==`/`!=` are identical, so the condition is always true or always false.
6
+ * Generalizes WGL011 (rules that always evaluate to never) to tautological
7
+ * conditions presented as gates.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractJobs, extractJobSection, extractJobRules } from "./yaml-helpers";
12
+
13
+ function strip(s: string): string {
14
+ return s.trim().replace(/^['"]|['"]$/g, "").trim();
15
+ }
16
+
17
+ /** Classify an `if:` expression as a tautology, returning a reason or undefined. */
18
+ export function tautologyReason(expr: string): string | undefined {
19
+ const e = strip(expr).replace(/^\$\{?/, "$").trim();
20
+ const eq = e.match(/^(\S.*?)\s*==\s*(\S.*?)$/);
21
+ if (eq && strip(eq[1]) === strip(eq[2])) return "always true (both sides of == are identical)";
22
+ const ne = e.match(/^(\S.*?)\s*!=\s*(\S.*?)$/);
23
+ if (ne && strip(ne[1]) === strip(ne[2])) return "always false (both sides of != are identical)";
24
+ return undefined;
25
+ }
26
+
27
+ export const wgl041: PostSynthCheck = {
28
+ id: "WGL041",
29
+ description: "Logically unsound (tautological) rules:if condition",
30
+
31
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
32
+ const diagnostics: PostSynthDiagnostic[] = [];
33
+
34
+ for (const [, output] of ctx.outputs) {
35
+ const yaml = getPrimaryOutput(output);
36
+ for (const [job] of extractJobs(yaml)) {
37
+ const section = extractJobSection(yaml, job);
38
+ if (!section) continue;
39
+ for (const rule of extractJobRules(section)) {
40
+ if (!rule.if) continue;
41
+ const reason = tautologyReason(rule.if);
42
+ if (reason) {
43
+ diagnostics.push({
44
+ checkId: "WGL041",
45
+ severity: "warning",
46
+ message: `Job "${job}" has a rules:if that is ${reason}: "${rule.if}". A gate that does not constrain anything is misleading — write the real condition or remove it.`,
47
+ entity: job,
48
+ lexicon: "gitlab",
49
+ });
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ return diagnostics;
56
+ },
57
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * WGL042: Unreachable rules After an Unconditional Match
3
+ *
4
+ * GitLab evaluates `rules:` top-down and the first match wins. A rule with no
5
+ * `if:` (and not `when: never`) matches every pipeline, so any rule listed after
6
+ * it is dead — it can never be reached. Such dead rules usually mean the gate
7
+ * the author intended is not actually applied. Reorder so specific rules precede
8
+ * the catch-all.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractJobs, extractJobSection, extractJobRules } from "./yaml-helpers";
13
+
14
+ export const wgl042: PostSynthCheck = {
15
+ id: "WGL042",
16
+ description: "Unreachable rules after an unconditional match",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+ for (const [job] of extractJobs(yaml)) {
24
+ const section = extractJobSection(yaml, job);
25
+ if (!section) continue;
26
+ const rules = extractJobRules(section);
27
+ for (let i = 0; i < rules.length - 1; i++) {
28
+ const r = rules[i];
29
+ const unconditional = !r.if && r.when !== "never";
30
+ if (unconditional) {
31
+ diagnostics.push({
32
+ checkId: "WGL042",
33
+ severity: "warning",
34
+ message: `Job "${job}" has an unconditional rule at position ${i + 1} that matches every pipeline, making the ${rules.length - i - 1} rule(s) after it unreachable. Put specific rules before the catch-all.`,
35
+ entity: job,
36
+ lexicon: "gitlab",
37
+ });
38
+ break;
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ return diagnostics;
45
+ },
46
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * WGL043: Match-Anything Regex Gate
3
+ *
4
+ * Flags a `rules:if` whose regex match (`=~`) uses a pattern that matches every
5
+ * value — an empty regex, dot-star, or an anchored dot-star. The condition reads
6
+ * like a filter on a ref or variable but admits everything, so it gates nothing.
7
+ * This is how an unsound condition is obscured behind a regex. Tighten the
8
+ * pattern or remove the gate.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractJobs, extractJobSection, extractJobRules } from "./yaml-helpers";
13
+
14
+ // `=~` followed by a regex literal that matches anything.
15
+ const MATCH_ANYTHING = /=~\s*\/(?:|\.\*|\^\.\*\$|\^\.\*|\.\*\$)\//;
16
+
17
+ export const wgl043: PostSynthCheck = {
18
+ id: "WGL043",
19
+ description: "Match-anything regex gate in rules:if",
20
+
21
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
+ const diagnostics: PostSynthDiagnostic[] = [];
23
+
24
+ for (const [, output] of ctx.outputs) {
25
+ const yaml = getPrimaryOutput(output);
26
+ for (const [job] of extractJobs(yaml)) {
27
+ const section = extractJobSection(yaml, job);
28
+ if (!section) continue;
29
+ for (const rule of extractJobRules(section)) {
30
+ if (rule.if && MATCH_ANYTHING.test(rule.if)) {
31
+ diagnostics.push({
32
+ checkId: "WGL043",
33
+ severity: "warning",
34
+ message: `Job "${job}" gates on a regex that matches everything ("${rule.if}") — the rule reads like a filter but admits every value. Tighten the pattern or remove the gate.`,
35
+ entity: job,
36
+ lexicon: "gitlab",
37
+ });
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ return diagnostics;
44
+ },
45
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * WGL044: Public Artifacts
3
+ *
4
+ * Flags `artifacts:public: true`. Public artifacts are downloadable by anyone,
5
+ * including unauthenticated users for public projects, exposing build output and
6
+ * anything swept into it. Keep artifacts private unless they are deliberately
7
+ * meant to be world-readable.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
12
+
13
+ const PUBLIC_ARTIFACTS = /^\s+public:\s*true\s*$/m;
14
+
15
+ export const wgl044: PostSynthCheck = {
16
+ id: "WGL044",
17
+ description: "Public artifacts expose build output",
18
+
19
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
20
+ const diagnostics: PostSynthDiagnostic[] = [];
21
+
22
+ for (const [, output] of ctx.outputs) {
23
+ const yaml = getPrimaryOutput(output);
24
+ for (const [job] of extractJobs(yaml)) {
25
+ const section = extractJobSection(yaml, job);
26
+ if (!section || !/^\s+artifacts:/m.test(section)) continue;
27
+ if (PUBLIC_ARTIFACTS.test(section)) {
28
+ diagnostics.push({
29
+ checkId: "WGL044",
30
+ severity: "warning",
31
+ message: `Job "${job}" publishes public artifacts (artifacts:public: true), downloadable by anyone. Keep them private unless they are meant to be world-readable.`,
32
+ entity: job,
33
+ lexicon: "gitlab",
34
+ });
35
+ }
36
+ }
37
+ }
38
+
39
+ return diagnostics;
40
+ },
41
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * WGL045: Credential-Bearing Artifact Path
3
+ *
4
+ * Flags an `artifacts:paths:` entry that looks like it would sweep a credential
5
+ * or sensitive file into the artifact (`.env`, `*.pem`, `id_rsa`, `*.key`,
6
+ * `.npmrc`, `.netrc`, `credentials`). Artifacts flow to later stages and are
7
+ * downloadable, so a captured credential leaks across the boundary. Exclude the
8
+ * sensitive file from the artifact path.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
13
+
14
+ const CREDENTIAL_PATH = /(^|\/)(\.env(\.|$)|\.npmrc|\.netrc|id_rsa|.*\.pem|.*\.key|.*\.p12|credentials?(\.|$))/i;
15
+
16
+ export const wgl045: PostSynthCheck = {
17
+ id: "WGL045",
18
+ description: "Artifact path that may capture a credential file",
19
+
20
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
21
+ const diagnostics: PostSynthDiagnostic[] = [];
22
+
23
+ for (const [, output] of ctx.outputs) {
24
+ const yaml = getPrimaryOutput(output);
25
+ for (const [job] of extractJobs(yaml)) {
26
+ const section = extractJobSection(yaml, job);
27
+ if (!section) continue;
28
+ // Collect the lines under the artifacts: block (deeper indent than the key).
29
+ const lines = section.split("\n");
30
+ const artifactsIdx = lines.findIndex((l) => /^\s+artifacts:\s*$/.test(l));
31
+ if (artifactsIdx === -1) continue;
32
+ const artifactsIndent = lines[artifactsIdx].search(/\S/);
33
+ const blockLines: string[] = [];
34
+ for (let i = artifactsIdx + 1; i < lines.length; i++) {
35
+ if (lines[i].trim() === "") continue;
36
+ if (lines[i].search(/\S/) <= artifactsIndent) break;
37
+ blockLines.push(lines[i]);
38
+ }
39
+ const hit = blockLines
40
+ .filter((l) => /^\s+-\s+/.test(l))
41
+ .map((l) => l.replace(/^\s+-\s+/, "").trim().replace(/^['"]|['"]$/g, ""))
42
+ .find((p) => CREDENTIAL_PATH.test(p));
43
+ if (hit) {
44
+ diagnostics.push({
45
+ checkId: "WGL045",
46
+ severity: "error",
47
+ message: `Job "${job}" includes a credential-like file ("${hit}") in its artifacts. Exclude it — artifacts are passed downstream and are downloadable.`,
48
+ entity: job,
49
+ lexicon: "gitlab",
50
+ });
51
+ }
52
+ }
53
+ }
54
+
55
+ return diagnostics;
56
+ },
57
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * WGL046: Cache Populated in a Merge-Request Pipeline (Poisoning Risk)
3
+ *
4
+ * Flags a job that writes a cache (`cache:` with a push policy) and is reachable
5
+ * from merge-request pipelines. An outside contributor's MR can populate the
6
+ * cache; a later protected run that restores the same key then executes
7
+ * attacker-influenced contents. Restrict cache writes to protected refs, or
8
+ * scope cache keys so MR pipelines cannot write entries a protected run reads.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, extractJobs, extractJobSection, isMergeRequestReachable } from "./yaml-helpers";
13
+
14
+ export const wgl046: PostSynthCheck = {
15
+ id: "WGL046",
16
+ description: "Cache populated in a merge-request pipeline (poisoning risk)",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+ for (const [job] of extractJobs(yaml)) {
24
+ const section = extractJobSection(yaml, job);
25
+ if (!section || !/^\s+cache:/m.test(section)) continue;
26
+ if (!isMergeRequestReachable(section)) continue;
27
+ // pull-only caches cannot poison; flag push/pull-push (the default when policy is absent)
28
+ const policyMatch = section.match(/^\s+policy:\s*(\S+)/m);
29
+ const policy = policyMatch ? policyMatch[1].replace(/^['"]|['"]$/g, "") : "pull-push";
30
+ if (policy === "pull") continue;
31
+ diagnostics.push({
32
+ checkId: "WGL046",
33
+ severity: "warning",
34
+ message: `Job "${job}" writes a cache 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.`,
35
+ entity: job,
36
+ lexicon: "gitlab",
37
+ });
38
+ }
39
+ }
40
+
41
+ return diagnostics;
42
+ },
43
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * WGL047: Software Fetched and Executed at Runtime Without Verification
3
+ *
4
+ * Flags a `script:` command that pipes a network download straight into a shell
5
+ * (`curl ... | bash`, `wget ... | sh`). The fetched script is unpinned and
6
+ * unverified, so whoever controls the URL controls what runs in the job.
7
+ * Download to a file, verify a checksum/signature, then execute 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 PIPE_TO_SHELL = /\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba)?sh\b/i;
14
+
15
+ export const wgl047: PostSynthCheck = {
16
+ id: "WGL047",
17
+ description: "Software fetched and piped to a shell without verification",
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 (PIPE_TO_SHELL.test(command) && !seen.has(job)) {
27
+ seen.add(job);
28
+ diagnostics.push({
29
+ checkId: "WGL047",
30
+ severity: "warning",
31
+ message: `Job "${job}" pipes a network download directly into a shell — the fetched code is unpinned and unverified. Download to a file, verify a checksum or signature, then execute it.`,
32
+ entity: job,
33
+ lexicon: "gitlab",
34
+ });
35
+ }
36
+ }
37
+ }
38
+
39
+ return diagnostics;
40
+ },
41
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * WGL048: Pipeline Without a Name
3
+ *
4
+ * Flags a pipeline that defines a `workflow:` block but no `workflow:name:`.
5
+ * A pipeline name shows up in the GitLab UI and audit output; naming pipelines
6
+ * makes runs easier to identify and review. Only flags pipelines that already
7
+ * customize `workflow:` (where a name is cheap to add and most useful).
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput } from "./yaml-helpers";
12
+
13
+ export const wgl048: PostSynthCheck = {
14
+ id: "WGL048",
15
+ description: "Pipeline defines workflow: but no workflow:name",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [, output] of ctx.outputs) {
21
+ const yaml = getPrimaryOutput(output);
22
+ const workflowSection = yaml.split("\n\n").find((s) => /^workflow:/.test(s));
23
+ if (!workflowSection) continue;
24
+ if (!/^\s+name:/m.test(workflowSection)) {
25
+ diagnostics.push({
26
+ checkId: "WGL048",
27
+ severity: "info",
28
+ message: `The pipeline defines a workflow: block but no workflow:name. Add a name so runs are identifiable in the GitLab UI and audit output.`,
29
+ entity: "workflow",
30
+ lexicon: "gitlab",
31
+ });
32
+ }
33
+ }
34
+
35
+ return diagnostics;
36
+ },
37
+ };
@@ -223,3 +223,217 @@ export function extractJobRules(section: string): ParsedRule[] {
223
223
 
224
224
  return rules;
225
225
  }
226
+
227
+ /** A container image reference (job/default `image:` or a `services:` entry). */
228
+ export interface ImageRef {
229
+ /** Owning job name, or "default" for the global image. */
230
+ job: string;
231
+ /** The image reference (e.g. `node:20`). */
232
+ image: string;
233
+ /** Where the image was declared. */
234
+ source: "image" | "service";
235
+ }
236
+
237
+ /**
238
+ * Extract container image references from `image:` and `services:` across the
239
+ * pipeline. Handles both the object form (`image:\n name: ...`) and the inline
240
+ * string form (`image: ...`).
241
+ */
242
+ export function extractImageRefs(yaml: string): ImageRef[] {
243
+ const refs: ImageRef[] = [];
244
+ for (const section of yaml.split("\n\n")) {
245
+ const lines = section.split("\n");
246
+ const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
247
+ const job = top ? top[1] : "default";
248
+
249
+ for (let i = 0; i < lines.length; i++) {
250
+ // image: as object (next indented `name:`) or inline string
251
+ const imgInline = lines[i].match(/^\s+image:\s+(\S.*)$/);
252
+ if (imgInline) {
253
+ refs.push({ job, image: imgInline[1].trim().replace(/^['"]|['"]$/g, ""), source: "image" });
254
+ continue;
255
+ }
256
+ if (/^\s+image:\s*$/.test(lines[i])) {
257
+ const nameLine = lines.slice(i + 1, i + 4).find((l) => /^\s+name:\s+/.test(l));
258
+ if (nameLine) {
259
+ const v = nameLine.replace(/^\s+name:\s+/, "").trim().replace(/^['"]|['"]$/g, "");
260
+ refs.push({ job, image: v, source: "image" });
261
+ }
262
+ continue;
263
+ }
264
+ // services: list entries — `- name:` or `- 'image'`
265
+ const svcName = lines[i].match(/^\s+-\s+name:\s+(\S.*)$/);
266
+ if (svcName) {
267
+ refs.push({ job, image: svcName[1].trim().replace(/^['"]|['"]$/g, ""), source: "service" });
268
+ }
269
+ }
270
+ }
271
+ return refs;
272
+ }
273
+
274
+ /** An `include:` entry from the top-level include block. */
275
+ export interface IncludeEntry {
276
+ kind: "project" | "remote" | "component" | "local" | "template" | "string";
277
+ /** The primary value (project path, URL, component address, …). */
278
+ value: string;
279
+ /** `ref:` for project includes, if present. */
280
+ ref?: string;
281
+ }
282
+
283
+ /**
284
+ * Parse the top-level `include:` block into structured entries.
285
+ */
286
+ export function extractIncludes(yaml: string): IncludeEntry[] {
287
+ const entries: IncludeEntry[] = [];
288
+ const section = yaml.split("\n\n").find((s) => /^include:/.test(s));
289
+ if (!section) return entries;
290
+
291
+ // Inline string form: `include: <value>` (value on the same line, not a list)
292
+ const inline = section.match(/^include:[ \t]+(\S.*)$/m);
293
+ if (inline) {
294
+ entries.push({ kind: "string", value: inline[1].trim().replace(/^['"]|['"]$/g, "") });
295
+ return entries;
296
+ }
297
+
298
+ const lines = section.split("\n");
299
+ let current: IncludeEntry | undefined;
300
+ const push = () => { if (current) entries.push(current); current = undefined; };
301
+ for (const line of lines) {
302
+ const start = line.match(/^\s+-\s+(project|remote|component|local|template):\s*(.*)$/);
303
+ if (start) {
304
+ push();
305
+ current = { kind: start[1] as IncludeEntry["kind"], value: start[2].trim().replace(/^['"]|['"]$/g, "") };
306
+ continue;
307
+ }
308
+ const refLine = line.match(/^\s+ref:\s+(.+)$/);
309
+ if (refLine && current) {
310
+ current.ref = refLine[1].trim().replace(/^['"]|['"]$/g, "");
311
+ }
312
+ // A bare `- 'https://...'` short remote form
313
+ const bare = line.match(/^\s+-\s+(['"]?https?:\/\/\S+['"]?)\s*$/);
314
+ if (bare) {
315
+ push();
316
+ entries.push({ kind: "remote", value: bare[1].replace(/^['"]|['"]$/g, "") });
317
+ }
318
+ }
319
+ push();
320
+ return entries;
321
+ }
322
+
323
+ /** True if a git ref is an immutable pin (40-hex SHA or a vN.N.N-style tag). */
324
+ export function isPinnedRef(ref: string): boolean {
325
+ if (/^[0-9a-f]{40}$/.test(ref)) return true;
326
+ // Semver-ish tag with all components fixed (v1.2.3 / 1.2.3)
327
+ if (/^v?\d+\.\d+\.\d+$/.test(ref)) return true;
328
+ return false;
329
+ }
330
+
331
+ /** An `id_tokens:` OIDC token declaration within a job. */
332
+ export interface IdTokenDecl {
333
+ job: string;
334
+ /** Token variable name (e.g. `GCP_ID_TOKEN`). */
335
+ name: string;
336
+ /** Declared audiences (`aud:`), empty if none. */
337
+ aud: string[];
338
+ }
339
+
340
+ /**
341
+ * Extract `id_tokens:` declarations (OIDC) per job, with their audiences.
342
+ */
343
+ export function extractIdTokens(yaml: string): IdTokenDecl[] {
344
+ const out: IdTokenDecl[] = [];
345
+ for (const section of yaml.split("\n\n")) {
346
+ const lines = section.split("\n");
347
+ const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
348
+ if (!top) continue;
349
+ const job = top[1];
350
+
351
+ let inIdTokens = false;
352
+ let idTokensIndent = -1;
353
+ let current: IdTokenDecl | undefined;
354
+ const flush = () => { if (current) out.push(current); current = undefined; };
355
+
356
+ for (const line of lines) {
357
+ if (/^\s+id_tokens:\s*$/.test(line)) {
358
+ inIdTokens = true;
359
+ idTokensIndent = line.search(/\S/);
360
+ continue;
361
+ }
362
+ if (!inIdTokens) continue;
363
+ const indent = line.search(/\S/);
364
+ if (line.trim() !== "" && indent <= idTokensIndent) { flush(); inIdTokens = false; continue; }
365
+
366
+ const tokenName = line.match(/^\s+([A-Z_][A-Z0-9_]*):\s*$/);
367
+ if (tokenName && indent === idTokensIndent + 2) {
368
+ flush();
369
+ current = { job, name: tokenName[1], aud: [] };
370
+ continue;
371
+ }
372
+ const audInline = line.match(/^\s+aud:\s+(\S.*)$/);
373
+ if (audInline && current) {
374
+ const v = audInline[1].trim();
375
+ if (v.startsWith("[")) {
376
+ for (const p of v.replace(/^\[|\]$/g, "").split(",")) {
377
+ const t = p.trim().replace(/^['"]|['"]$/g, "");
378
+ if (t) current.aud.push(t);
379
+ }
380
+ } else {
381
+ current.aud.push(v.replace(/^['"]|['"]$/g, ""));
382
+ }
383
+ continue;
384
+ }
385
+ const audItem = line.match(/^\s+-\s+(\S.*)$/);
386
+ if (audItem && current) {
387
+ current.aud.push(audItem[1].trim().replace(/^['"]|['"]$/g, ""));
388
+ }
389
+ }
390
+ flush();
391
+ }
392
+ return out;
393
+ }
394
+
395
+ /** True if a job section's rules make it reachable from merge-request pipelines. */
396
+ export function isMergeRequestReachable(section: string): boolean {
397
+ return /merge_request_event|CI_MERGE_REQUEST|CI_PIPELINE_SOURCE\s*==\s*['"]?merge_request/.test(section);
398
+ }
399
+
400
+ /** A shell command line from a `script:` / `before_script:` / `after_script:`. */
401
+ export interface ScriptCommand {
402
+ job: string;
403
+ command: string;
404
+ }
405
+
406
+ /**
407
+ * Extract shell command lines from all `script:` family blocks, per job.
408
+ */
409
+ export function extractScriptCommands(yaml: string): ScriptCommand[] {
410
+ const out: ScriptCommand[] = [];
411
+ for (const section of yaml.split("\n\n")) {
412
+ const lines = section.split("\n");
413
+ const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
414
+ if (!top) continue;
415
+ const job = top[1];
416
+
417
+ let inScript = false;
418
+ let scriptIndent = -1;
419
+ for (const line of lines) {
420
+ if (/^\s+(before_script|after_script|script):\s*$/.test(line)) {
421
+ inScript = true;
422
+ scriptIndent = line.search(/\S/);
423
+ continue;
424
+ }
425
+ // inline form `script: cmd`
426
+ const inlineScript = line.match(/^\s+(?:before_script|after_script|script):\s+(\S.*)$/);
427
+ if (inlineScript) {
428
+ out.push({ job, command: inlineScript[1].trim().replace(/^['"]|['"]$/g, "") });
429
+ continue;
430
+ }
431
+ if (!inScript) continue;
432
+ const indent = line.search(/\S/);
433
+ if (line.trim() !== "" && indent <= scriptIndent) { inScript = false; continue; }
434
+ const item = line.match(/^\s+-\s+(.*)$/);
435
+ if (item) out.push({ job, command: item[1].trim().replace(/^['"]|['"]$/g, "") });
436
+ }
437
+ }
438
+ return out;
439
+ }
@@ -79,6 +79,16 @@ npx chant migrate path/to/workflow.yml --output .gitlab-ci.yml --validate
79
79
 
80
80
  The CLI picks `glci` (offline, no auth) first; `glab ci lint` second. If neither is on PATH, `--validate` warns and skips. Pair with `--strict` to make validation failures hard.
81
81
 
82
+ `--validate` also runs the **security backend** (#306): it classifies the fate of each security property across the migration edge and runs the GitLab security post-synth checks (WGL029+) against the emitted YAML — *migrate, then prove the output clears the GitLab security bar*. The report gains a **Security posture** section plus `MIG-*` security diagnostics:
83
+
84
+ - `MIG-PIN-LOST` — a GitHub SHA pin can't follow the action to its GitLab include/image; re-pin on GitLab (→ #297 / WGL029, WGL031).
85
+ - `MIG-SECRET-UNSCOPED` — a migrated `$VAR` assumes a masked + protected GitLab variable, set outside the YAML (→ #300 / WGL038).
86
+ - `MIG-INJECTION-CARRIED` — an untrusted-input injection site translated verbatim; the weakness carries across (→ #299 / WGL035).
87
+ - `MIG-TRUST-BOUNDARY` — `pull_request_target` → merge-request pipelines that fork contributors can trigger; re-establish protected-ref/approval gating (→ #299 / WGL034, WGL036).
88
+ - `MIG-PERMISSIONS-001` (least-privilege) — no per-job GitLab YAML equivalent; lives in project token settings + `id_tokens` scoping (→ #298 / WGL033).
89
+
90
+ Fates mirror the functional provenance model: **translated** (weakness carried as-is), **approximated**, **needs-review** (re-establish on GitLab), **lost** (no GitLab equivalent for the property). Use these to tell the user not just *did it migrate* but *did the security posture survive*.
91
+
82
92
  ## Step 5: Decide the emit mode
83
93
 
84
94
  Two modes serve different intents:
@@ -197,6 +197,21 @@ export const app = NodePipeline({
197
197
  });
198
198
  ```
199
199
 
200
+ ### Publish the plan to the merge request
201
+
202
+ `MrPlanReport` turns `chant lifecycle plan` into the MR plan widget (the `terraform` artifact type above). It runs the plan with `--report gitlab-mr`, writes the `{create,update,delete}` counts to `tfplan.json`, and declares it as `artifacts:reports:terraform`:
203
+
204
+ ```typescript
205
+ import { MrPlanReport } from "@intentius/chant-lexicon-gitlab";
206
+
207
+ export const plan = MrPlanReport({
208
+ environment: "prod",
209
+ before: ["aws sts get-caller-identity"], // credential setup — the plan reads live state
210
+ });
211
+ ```
212
+
213
+ The widget label always reads "Terraform" (GitLab's fixed string). Counts only — `adopt`/`noop` are excluded; run the plan in the job log for the per-resource breakdown.
214
+
200
215
  ## Rules and Conditional Execution
201
216
 
202
217
  ### Branch-Based Rules
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.1.23",
3
+ "version": "0.3.0",
4
4
  "description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",