@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.
- package/dist/integrity.json +24 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/wgl029.ts +59 -0
- package/dist/rules/wgl030.ts +48 -0
- package/dist/rules/wgl031.ts +37 -0
- package/dist/rules/wgl032.ts +68 -0
- package/dist/rules/wgl033.ts +38 -0
- package/dist/rules/wgl034.ts +42 -0
- package/dist/rules/wgl035.ts +44 -0
- package/dist/rules/wgl036.ts +44 -0
- package/dist/rules/wgl037.ts +47 -0
- package/dist/rules/wgl038.ts +48 -0
- package/dist/rules/wgl039.ts +41 -0
- package/dist/rules/wgl040.ts +42 -0
- package/dist/rules/wgl041.ts +57 -0
- package/dist/rules/wgl042.ts +46 -0
- package/dist/rules/wgl043.ts +45 -0
- package/dist/rules/wgl044.ts +41 -0
- package/dist/rules/wgl045.ts +57 -0
- package/dist/rules/wgl046.ts +43 -0
- package/dist/rules/wgl047.ts +41 -0
- package/dist/rules/wgl048.ts +37 -0
- package/dist/rules/yaml-helpers.ts +214 -0
- package/dist/skills/chant-gitlab-migrate.md +10 -0
- package/package.json +1 -1
- package/src/codegen/docs.ts +130 -0
- package/src/lint/post-synth/wgl029.test.ts +77 -0
- package/src/lint/post-synth/wgl029.ts +59 -0
- package/src/lint/post-synth/wgl030.test.ts +57 -0
- package/src/lint/post-synth/wgl030.ts +48 -0
- package/src/lint/post-synth/wgl031.test.ts +66 -0
- package/src/lint/post-synth/wgl031.ts +37 -0
- package/src/lint/post-synth/wgl032.test.ts +53 -0
- package/src/lint/post-synth/wgl032.ts +68 -0
- package/src/lint/post-synth/wgl033.test.ts +58 -0
- package/src/lint/post-synth/wgl033.ts +38 -0
- package/src/lint/post-synth/wgl034.test.ts +60 -0
- package/src/lint/post-synth/wgl034.ts +42 -0
- package/src/lint/post-synth/wgl035.test.ts +42 -0
- package/src/lint/post-synth/wgl035.ts +44 -0
- package/src/lint/post-synth/wgl036.test.ts +49 -0
- package/src/lint/post-synth/wgl036.ts +44 -0
- package/src/lint/post-synth/wgl037.test.ts +45 -0
- package/src/lint/post-synth/wgl037.ts +47 -0
- package/src/lint/post-synth/wgl038.test.ts +54 -0
- package/src/lint/post-synth/wgl038.ts +48 -0
- package/src/lint/post-synth/wgl039.test.ts +39 -0
- package/src/lint/post-synth/wgl039.ts +41 -0
- package/src/lint/post-synth/wgl040.test.ts +48 -0
- package/src/lint/post-synth/wgl040.ts +42 -0
- package/src/lint/post-synth/wgl041.test.ts +49 -0
- package/src/lint/post-synth/wgl041.ts +57 -0
- package/src/lint/post-synth/wgl042.test.ts +45 -0
- package/src/lint/post-synth/wgl042.ts +46 -0
- package/src/lint/post-synth/wgl043.test.ts +43 -0
- package/src/lint/post-synth/wgl043.ts +45 -0
- package/src/lint/post-synth/wgl044.test.ts +46 -0
- package/src/lint/post-synth/wgl044.ts +41 -0
- package/src/lint/post-synth/wgl045.test.ts +48 -0
- package/src/lint/post-synth/wgl045.ts +57 -0
- package/src/lint/post-synth/wgl046.test.ts +67 -0
- package/src/lint/post-synth/wgl046.ts +43 -0
- package/src/lint/post-synth/wgl047.test.ts +40 -0
- package/src/lint/post-synth/wgl047.ts +41 -0
- package/src/lint/post-synth/wgl048.test.ts +56 -0
- package/src/lint/post-synth/wgl048.ts +37 -0
- package/src/lint/post-synth/yaml-helpers.ts +214 -0
- package/src/lint/rules/data/known-include-sources.ts +41 -0
- package/src/lint/rules/data/untrusted-variables.ts +31 -0
- package/src/migrate/from-github/diagnostics.ts +18 -0
- package/src/migrate/from-github/index.ts +20 -1
- package/src/migrate/from-github/provenance.ts +28 -0
- package/src/migrate/from-github/rules.ts +7 -0
- package/src/migrate/from-github/security.test.ts +98 -0
- package/src/migrate/from-github/security.ts +208 -0
- package/src/plugin.test.ts +21 -1
- 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
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -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(
|
|
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:
|