@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.
- package/dist/integrity.json +25 -5
- 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/dist/skills/chant-gitlab-patterns.md +15 -0
- package/package.json +1 -1
- package/src/codegen/docs.ts +251 -2
- package/src/composites/composites.test.ts +58 -0
- package/src/composites/index.ts +2 -0
- package/src/composites/mr-plan-report.ts +82 -0
- package/src/index.ts +2 -2
- 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/mcp/context-tools.test.ts +178 -0
- package/src/mcp/context-tools.ts +282 -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 +30 -1
- package/src/plugin.ts +4 -0
- package/src/skills/chant-gitlab-migrate.md +10 -0
- package/src/skills/chant-gitlab-patterns.md +15 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL030: Mutable or Insecure include:remote
|
|
3
|
+
*
|
|
4
|
+
* Flags `include:remote` URLs that are fetched over HTTP (no transport
|
|
5
|
+
* integrity) or over HTTPS but inherently mutable (a raw URL can be repointed
|
|
6
|
+
* at any time). Prefer `include:project` pinned to a tag/SHA, or a pinned
|
|
7
|
+
* component, over a remote URL. Generalizes WGL017 (non-HTTPS) to includes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractIncludes } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
export const wgl030: PostSynthCheck = {
|
|
14
|
+
id: "WGL030",
|
|
15
|
+
description: "include:remote is insecure (HTTP) or mutable",
|
|
16
|
+
|
|
17
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const [, output] of ctx.outputs) {
|
|
21
|
+
const yaml = getPrimaryOutput(output);
|
|
22
|
+
for (const entry of extractIncludes(yaml)) {
|
|
23
|
+
const isRemote = entry.kind === "remote" || (entry.kind === "string" && /^https?:\/\//.test(entry.value));
|
|
24
|
+
if (!isRemote) continue;
|
|
25
|
+
|
|
26
|
+
if (/^http:\/\//.test(entry.value)) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "WGL030",
|
|
29
|
+
severity: "error",
|
|
30
|
+
message: `include:remote "${entry.value}" is fetched over HTTP — an attacker on the network can swap the included config. Use HTTPS, and prefer a pinned include:project or component.`,
|
|
31
|
+
entity: entry.value,
|
|
32
|
+
lexicon: "gitlab",
|
|
33
|
+
});
|
|
34
|
+
} else {
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
checkId: "WGL030",
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `include:remote "${entry.value}" is a mutable URL — its contents can change after review. Prefer include:project pinned to a tag/SHA, or a pinned component.`,
|
|
39
|
+
entity: entry.value,
|
|
40
|
+
lexicon: "gitlab",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return diagnostics;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl031 } from "./wgl031";
|
|
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("WGL031: container image without digest", () => {
|
|
20
|
+
test("flags an image pinned to a tag", () => {
|
|
21
|
+
const yaml = `build-job:
|
|
22
|
+
image:
|
|
23
|
+
name: node:20
|
|
24
|
+
script:
|
|
25
|
+
- echo build
|
|
26
|
+
`;
|
|
27
|
+
const diags = wgl031.check(makeCtx(yaml));
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
expect(diags[0].checkId).toBe("WGL031");
|
|
30
|
+
expect(diags[0].message).toContain("node:20");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("flags a service image pinned to a tag", () => {
|
|
34
|
+
const yaml = `build-job:
|
|
35
|
+
services:
|
|
36
|
+
- name: postgres:16
|
|
37
|
+
script:
|
|
38
|
+
- echo build
|
|
39
|
+
`;
|
|
40
|
+
const diags = wgl031.check(makeCtx(yaml));
|
|
41
|
+
expect(diags).toHaveLength(1);
|
|
42
|
+
expect(diags[0].message).toContain("postgres:16");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("does not flag an image pinned to a digest", () => {
|
|
46
|
+
const yaml = `build-job:
|
|
47
|
+
image:
|
|
48
|
+
name: node@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
49
|
+
script:
|
|
50
|
+
- echo build
|
|
51
|
+
`;
|
|
52
|
+
const diags = wgl031.check(makeCtx(yaml));
|
|
53
|
+
expect(diags).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("does not flag a variable-based image reference", () => {
|
|
57
|
+
const yaml = `build-job:
|
|
58
|
+
image:
|
|
59
|
+
name: $CI_REGISTRY_IMAGE:latest
|
|
60
|
+
script:
|
|
61
|
+
- echo build
|
|
62
|
+
`;
|
|
63
|
+
const diags = wgl031.check(makeCtx(yaml));
|
|
64
|
+
expect(diags).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL031: Container Image Without an Immutable Digest
|
|
3
|
+
*
|
|
4
|
+
* Flags `image:` and `services:` references that are not pinned to an immutable
|
|
5
|
+
* `@sha256:` digest. A mutable tag can be repointed to a different image after
|
|
6
|
+
* review. Images built from a variable (e.g. `$CI_REGISTRY_IMAGE:tag`) are
|
|
7
|
+
* skipped — their concrete reference is not knowable from the static config.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractImageRefs } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
export const wgl031: PostSynthCheck = {
|
|
14
|
+
id: "WGL031",
|
|
15
|
+
description: "Container image not pinned to an immutable digest",
|
|
16
|
+
|
|
17
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const [, output] of ctx.outputs) {
|
|
21
|
+
const yaml = getPrimaryOutput(output);
|
|
22
|
+
for (const { job, image, source } of extractImageRefs(yaml)) {
|
|
23
|
+
if (image.includes("@sha256:")) continue;
|
|
24
|
+
if (image.includes("$")) continue; // variable-based reference — not statically knowable
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
checkId: "WGL031",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
message: `Job "${job}" ${source === "service" ? "service image" : "image"} "${image}" is not pinned to a digest — reference it by @sha256:... so the image cannot change after review.`,
|
|
29
|
+
entity: job,
|
|
30
|
+
lexicon: "gitlab",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return diagnostics;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl032, nearestKnownSource } from "./wgl032";
|
|
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("WGL032: look-alike include source", () => {
|
|
20
|
+
test("nearestKnownSource flags a near-miss", () => {
|
|
21
|
+
expect(nearestKnownSource("gitlab-org/gitlab")).toBeUndefined();
|
|
22
|
+
expect(nearestKnownSource("gitlab-org/gitlabb")).toBe("gitlab-org/gitlab");
|
|
23
|
+
expect(nearestKnownSource("totally/unrelated")).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("flags a typo-squatted include:project", () => {
|
|
27
|
+
const yaml = `include:
|
|
28
|
+
- project: gitlab-org/gitlabb
|
|
29
|
+
ref: v1.0.0
|
|
30
|
+
file: /x.yml
|
|
31
|
+
|
|
32
|
+
stages:
|
|
33
|
+
- build
|
|
34
|
+
`;
|
|
35
|
+
const diags = wgl032.check(makeCtx(yaml));
|
|
36
|
+
expect(diags).toHaveLength(1);
|
|
37
|
+
expect(diags[0].checkId).toBe("WGL032");
|
|
38
|
+
expect(diags[0].message).toContain("gitlab-org/gitlab");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("does not flag an exact known source", () => {
|
|
42
|
+
const yaml = `include:
|
|
43
|
+
- project: gitlab-org/gitlab
|
|
44
|
+
ref: v1.0.0
|
|
45
|
+
file: /x.yml
|
|
46
|
+
|
|
47
|
+
stages:
|
|
48
|
+
- build
|
|
49
|
+
`;
|
|
50
|
+
const diags = wgl032.check(makeCtx(yaml));
|
|
51
|
+
expect(diags).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL032: Include/Component Source Resembling a Well-Known Project
|
|
3
|
+
*
|
|
4
|
+
* Flags an `include:project` path or `component:` source that is a near-miss
|
|
5
|
+
* (edit distance 1–2) of a well-known GitLab CI source but not an exact match —
|
|
6
|
+
* a likely typo or an impersonation of the trusted project. Advisory; backed by
|
|
7
|
+
* a vendored reference list.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractIncludes } from "./yaml-helpers";
|
|
12
|
+
import { KNOWN_INCLUDE_SOURCES, editDistance } from "../rules/data/known-include-sources";
|
|
13
|
+
|
|
14
|
+
const KNOWN = new Set(KNOWN_INCLUDE_SOURCES);
|
|
15
|
+
|
|
16
|
+
/** Reduce a component address (`host/group/name@ver`) or project path to its group/name path. */
|
|
17
|
+
function sourcePath(value: string, kind: string): string {
|
|
18
|
+
let v = value;
|
|
19
|
+
if (kind === "component") {
|
|
20
|
+
const at = v.lastIndexOf("@");
|
|
21
|
+
if (at !== -1) v = v.slice(0, at);
|
|
22
|
+
const slash = v.indexOf("/");
|
|
23
|
+
if (slash !== -1 && v.slice(0, slash).includes(".")) v = v.slice(slash + 1); // drop host
|
|
24
|
+
}
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function nearestKnownSource(path: string): string | undefined {
|
|
29
|
+
if (KNOWN.has(path)) return undefined;
|
|
30
|
+
let best: string | undefined;
|
|
31
|
+
let bestDist = 3;
|
|
32
|
+
for (const known of KNOWN) {
|
|
33
|
+
const d = editDistance(path, known, 2);
|
|
34
|
+
if (d >= 1 && d <= 2 && d < bestDist) {
|
|
35
|
+
best = known;
|
|
36
|
+
bestDist = d;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return best;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const wgl032: PostSynthCheck = {
|
|
43
|
+
id: "WGL032",
|
|
44
|
+
description: "Include/component source resembles a well-known project (possible impersonation)",
|
|
45
|
+
|
|
46
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
47
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
48
|
+
|
|
49
|
+
for (const [, output] of ctx.outputs) {
|
|
50
|
+
const yaml = getPrimaryOutput(output);
|
|
51
|
+
for (const entry of extractIncludes(yaml)) {
|
|
52
|
+
if (entry.kind !== "project" && entry.kind !== "component") continue;
|
|
53
|
+
const path = sourcePath(entry.value, entry.kind);
|
|
54
|
+
const lookAlike = nearestKnownSource(path);
|
|
55
|
+
if (!lookAlike) continue;
|
|
56
|
+
diagnostics.push({
|
|
57
|
+
checkId: "WGL032",
|
|
58
|
+
severity: "warning",
|
|
59
|
+
message: `Include source "${path}" closely resembles the well-known "${lookAlike}". Confirm the source is who you intend — this may be a typo or impersonation.`,
|
|
60
|
+
entity: entry.value,
|
|
61
|
+
lexicon: "gitlab",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return diagnostics;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl033 } from "./wgl033";
|
|
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("WGL033: OIDC id_token audience scoping", () => {
|
|
20
|
+
test("flags a wildcard audience", () => {
|
|
21
|
+
const yaml = `deploy:
|
|
22
|
+
stage: deploy
|
|
23
|
+
id_tokens:
|
|
24
|
+
GCP_TOKEN:
|
|
25
|
+
aud: '*'
|
|
26
|
+
script:
|
|
27
|
+
- echo deploy
|
|
28
|
+
`;
|
|
29
|
+
const diags = wgl033.check(makeCtx(yaml));
|
|
30
|
+
expect(diags).toHaveLength(1);
|
|
31
|
+
expect(diags[0].checkId).toBe("WGL033");
|
|
32
|
+
expect(diags[0].entity).toBe("deploy");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("flags an empty audience", () => {
|
|
36
|
+
const yaml = `deploy:
|
|
37
|
+
id_tokens:
|
|
38
|
+
GCP_TOKEN:
|
|
39
|
+
aud: ''
|
|
40
|
+
script:
|
|
41
|
+
- echo deploy
|
|
42
|
+
`;
|
|
43
|
+
const diags = wgl033.check(makeCtx(yaml));
|
|
44
|
+
expect(diags).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("does not flag a scoped audience", () => {
|
|
48
|
+
const yaml = `deploy:
|
|
49
|
+
id_tokens:
|
|
50
|
+
GCP_TOKEN:
|
|
51
|
+
aud: https://iam.googleapis.com
|
|
52
|
+
script:
|
|
53
|
+
- echo deploy
|
|
54
|
+
`;
|
|
55
|
+
const diags = wgl033.check(makeCtx(yaml));
|
|
56
|
+
expect(diags).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL033: OIDC id_token Without a Scoped Audience
|
|
3
|
+
*
|
|
4
|
+
* Flags an `id_tokens:` declaration with no `aud:` or a wildcard audience. The
|
|
5
|
+
* audience binds the minted OIDC token to a specific relying party; without it
|
|
6
|
+
* (or with `*`), a leaked token is accepted anywhere, defeating the point of
|
|
7
|
+
* short-lived federated identity. Set `aud:` to the exact audience(s) needed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractIdTokens } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
export const wgl033: PostSynthCheck = {
|
|
14
|
+
id: "WGL033",
|
|
15
|
+
description: "OIDC id_token without a scoped audience",
|
|
16
|
+
|
|
17
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const [, output] of ctx.outputs) {
|
|
21
|
+
const yaml = getPrimaryOutput(output);
|
|
22
|
+
for (const { job, name, aud } of extractIdTokens(yaml)) {
|
|
23
|
+
const broad = aud.length === 0 || aud.some((a) => a === "*" || a === "" || a.includes("*"));
|
|
24
|
+
if (broad) {
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
checkId: "WGL033",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
message: `Job "${job}" id_token "${name}" has ${aud.length === 0 ? "no aud:" : "a wildcard audience"}. Scope it to the exact audience the relying party expects so a leaked token cannot be used elsewhere.`,
|
|
29
|
+
entity: job,
|
|
30
|
+
lexicon: "gitlab",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return diagnostics;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl034 } from "./wgl034";
|
|
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("WGL034: OIDC token mintable from a merge-request pipeline", () => {
|
|
20
|
+
test("flags an OIDC job reachable from merge requests", () => {
|
|
21
|
+
const yaml = `deploy:
|
|
22
|
+
rules:
|
|
23
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
24
|
+
id_tokens:
|
|
25
|
+
GCP_TOKEN:
|
|
26
|
+
aud: https://iam.googleapis.com
|
|
27
|
+
script:
|
|
28
|
+
- echo deploy
|
|
29
|
+
`;
|
|
30
|
+
const diags = wgl034.check(makeCtx(yaml));
|
|
31
|
+
expect(diags).toHaveLength(1);
|
|
32
|
+
expect(diags[0].checkId).toBe("WGL034");
|
|
33
|
+
expect(diags[0].entity).toBe("deploy");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("does not flag an OIDC job gated to a protected branch", () => {
|
|
37
|
+
const yaml = `deploy:
|
|
38
|
+
rules:
|
|
39
|
+
- if: $CI_COMMIT_BRANCH == "main"
|
|
40
|
+
id_tokens:
|
|
41
|
+
GCP_TOKEN:
|
|
42
|
+
aud: https://iam.googleapis.com
|
|
43
|
+
script:
|
|
44
|
+
- echo deploy
|
|
45
|
+
`;
|
|
46
|
+
const diags = wgl034.check(makeCtx(yaml));
|
|
47
|
+
expect(diags).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("does not flag a merge-request job without OIDC", () => {
|
|
51
|
+
const yaml = `test:
|
|
52
|
+
rules:
|
|
53
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
54
|
+
script:
|
|
55
|
+
- echo test
|
|
56
|
+
`;
|
|
57
|
+
const diags = wgl034.check(makeCtx(yaml));
|
|
58
|
+
expect(diags).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL034: OIDC id_token Mintable from a Merge-Request Pipeline
|
|
3
|
+
*
|
|
4
|
+
* Flags a job that declares an `id_tokens:` (OIDC) and is reachable from
|
|
5
|
+
* merge-request pipelines. MR pipelines can be triggered by outside
|
|
6
|
+
* contributors, so a job that mints a cloud-federated token there hands fork
|
|
7
|
+
* code a path to your cloud roles. Gate OIDC jobs to protected refs, or require
|
|
8
|
+
* approval before they run.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractIdTokens, extractJobSection, isMergeRequestReachable } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export const wgl034: PostSynthCheck = {
|
|
15
|
+
id: "WGL034",
|
|
16
|
+
description: "OIDC id_token mintable from a merge-request pipeline",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
for (const { job } of extractIdTokens(yaml)) {
|
|
25
|
+
if (seen.has(job)) continue;
|
|
26
|
+
const section = extractJobSection(yaml, job);
|
|
27
|
+
if (section && isMergeRequestReachable(section)) {
|
|
28
|
+
seen.add(job);
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "WGL034",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: `Job "${job}" mints an OIDC id_token and is reachable from merge-request pipelines, which outside contributors can trigger. Restrict it to protected refs or require approval before it runs.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "gitlab",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl035 } from "./wgl035";
|
|
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("WGL035: untrusted CI variable in script", () => {
|
|
20
|
+
test("flags an untrusted ref name used in a script command", () => {
|
|
21
|
+
const yaml = `build:
|
|
22
|
+
stage: build
|
|
23
|
+
script:
|
|
24
|
+
- git checkout $CI_COMMIT_REF_NAME
|
|
25
|
+
`;
|
|
26
|
+
const diags = wgl035.check(makeCtx(yaml));
|
|
27
|
+
expect(diags).toHaveLength(1);
|
|
28
|
+
expect(diags[0].checkId).toBe("WGL035");
|
|
29
|
+
expect(diags[0].entity).toBe("build");
|
|
30
|
+
expect(diags[0].message).toContain("CI_COMMIT_REF_NAME");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("does not flag a trusted variable", () => {
|
|
34
|
+
const yaml = `build:
|
|
35
|
+
stage: build
|
|
36
|
+
script:
|
|
37
|
+
- echo "$CI_COMMIT_SHA"
|
|
38
|
+
`;
|
|
39
|
+
const diags = wgl035.check(makeCtx(yaml));
|
|
40
|
+
expect(diags).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL035: Untrusted CI Variable Interpolated into a Script
|
|
3
|
+
*
|
|
4
|
+
* Flags an attacker-controllable predefined variable (branch/tag name, commit or
|
|
5
|
+
* merge-request title/description, author) referenced in a `script:` command. A
|
|
6
|
+
* crafted branch name or commit title containing shell metacharacters can break
|
|
7
|
+
* out of the command. Quote the reference and avoid using it in sensitive
|
|
8
|
+
* commands, or sanitize it first.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractScriptCommands } from "./yaml-helpers";
|
|
13
|
+
import { UNTRUSTED_CI_VARIABLES, variableReferenced } from "../rules/data/untrusted-variables";
|
|
14
|
+
|
|
15
|
+
export const wgl035: PostSynthCheck = {
|
|
16
|
+
id: "WGL035",
|
|
17
|
+
description: "Untrusted CI variable interpolated into a script command",
|
|
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
|
+
for (const name of UNTRUSTED_CI_VARIABLES) {
|
|
27
|
+
if (!variableReferenced(command, name)) continue;
|
|
28
|
+
const key = `${job}:${name}`;
|
|
29
|
+
if (seen.has(key)) continue;
|
|
30
|
+
seen.add(key);
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "WGL035",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
message: `Job "${job}" uses the attacker-controllable variable $${name} in a script command. A crafted value can inject shell commands — quote it ("$${name}") and avoid using it in sensitive commands, or sanitize it first.`,
|
|
35
|
+
entity: job,
|
|
36
|
+
lexicon: "gitlab",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl036 } from "./wgl036";
|
|
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("WGL036: privileged DinD reachable from merge requests", () => {
|
|
20
|
+
test("flags DinD in a merge-request-reachable job", () => {
|
|
21
|
+
const yaml = `build:
|
|
22
|
+
rules:
|
|
23
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
24
|
+
image: docker:24
|
|
25
|
+
services:
|
|
26
|
+
- docker:24-dind
|
|
27
|
+
script:
|
|
28
|
+
- docker build .
|
|
29
|
+
`;
|
|
30
|
+
const diags = wgl036.check(makeCtx(yaml));
|
|
31
|
+
expect(diags).toHaveLength(1);
|
|
32
|
+
expect(diags[0].checkId).toBe("WGL036");
|
|
33
|
+
expect(diags[0].entity).toBe("build");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("does not flag DinD gated to a protected branch", () => {
|
|
37
|
+
const yaml = `build:
|
|
38
|
+
rules:
|
|
39
|
+
- if: $CI_COMMIT_BRANCH == "main"
|
|
40
|
+
image: docker:24
|
|
41
|
+
services:
|
|
42
|
+
- docker:24-dind
|
|
43
|
+
script:
|
|
44
|
+
- docker build .
|
|
45
|
+
`;
|
|
46
|
+
const diags = wgl036.check(makeCtx(yaml));
|
|
47
|
+
expect(diags).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL036: Privileged Service / Docker-in-Docker Reachable from Merge Requests
|
|
3
|
+
*
|
|
4
|
+
* Flags a job that uses Docker-in-Docker (a privileged service) and is reachable
|
|
5
|
+
* from merge-request pipelines. MR pipelines can run code from an outside
|
|
6
|
+
* contributor; a privileged DinD service shares the host daemon socket and can
|
|
7
|
+
* be used to escape the build and reach the runner. Restrict privileged services
|
|
8
|
+
* to protected refs, or require approval before such jobs run.
|
|
9
|
+
*
|
|
10
|
+
* Complements WGL026 (DinD without TLS) by adding the trust-boundary dimension.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
14
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, isMergeRequestReachable } from "./yaml-helpers";
|
|
15
|
+
|
|
16
|
+
const DIND_RE = /docker:[\w.-]*dind|dind|privileged:\s*true/;
|
|
17
|
+
|
|
18
|
+
export const wgl036: PostSynthCheck = {
|
|
19
|
+
id: "WGL036",
|
|
20
|
+
description: "Privileged service / DinD reachable from merge-request pipelines",
|
|
21
|
+
|
|
22
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
23
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
24
|
+
|
|
25
|
+
for (const [, output] of ctx.outputs) {
|
|
26
|
+
const yaml = getPrimaryOutput(output);
|
|
27
|
+
for (const [job] of extractJobs(yaml)) {
|
|
28
|
+
const section = extractJobSection(yaml, job);
|
|
29
|
+
if (!section) continue;
|
|
30
|
+
if (DIND_RE.test(section) && isMergeRequestReachable(section)) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "WGL036",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
message: `Job "${job}" runs a privileged Docker-in-Docker service and is reachable from merge-request pipelines, which outside contributors can trigger. Restrict privileged services to protected refs or require approval.`,
|
|
35
|
+
entity: job,
|
|
36
|
+
lexicon: "gitlab",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diagnostics;
|
|
43
|
+
},
|
|
44
|
+
};
|