@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
@@ -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
  // -----------------------------------------------------------------------
@@ -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: