@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.
- 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,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,56 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl048 } from "./wgl048";
|
|
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("WGL048: pipeline without a name", () => {
|
|
20
|
+
test("flags a workflow: block with no name", () => {
|
|
21
|
+
const yaml = `workflow:
|
|
22
|
+
rules:
|
|
23
|
+
- if: $CI_COMMIT_BRANCH
|
|
24
|
+
|
|
25
|
+
build:
|
|
26
|
+
script:
|
|
27
|
+
- make
|
|
28
|
+
`;
|
|
29
|
+
const diags = wgl048.check(makeCtx(yaml));
|
|
30
|
+
expect(diags).toHaveLength(1);
|
|
31
|
+
expect(diags[0].checkId).toBe("WGL048");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("does not flag a named workflow", () => {
|
|
35
|
+
const yaml = `workflow:
|
|
36
|
+
name: My Pipeline
|
|
37
|
+
rules:
|
|
38
|
+
- if: $CI_COMMIT_BRANCH
|
|
39
|
+
|
|
40
|
+
build:
|
|
41
|
+
script:
|
|
42
|
+
- make
|
|
43
|
+
`;
|
|
44
|
+
const diags = wgl048.check(makeCtx(yaml));
|
|
45
|
+
expect(diags).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("does not flag a pipeline with no workflow block", () => {
|
|
49
|
+
const yaml = `build:
|
|
50
|
+
script:
|
|
51
|
+
- make
|
|
52
|
+
`;
|
|
53
|
+
const diags = wgl048.check(makeCtx(yaml));
|
|
54
|
+
expect(diags).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored snapshot of well-known GitLab CI include/component source projects,
|
|
3
|
+
* used by the look-alike check (WGL032) as the reference set: an `include` or
|
|
4
|
+
* `component` source that is a near-miss (edit distance 1–2) of one of these —
|
|
5
|
+
* but not an exact match — is likely a typo or an impersonation.
|
|
6
|
+
*
|
|
7
|
+
* Advisory only. Refresh source: GitLab-maintained CI templates and the popular
|
|
8
|
+
* CI/CD Catalog components. Accept staleness — a stale entry only weakens
|
|
9
|
+
* look-alike detection, it never blocks a build.
|
|
10
|
+
*/
|
|
11
|
+
export const KNOWN_INCLUDE_SOURCES: readonly string[] = [
|
|
12
|
+
"gitlab-org/gitlab",
|
|
13
|
+
"gitlab-org/security-products/ci-templates",
|
|
14
|
+
"components/sast",
|
|
15
|
+
"components/secret-detection",
|
|
16
|
+
"components/dependency-scanning",
|
|
17
|
+
"components/container-scanning",
|
|
18
|
+
"components/code-quality",
|
|
19
|
+
"gitlab-org/components/sast",
|
|
20
|
+
"gitlab-org/components/secret-detection",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Levenshtein edit distance, capped: returns early once it exceeds `max`. */
|
|
24
|
+
export function editDistance(a: string, b: string, max: number): number {
|
|
25
|
+
if (a === b) return 0;
|
|
26
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
27
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
28
|
+
for (let i = 1; i <= a.length; i++) {
|
|
29
|
+
const curr = [i];
|
|
30
|
+
let rowMin = i;
|
|
31
|
+
for (let j = 1; j <= b.length; j++) {
|
|
32
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
33
|
+
const val = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
34
|
+
curr.push(val);
|
|
35
|
+
if (val < rowMin) rowMin = val;
|
|
36
|
+
}
|
|
37
|
+
if (rowMin > max) return max + 1;
|
|
38
|
+
prev = curr;
|
|
39
|
+
}
|
|
40
|
+
return prev[b.length];
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored snapshot of GitLab CI predefined variables whose values are
|
|
3
|
+
* attacker-controllable — branch/tag names, commit and merge-request titles and
|
|
4
|
+
* descriptions, author identity. Interpolating these into a `script:` (or using
|
|
5
|
+
* them as a gate) lets a crafted branch name or commit title influence what runs.
|
|
6
|
+
*
|
|
7
|
+
* Source: GitLab "Predefined CI/CD variables" reference, filtered to the values
|
|
8
|
+
* an outside contributor can set on a fork or merge request. Advisory and
|
|
9
|
+
* necessarily incomplete; editing this list is the refresh mechanism.
|
|
10
|
+
*/
|
|
11
|
+
export const UNTRUSTED_CI_VARIABLES: readonly string[] = [
|
|
12
|
+
"CI_COMMIT_TITLE",
|
|
13
|
+
"CI_COMMIT_MESSAGE",
|
|
14
|
+
"CI_COMMIT_DESCRIPTION",
|
|
15
|
+
"CI_COMMIT_REF_NAME",
|
|
16
|
+
"CI_COMMIT_REF_SLUG",
|
|
17
|
+
"CI_COMMIT_BRANCH",
|
|
18
|
+
"CI_COMMIT_TAG",
|
|
19
|
+
"CI_COMMIT_AUTHOR",
|
|
20
|
+
"CI_MERGE_REQUEST_TITLE",
|
|
21
|
+
"CI_MERGE_REQUEST_DESCRIPTION",
|
|
22
|
+
"CI_MERGE_REQUEST_SOURCE_BRANCH_NAME",
|
|
23
|
+
"CI_MERGE_REQUEST_SOURCE_BRANCH_SHA",
|
|
24
|
+
"CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Reference forms a variable can take in a script: `$VAR` or `${VAR}`. */
|
|
28
|
+
export function variableReferenced(text: string, name: string): boolean {
|
|
29
|
+
const re = new RegExp(`\\$\\{?${name}\\}?(?![A-Z0-9_])`);
|
|
30
|
+
return re.test(text);
|
|
31
|
+
}
|
|
@@ -22,6 +22,24 @@ export function provenanceToDiagnostics(
|
|
|
22
22
|
): LintDiagnostic[] {
|
|
23
23
|
const out: LintDiagnostic[] = [];
|
|
24
24
|
for (const r of records) {
|
|
25
|
+
// Security records always emit a diagnostic, at their own severity
|
|
26
|
+
// (escalated to error under --strict when the property was lost or needs
|
|
27
|
+
// review), independent of the functional category.
|
|
28
|
+
if (r.security) {
|
|
29
|
+
const sev =
|
|
30
|
+
opts.strict && (r.security.fate === "lost" || r.security.fate === "needs-review")
|
|
31
|
+
? "error"
|
|
32
|
+
: r.security.severity;
|
|
33
|
+
out.push({
|
|
34
|
+
file: r.sourceFile ?? "<input>",
|
|
35
|
+
line: r.sourceLine ?? 1,
|
|
36
|
+
column: r.sourceColumn ?? 1,
|
|
37
|
+
ruleId: r.rule,
|
|
38
|
+
severity: sev,
|
|
39
|
+
message: r.note ?? r.rule,
|
|
40
|
+
});
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
25
43
|
let severity: "error" | "warning" | "info" | undefined;
|
|
26
44
|
if (r.category === "needs-review") {
|
|
27
45
|
severity = opts.strict ? "error" : "warning";
|
|
@@ -12,6 +12,7 @@ import { ProvenanceAccumulator, type ProvenanceRecord } from "./provenance";
|
|
|
12
12
|
import { transformIR } from "./transformer";
|
|
13
13
|
import { emitGitlabYaml } from "./emit-yaml";
|
|
14
14
|
import { provenanceToDiagnostics } from "./diagnostics";
|
|
15
|
+
import { analyzeSecurity, runSecurityChecks, renderSecurityPosture } from "./security";
|
|
15
16
|
import { GitLabGenerator } from "../../import/generator";
|
|
16
17
|
import { applyComposites } from "./composites/rewriter";
|
|
17
18
|
import type { ActionMappingRegistry } from "./actions/registry";
|
|
@@ -31,6 +32,12 @@ export interface MigrateOptions {
|
|
|
31
32
|
registry?: ActionMappingRegistry;
|
|
32
33
|
/** Escalate needs-review diagnostics to errors. */
|
|
33
34
|
strict?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Security-aware migration (#306): classify each security property's fate
|
|
37
|
+
* across the edge and run the GitLab security post-synth checks against the
|
|
38
|
+
* migrated YAML. Enabled by the CLI's `--validate` path.
|
|
39
|
+
*/
|
|
40
|
+
security?: boolean;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export interface MigrationResult {
|
|
@@ -44,6 +51,8 @@ export interface MigrationResult {
|
|
|
44
51
|
diagnostics: LintDiagnostic[];
|
|
45
52
|
/** Inferred stage list. */
|
|
46
53
|
stages: string[];
|
|
54
|
+
/** Markdown "Security posture" section (#306). */
|
|
55
|
+
securityPosture: string;
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
/**
|
|
@@ -106,10 +115,20 @@ export async function transform(
|
|
|
106
115
|
output = emitGitlabYaml(ir);
|
|
107
116
|
}
|
|
108
117
|
|
|
118
|
+
// Security-aware migration (#306): classify each security property's fate
|
|
119
|
+
// across the edge, and run the GitLab security post-synth checks against the
|
|
120
|
+
// migrated YAML so weaknesses lost in translation surface on the target side.
|
|
121
|
+
if (opts.security) {
|
|
122
|
+
const yamlForSecurity = opts.emit === "ts" ? emitGitlabYaml(ir) : output;
|
|
123
|
+
provAcc.pushAll(analyzeSecurity(yamlContent, { sourceFile: opts.sourceFile }));
|
|
124
|
+
provAcc.pushAll(await runSecurityChecks(yamlForSecurity, { sourceFile: opts.sourceFile }));
|
|
125
|
+
}
|
|
126
|
+
|
|
109
127
|
const provenance = provAcc.all();
|
|
110
128
|
const diagnostics = provenanceToDiagnostics(provenance, { strict: opts.strict });
|
|
129
|
+
const securityPosture = renderSecurityPosture(provenance);
|
|
111
130
|
|
|
112
|
-
return { ir, output, provenance, diagnostics, stages };
|
|
131
|
+
return { ir, output, provenance, diagnostics, stages, securityPosture };
|
|
113
132
|
}
|
|
114
133
|
|
|
115
134
|
/**
|
|
@@ -37,6 +37,29 @@ export interface ProvenanceRecord {
|
|
|
37
37
|
actionRef?: string;
|
|
38
38
|
/** Tier 1/2/3 for action-map records */
|
|
39
39
|
mappingTier?: 1 | 2 | 3;
|
|
40
|
+
/** Security classification, when this record concerns a security property. */
|
|
41
|
+
security?: SecurityProvenance;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fate of a security property as it crosses the GitHub → GitLab boundary,
|
|
46
|
+
* mirroring the functional provenance categories.
|
|
47
|
+
*/
|
|
48
|
+
export type SecurityFate = "translated" | "approximated" | "needs-review" | "lost";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Security classification attached to a provenance record — tracks whether a
|
|
52
|
+
* security-relevant property survived migration, alongside the functional one.
|
|
53
|
+
*/
|
|
54
|
+
export interface SecurityProvenance {
|
|
55
|
+
/** Human label for the property (e.g. "Pinned action SHA"). */
|
|
56
|
+
property: string;
|
|
57
|
+
/** What happened to the property across the migration edge. */
|
|
58
|
+
fate: SecurityFate;
|
|
59
|
+
/** Diagnostic severity for this finding. */
|
|
60
|
+
severity: "error" | "warning" | "info";
|
|
61
|
+
/** Cross-reference to the endpoint that re-establishes the property. */
|
|
62
|
+
reestablish?: string;
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
/**
|
|
@@ -65,4 +88,9 @@ export class ProvenanceAccumulator {
|
|
|
65
88
|
needsReviewCount(): number {
|
|
66
89
|
return this.records.filter((r) => r.category === "needs-review").length;
|
|
67
90
|
}
|
|
91
|
+
|
|
92
|
+
/** Records carrying a security classification. */
|
|
93
|
+
securityRecords(): ProvenanceRecord[] {
|
|
94
|
+
return this.records.filter((r) => r.security !== undefined);
|
|
95
|
+
}
|
|
68
96
|
}
|
|
@@ -79,4 +79,11 @@ export const MIGRATION_RULES: LintRule[] = [
|
|
|
79
79
|
|
|
80
80
|
// Action mapping fallback
|
|
81
81
|
rule("MIG-ACTION-UNKNOWN", "warning", "Marketplace action has no registered mapping"),
|
|
82
|
+
|
|
83
|
+
// ── Security posture across the migration edge (#306) ──────────────────
|
|
84
|
+
// Each cross-references the endpoint issue that re-establishes the property.
|
|
85
|
+
rule("MIG-PIN-LOST", "warning", "A pinned action SHA could not be carried to the GitLab include/image — re-pin on the GitLab side (#297)"),
|
|
86
|
+
rule("MIG-SECRET-UNSCOPED", "warning", "Migrated secret reference assumes a masked + protected GitLab variable (#300)"),
|
|
87
|
+
rule("MIG-INJECTION-CARRIED", "warning", "An untrusted-input injection site was translated verbatim across the edge (#299)"),
|
|
88
|
+
rule("MIG-TRUST-BOUNDARY", "warning", "Trigger/fork semantics shifted the trust boundary (pull_request_target → MR/fork pipeline) (#299)"),
|
|
82
89
|
];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { analyzeSecurity, runSecurityChecks, renderSecurityPosture } from "./security";
|
|
3
|
+
import { transform } from "./index";
|
|
4
|
+
|
|
5
|
+
const WORKFLOW = `name: CI
|
|
6
|
+
on:
|
|
7
|
+
pull_request_target:
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
|
|
15
|
+
- run: echo "title is \${{ github.event.pull_request.title }}"
|
|
16
|
+
- run: deploy --token \${{ secrets.DEPLOY_TOKEN }}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
describe("analyzeSecurity (#306)", () => {
|
|
20
|
+
test("classifies each security property's fate", () => {
|
|
21
|
+
const records = analyzeSecurity(WORKFLOW, { sourceFile: "ci.yml" });
|
|
22
|
+
const byRule = new Map(records.map((r) => [r.rule, r]));
|
|
23
|
+
|
|
24
|
+
expect(byRule.get("MIG-PIN-LOST")?.security?.fate).toBe("lost");
|
|
25
|
+
expect(byRule.get("MIG-SECRET-UNSCOPED")?.security?.fate).toBe("needs-review");
|
|
26
|
+
expect(byRule.get("MIG-INJECTION-CARRIED")?.security?.fate).toBe("translated");
|
|
27
|
+
expect(byRule.get("MIG-TRUST-BOUNDARY")?.security?.fate).toBe("needs-review");
|
|
28
|
+
expect(byRule.get("MIG-PERMISSIONS-001")?.security?.fate).toBe("needs-review");
|
|
29
|
+
|
|
30
|
+
// each cross-references the endpoint that re-establishes the property
|
|
31
|
+
expect(byRule.get("MIG-PIN-LOST")?.security?.reestablish).toBe("#297");
|
|
32
|
+
expect(byRule.get("MIG-SECRET-UNSCOPED")?.security?.reestablish).toBe("#300");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not flag a clean workflow", () => {
|
|
36
|
+
const clean = `name: CI
|
|
37
|
+
on:
|
|
38
|
+
push:
|
|
39
|
+
jobs:
|
|
40
|
+
build:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
steps:
|
|
43
|
+
- run: echo build
|
|
44
|
+
`;
|
|
45
|
+
expect(analyzeSecurity(clean)).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("runSecurityChecks (#306)", () => {
|
|
50
|
+
test("runs the GitLab post-synth checks against migrated YAML", async () => {
|
|
51
|
+
// A migrated pipeline whose image is unpinned should trip WGL031.
|
|
52
|
+
const yaml = `stages:
|
|
53
|
+
- build
|
|
54
|
+
|
|
55
|
+
build:
|
|
56
|
+
stage: build
|
|
57
|
+
image:
|
|
58
|
+
name: node:20
|
|
59
|
+
script:
|
|
60
|
+
- npm ci
|
|
61
|
+
`;
|
|
62
|
+
const records = await runSecurityChecks(yaml);
|
|
63
|
+
expect(records.some((r) => r.rule === "WGL031")).toBe(true);
|
|
64
|
+
expect(records.every((r) => r.security !== undefined)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("renderSecurityPosture (#306)", () => {
|
|
69
|
+
test("renders a posture table", () => {
|
|
70
|
+
const records = analyzeSecurity(WORKFLOW, { sourceFile: "ci.yml" });
|
|
71
|
+
const md = renderSecurityPosture(records);
|
|
72
|
+
expect(md).toContain("## Security posture");
|
|
73
|
+
expect(md).toContain("MIG-PIN-LOST");
|
|
74
|
+
expect(md).toContain("lost");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("reports nothing for a clean workflow", () => {
|
|
78
|
+
expect(renderSecurityPosture([])).toContain("No security-relevant properties");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("transform with security: true (#306)", () => {
|
|
83
|
+
test("security findings flow into provenance + diagnostics + posture", async () => {
|
|
84
|
+
const result = await transform(WORKFLOW, { sourceFile: "ci.yml", security: true });
|
|
85
|
+
const ruleIds = new Set(result.diagnostics.map((d) => d.ruleId));
|
|
86
|
+
expect(ruleIds.has("MIG-PIN-LOST")).toBe(true);
|
|
87
|
+
expect(ruleIds.has("MIG-TRUST-BOUNDARY")).toBe(true);
|
|
88
|
+
expect(result.securityPosture).toContain("## Security posture");
|
|
89
|
+
expect(result.provenance.some((p) => p.security)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("security analysis is off by default", async () => {
|
|
93
|
+
const result = await transform(WORKFLOW, { sourceFile: "ci.yml" });
|
|
94
|
+
const ruleIds = new Set(result.diagnostics.map((d) => d.ruleId));
|
|
95
|
+
expect(ruleIds.has("MIG-PIN-LOST")).toBe(false);
|
|
96
|
+
expect(result.provenance.some((p) => p.security)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|