@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,208 @@
1
+ /**
2
+ * Security-aware migration (#306).
3
+ *
4
+ * Migration is the edge between the two security endpoints (#296 ↔ #305).
5
+ * Security properties don't translate 1:1 — some are silently preserved, some
6
+ * lost, some need re-establishing on the GitLab side. This module classifies
7
+ * each security-relevant property's fate as it crosses the boundary and runs
8
+ * the GitLab security post-synth checks against the migrated output so anything
9
+ * lost in translation is caught on the target side.
10
+ */
11
+
12
+ import type { ProvenanceRecord } from "./provenance";
13
+
14
+ const UNTRUSTED_CONTEXTS = [
15
+ "github.event.issue.title",
16
+ "github.event.issue.body",
17
+ "github.event.pull_request.title",
18
+ "github.event.pull_request.body",
19
+ "github.event.pull_request.head.ref",
20
+ "github.event.comment.body",
21
+ "github.event.review.body",
22
+ "github.event.head_commit.message",
23
+ "github.head_ref",
24
+ ];
25
+
26
+ const SHA_PINNED_USES = /uses:\s*['"]?([\w.-]+\/[\w.-]+(?:\/[\w.-]+)*)@([0-9a-f]{40})['"]?/g;
27
+ const SECRET_REF = /\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
28
+
29
+ /**
30
+ * Classify the fate of security-relevant properties as they migrate from a
31
+ * GitHub Actions workflow to GitLab CI. Returns provenance records carrying a
32
+ * `security` classification (which flow into diagnostics, SARIF, and the report
33
+ * like any other provenance record).
34
+ */
35
+ export function analyzeSecurity(sourceYaml: string, opts: { sourceFile?: string } = {}): ProvenanceRecord[] {
36
+ const records: ProvenanceRecord[] = [];
37
+ const file = opts.sourceFile;
38
+
39
+ // MIG-PIN-LOST — a GitHub SHA pin has no meaning once the action maps to a
40
+ // GitLab include/image (or becomes a hand-written script). Re-pin on GitLab.
41
+ const seenPins = new Set<string>();
42
+ let m: RegExpExecArray | null;
43
+ SHA_PINNED_USES.lastIndex = 0;
44
+ while ((m = SHA_PINNED_USES.exec(sourceYaml)) !== null) {
45
+ const slug = m[1];
46
+ if (seenPins.has(slug)) continue;
47
+ seenPins.add(slug);
48
+ records.push({
49
+ gitlabPath: "(target)",
50
+ sourceKey: `uses: ${slug}`,
51
+ sourceFile: file,
52
+ category: "synthesis",
53
+ rule: "MIG-PIN-LOST",
54
+ note: `The SHA pin on "${slug}" does not carry to its GitLab include/image — re-pin the GitLab target to a tag or digest (WGL029/WGL031).`,
55
+ security: { property: "Pinned action SHA", fate: "lost", severity: "warning", reestablish: "#297" },
56
+ });
57
+ }
58
+
59
+ // MIG-SECRET-UNSCOPED — ${{ secrets.X }} → $X, but masking/protection is set
60
+ // outside the YAML; the reference assumes a masked + protected variable.
61
+ const seenSecrets = new Set<string>();
62
+ SECRET_REF.lastIndex = 0;
63
+ while ((m = SECRET_REF.exec(sourceYaml)) !== null) {
64
+ const name = m[1];
65
+ if (seenSecrets.has(name)) continue;
66
+ seenSecrets.add(name);
67
+ records.push({
68
+ gitlabPath: "(target)",
69
+ sourceKey: `secrets.${name}`,
70
+ sourceFile: file,
71
+ category: "needs-review",
72
+ rule: "MIG-SECRET-UNSCOPED",
73
+ note: `secrets.${name} → $${name}, which assumes a masked + protected GitLab CI/CD variable. Mark it masked and protected (WGL038).`,
74
+ security: { property: "Secret reference", fate: "needs-review", severity: "warning", reestablish: "#300" },
75
+ });
76
+ }
77
+
78
+ // MIG-INJECTION-CARRIED — an untrusted-context interpolation translates
79
+ // verbatim, carrying the injection straight across.
80
+ const seenInjection = new Set<string>();
81
+ for (const ctx of UNTRUSTED_CONTEXTS) {
82
+ if (sourceYaml.includes(ctx) && !seenInjection.has(ctx)) {
83
+ seenInjection.add(ctx);
84
+ records.push({
85
+ gitlabPath: "(target)",
86
+ sourceKey: ctx,
87
+ sourceFile: file,
88
+ category: "needs-review",
89
+ rule: "MIG-INJECTION-CARRIED",
90
+ note: `Untrusted input \${{ ${ctx} }} translates to a $CI_* reference verbatim — the script-injection risk carries across (WGL035).`,
91
+ security: { property: "Template injection site", fate: "translated", severity: "warning", reestablish: "#299" },
92
+ });
93
+ }
94
+ }
95
+
96
+ // MIG-TRUST-BOUNDARY — pull_request_target maps to merge-request pipelines,
97
+ // which fork contributors can trigger; the trust boundary shifts.
98
+ if (/pull_request_target/.test(sourceYaml)) {
99
+ records.push({
100
+ gitlabPath: "(target)",
101
+ sourceKey: "on.pull_request_target",
102
+ sourceFile: file,
103
+ category: "needs-review",
104
+ rule: "MIG-TRUST-BOUNDARY",
105
+ note: `pull_request_target → merge-request pipelines, which fork contributors can trigger. Re-establish the trust boundary (protected refs, approvals) on GitLab (WGL034/WGL036).`,
106
+ security: { property: "Trigger trust boundary", fate: "needs-review", severity: "warning", reestablish: "#299" },
107
+ });
108
+ }
109
+
110
+ // MIG-PERMISSIONS-001 (reframed as least-privilege) — permissions: has no
111
+ // per-job GitLab YAML equivalent; least privilege lives in project settings.
112
+ if (/^permissions:/m.test(sourceYaml) || /^\s+permissions:/m.test(sourceYaml)) {
113
+ records.push({
114
+ gitlabPath: "(target)",
115
+ sourceKey: "permissions",
116
+ sourceFile: file,
117
+ category: "needs-review",
118
+ rule: "MIG-PERMISSIONS-001",
119
+ note: `Least-privilege permissions: have no per-job GitLab YAML equivalent — re-establish via project CI/CD token settings and id_tokens scoping (WGL033).`,
120
+ security: { property: "Least-privilege permissions", fate: "needs-review", severity: "warning", reestablish: "#298" },
121
+ });
122
+ }
123
+
124
+ return records;
125
+ }
126
+
127
+ /**
128
+ * Run the GitLab security post-synth checks against the migrated `.gitlab-ci.yml`
129
+ * and return their findings as provenance records — *migrate, then prove the
130
+ * output clears the GitLab security bar*. Anything lost in translation that
131
+ * lands as a concrete weakness on the target is caught here.
132
+ */
133
+ export async function runSecurityChecks(gitlabYaml: string, opts: { sourceFile?: string } = {}): Promise<ProvenanceRecord[]> {
134
+ const { gitlabPlugin } = await import("../../plugin");
135
+ const checks = gitlabPlugin.postSynthChecks?.() ?? [];
136
+ const ctx = {
137
+ outputs: new Map<string, string>([["gitlab", gitlabYaml]]),
138
+ entities: new Map(),
139
+ buildResult: {
140
+ outputs: new Map<string, string>([["gitlab", gitlabYaml]]),
141
+ entities: new Map(),
142
+ warnings: [],
143
+ errors: [],
144
+ sourceFileCount: 1,
145
+ },
146
+ };
147
+
148
+ const records: ProvenanceRecord[] = [];
149
+ for (const check of checks) {
150
+ let diags;
151
+ try {
152
+ diags = check.check(ctx as never);
153
+ } catch {
154
+ continue; // a check that needs entities/other context simply yields nothing here
155
+ }
156
+ for (const d of diags) {
157
+ records.push({
158
+ gitlabPath: "(target)",
159
+ sourceKey: d.entity ?? d.checkId,
160
+ sourceFile: opts.sourceFile,
161
+ category: "synthesis",
162
+ rule: d.checkId,
163
+ note: `Migrated output triggers ${d.checkId}: ${d.message}`,
164
+ security: {
165
+ property: "GitLab security check",
166
+ fate: "needs-review",
167
+ severity: d.severity === "error" ? "error" : d.severity === "info" ? "info" : "warning",
168
+ },
169
+ });
170
+ }
171
+ }
172
+ return records;
173
+ }
174
+
175
+ /** A one-property line in the security posture summary. */
176
+ interface PostureLine {
177
+ property: string;
178
+ fate: string;
179
+ rule: string;
180
+ count: number;
181
+ }
182
+
183
+ /**
184
+ * Render a "Security posture" Markdown section from the security provenance
185
+ * records (those carrying a `security` classification).
186
+ */
187
+ export function renderSecurityPosture(records: ProvenanceRecord[]): string {
188
+ const security = records.filter((r) => r.security);
189
+ if (security.length === 0) {
190
+ return "## Security posture\n\nNo security-relevant properties detected at the migration edge.\n";
191
+ }
192
+
193
+ const byRule = new Map<string, PostureLine>();
194
+ for (const r of security) {
195
+ const s = r.security!;
196
+ const existing = byRule.get(r.rule);
197
+ if (existing) existing.count += 1;
198
+ else byRule.set(r.rule, { property: s.property, fate: s.fate, rule: r.rule, count: 1 });
199
+ }
200
+
201
+ let out = "## Security posture\n\n";
202
+ out += "| Property | Fate | Rule | Count |\n|---|---|---|---|\n";
203
+ for (const line of byRule.values()) {
204
+ out += `| ${line.property} | ${line.fate} | ${line.rule} | ${line.count} |\n`;
205
+ }
206
+ out += "\nFates: **translated** (weakness carried as-is) · **approximated** · **needs-review** (re-establish on GitLab) · **lost** (no GitLab equivalent for the property).\n";
207
+ return out;
208
+ }
@@ -83,7 +83,7 @@ describe("gitlabPlugin", () => {
83
83
 
84
84
  test("returns post-synth checks", () => {
85
85
  const checks = gitlabPlugin.postSynthChecks!();
86
- expect(checks).toHaveLength(19);
86
+ expect(checks).toHaveLength(39);
87
87
  const ids = checks.map((c) => c.id);
88
88
  expect(ids).toContain("WGL010");
89
89
  expect(ids).toContain("WGL011");
@@ -104,6 +104,26 @@ describe("gitlabPlugin", () => {
104
104
  expect(ids).toContain("WGL026");
105
105
  expect(ids).toContain("WGL027");
106
106
  expect(ids).toContain("WGL028");
107
+ expect(ids).toContain("WGL029");
108
+ expect(ids).toContain("WGL030");
109
+ expect(ids).toContain("WGL031");
110
+ expect(ids).toContain("WGL032");
111
+ expect(ids).toContain("WGL033");
112
+ expect(ids).toContain("WGL034");
113
+ expect(ids).toContain("WGL035");
114
+ expect(ids).toContain("WGL036");
115
+ expect(ids).toContain("WGL037");
116
+ expect(ids).toContain("WGL038");
117
+ expect(ids).toContain("WGL039");
118
+ expect(ids).toContain("WGL040");
119
+ expect(ids).toContain("WGL041");
120
+ expect(ids).toContain("WGL042");
121
+ expect(ids).toContain("WGL043");
122
+ expect(ids).toContain("WGL044");
123
+ expect(ids).toContain("WGL045");
124
+ expect(ids).toContain("WGL046");
125
+ expect(ids).toContain("WGL047");
126
+ expect(ids).toContain("WGL048");
107
127
  });
108
128
 
109
129
  // -----------------------------------------------------------------------
@@ -218,6 +238,15 @@ describe("gitlabPlugin", () => {
218
238
  const names = tools.map((t) => t.name);
219
239
  expect(names).toContain("gitlab:diff");
220
240
  expect(names).toContain("migrate");
241
+ // Read-only context tools (#327/#328)
242
+ expect(names).toContain("gitlab:checks");
243
+ expect(names).toContain("gitlab:pipeline");
244
+ expect(names).toContain("gitlab:references");
245
+ expect(names).toContain("gitlab:affected");
246
+ expect(names).toContain("gitlab:pipeline-yaml");
247
+ expect(names).toContain("gitlab:source");
248
+ expect(names).toContain("gitlab:owns");
249
+ expect(names).toContain("gitlab:compare");
221
250
  for (const t of tools) {
222
251
  expect(typeof t.handler).toBe("function");
223
252
  }
package/src/plugin.ts CHANGED
@@ -12,6 +12,7 @@ import { createSkillsLoader, createDiffTool, createCatalogResource } from "@inte
12
12
  import { join, dirname } from "path";
13
13
  import { fileURLToPath } from "url";
14
14
  import { gitlabSerializer } from "./serializer";
15
+ import { gitlabContextTools } from "./mcp/context-tools";
15
16
  import { deprecatedOnlyExceptRule } from "./lint/rules/deprecated-only-except";
16
17
  import { missingScriptRule } from "./lint/rules/missing-script";
17
18
  import { missingStageRule } from "./lint/rules/missing-stage";
@@ -254,6 +255,9 @@ export const test = new Job({
254
255
  mcpTools() {
255
256
  return [
256
257
  createDiffTool(gitlabSerializer, "Compare current build output against previous output for GitLab CI", "gitlab"),
258
+ // Read-only context tools: what the pipeline does, what it pulls in, and
259
+ // its security findings — before anything runs (#327/#328).
260
+ ...gitlabContextTools(),
257
261
  {
258
262
  name: "migrate",
259
263
  description: "Translate a GitHub Actions workflow YAML into a GitLab CI/CD pipeline. Returns the rendered output plus diagnostic + provenance arrays.",
@@ -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