@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,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL040: Hardcoded Credential in a Registry Login
|
|
3
|
+
*
|
|
4
|
+
* Flags a `docker login` (or compatible) in a `script:` that passes a literal
|
|
5
|
+
* password via `-p` / `--password` instead of a variable or `--password-stdin`.
|
|
6
|
+
* An inlined credential sits in the committed pipeline and the job log. Use a
|
|
7
|
+
* masked CI/CD variable and `--password-stdin`. Extends WGL016 to scripts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractScriptCommands } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
// docker/podman/buildah/helm login with -p/--password followed by a non-variable literal
|
|
14
|
+
const LITERAL_LOGIN = /\b(?:docker|podman|buildah|helm|crane|skopeo)\b[^\n]*\blogin\b[^\n]*(?:-p|--password)[=\s]+(?!["']?\$)["']?[^\s"']+/i;
|
|
15
|
+
|
|
16
|
+
export const wgl040: PostSynthCheck = {
|
|
17
|
+
id: "WGL040",
|
|
18
|
+
description: "Hardcoded credential in a registry login command",
|
|
19
|
+
|
|
20
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
21
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [, output] of ctx.outputs) {
|
|
24
|
+
const yaml = getPrimaryOutput(output);
|
|
25
|
+
const seen = new Set<string>();
|
|
26
|
+
for (const { job, command } of extractScriptCommands(yaml)) {
|
|
27
|
+
if (LITERAL_LOGIN.test(command) && !seen.has(job)) {
|
|
28
|
+
seen.add(job);
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "WGL040",
|
|
31
|
+
severity: "error",
|
|
32
|
+
message: `Job "${job}" passes a hardcoded password to a registry login. Use a masked CI/CD variable with --password-stdin instead of inlining the credential.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "gitlab",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return diagnostics;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl041, tautologyReason } from "./wgl041";
|
|
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("WGL041: unsound rules:if", () => {
|
|
20
|
+
test("tautologyReason classifies identical operands", () => {
|
|
21
|
+
expect(tautologyReason('$CI_COMMIT_BRANCH == $CI_COMMIT_BRANCH')).toContain("always true");
|
|
22
|
+
expect(tautologyReason('$X != $X')).toContain("always false");
|
|
23
|
+
expect(tautologyReason('$CI_COMMIT_BRANCH == "main"')).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("flags a tautological rule condition", () => {
|
|
27
|
+
const yaml = `deploy:
|
|
28
|
+
rules:
|
|
29
|
+
- if: $CI_COMMIT_BRANCH == $CI_COMMIT_BRANCH
|
|
30
|
+
script:
|
|
31
|
+
- echo deploy
|
|
32
|
+
`;
|
|
33
|
+
const diags = wgl041.check(makeCtx(yaml));
|
|
34
|
+
expect(diags).toHaveLength(1);
|
|
35
|
+
expect(diags[0].checkId).toBe("WGL041");
|
|
36
|
+
expect(diags[0].entity).toBe("deploy");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("does not flag a real condition", () => {
|
|
40
|
+
const yaml = `deploy:
|
|
41
|
+
rules:
|
|
42
|
+
- if: $CI_COMMIT_BRANCH == "main"
|
|
43
|
+
script:
|
|
44
|
+
- echo deploy
|
|
45
|
+
`;
|
|
46
|
+
const diags = wgl041.check(makeCtx(yaml));
|
|
47
|
+
expect(diags).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL041: Logically Unsound rules:if Condition
|
|
3
|
+
*
|
|
4
|
+
* Flags a `rules:if` that reads like a gate but is a constant — both sides of an
|
|
5
|
+
* `==`/`!=` are identical, so the condition is always true or always false.
|
|
6
|
+
* Generalizes WGL011 (rules that always evaluate to never) to tautological
|
|
7
|
+
* conditions presented as gates.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, extractJobRules } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
function strip(s: string): string {
|
|
14
|
+
return s.trim().replace(/^['"]|['"]$/g, "").trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Classify an `if:` expression as a tautology, returning a reason or undefined. */
|
|
18
|
+
export function tautologyReason(expr: string): string | undefined {
|
|
19
|
+
const e = strip(expr).replace(/^\$\{?/, "$").trim();
|
|
20
|
+
const eq = e.match(/^(\S.*?)\s*==\s*(\S.*?)$/);
|
|
21
|
+
if (eq && strip(eq[1]) === strip(eq[2])) return "always true (both sides of == are identical)";
|
|
22
|
+
const ne = e.match(/^(\S.*?)\s*!=\s*(\S.*?)$/);
|
|
23
|
+
if (ne && strip(ne[1]) === strip(ne[2])) return "always false (both sides of != are identical)";
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const wgl041: PostSynthCheck = {
|
|
28
|
+
id: "WGL041",
|
|
29
|
+
description: "Logically unsound (tautological) rules:if condition",
|
|
30
|
+
|
|
31
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
32
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
33
|
+
|
|
34
|
+
for (const [, output] of ctx.outputs) {
|
|
35
|
+
const yaml = getPrimaryOutput(output);
|
|
36
|
+
for (const [job] of extractJobs(yaml)) {
|
|
37
|
+
const section = extractJobSection(yaml, job);
|
|
38
|
+
if (!section) continue;
|
|
39
|
+
for (const rule of extractJobRules(section)) {
|
|
40
|
+
if (!rule.if) continue;
|
|
41
|
+
const reason = tautologyReason(rule.if);
|
|
42
|
+
if (reason) {
|
|
43
|
+
diagnostics.push({
|
|
44
|
+
checkId: "WGL041",
|
|
45
|
+
severity: "warning",
|
|
46
|
+
message: `Job "${job}" has a rules:if that is ${reason}: "${rule.if}". A gate that does not constrain anything is misleading — write the real condition or remove it.`,
|
|
47
|
+
entity: job,
|
|
48
|
+
lexicon: "gitlab",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return diagnostics;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl042 } from "./wgl042";
|
|
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("WGL042: unreachable rules after unconditional match", () => {
|
|
20
|
+
test("flags a rule after an unconditional catch-all", () => {
|
|
21
|
+
const yaml = `deploy:
|
|
22
|
+
rules:
|
|
23
|
+
- when: always
|
|
24
|
+
- if: $CI_COMMIT_BRANCH == "main"
|
|
25
|
+
script:
|
|
26
|
+
- echo deploy
|
|
27
|
+
`;
|
|
28
|
+
const diags = wgl042.check(makeCtx(yaml));
|
|
29
|
+
expect(diags).toHaveLength(1);
|
|
30
|
+
expect(diags[0].checkId).toBe("WGL042");
|
|
31
|
+
expect(diags[0].entity).toBe("deploy");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("does not flag a trailing when: never default", () => {
|
|
35
|
+
const yaml = `deploy:
|
|
36
|
+
rules:
|
|
37
|
+
- if: $CI_COMMIT_BRANCH == "main"
|
|
38
|
+
- when: never
|
|
39
|
+
script:
|
|
40
|
+
- echo deploy
|
|
41
|
+
`;
|
|
42
|
+
const diags = wgl042.check(makeCtx(yaml));
|
|
43
|
+
expect(diags).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL042: Unreachable rules After an Unconditional Match
|
|
3
|
+
*
|
|
4
|
+
* GitLab evaluates `rules:` top-down and the first match wins. A rule with no
|
|
5
|
+
* `if:` (and not `when: never`) matches every pipeline, so any rule listed after
|
|
6
|
+
* it is dead — it can never be reached. Such dead rules usually mean the gate
|
|
7
|
+
* the author intended is not actually applied. Reorder so specific rules precede
|
|
8
|
+
* the catch-all.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, extractJobRules } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export const wgl042: PostSynthCheck = {
|
|
15
|
+
id: "WGL042",
|
|
16
|
+
description: "Unreachable rules after an unconditional match",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
for (const [job] of extractJobs(yaml)) {
|
|
24
|
+
const section = extractJobSection(yaml, job);
|
|
25
|
+
if (!section) continue;
|
|
26
|
+
const rules = extractJobRules(section);
|
|
27
|
+
for (let i = 0; i < rules.length - 1; i++) {
|
|
28
|
+
const r = rules[i];
|
|
29
|
+
const unconditional = !r.if && r.when !== "never";
|
|
30
|
+
if (unconditional) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "WGL042",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
message: `Job "${job}" has an unconditional rule at position ${i + 1} that matches every pipeline, making the ${rules.length - i - 1} rule(s) after it unreachable. Put specific rules before the catch-all.`,
|
|
35
|
+
entity: job,
|
|
36
|
+
lexicon: "gitlab",
|
|
37
|
+
});
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return diagnostics;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl043 } from "./wgl043";
|
|
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("WGL043: match-anything regex gate", () => {
|
|
20
|
+
test("flags a =~ /.*/ gate", () => {
|
|
21
|
+
const yaml = `deploy:
|
|
22
|
+
rules:
|
|
23
|
+
- if: $CI_COMMIT_REF_NAME =~ /.*/
|
|
24
|
+
script:
|
|
25
|
+
- echo deploy
|
|
26
|
+
`;
|
|
27
|
+
const diags = wgl043.check(makeCtx(yaml));
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
expect(diags[0].checkId).toBe("WGL043");
|
|
30
|
+
expect(diags[0].entity).toBe("deploy");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("does not flag a specific regex", () => {
|
|
34
|
+
const yaml = `deploy:
|
|
35
|
+
rules:
|
|
36
|
+
- if: $CI_COMMIT_REF_NAME =~ /^release/
|
|
37
|
+
script:
|
|
38
|
+
- echo deploy
|
|
39
|
+
`;
|
|
40
|
+
const diags = wgl043.check(makeCtx(yaml));
|
|
41
|
+
expect(diags).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL043: Match-Anything Regex Gate
|
|
3
|
+
*
|
|
4
|
+
* Flags a `rules:if` whose regex match (`=~`) uses a pattern that matches every
|
|
5
|
+
* value — an empty regex, dot-star, or an anchored dot-star. The condition reads
|
|
6
|
+
* like a filter on a ref or variable but admits everything, so it gates nothing.
|
|
7
|
+
* This is how an unsound condition is obscured behind a regex. Tighten the
|
|
8
|
+
* pattern or remove the gate.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, extractJobRules } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
// `=~` followed by a regex literal that matches anything.
|
|
15
|
+
const MATCH_ANYTHING = /=~\s*\/(?:|\.\*|\^\.\*\$|\^\.\*|\.\*\$)\//;
|
|
16
|
+
|
|
17
|
+
export const wgl043: PostSynthCheck = {
|
|
18
|
+
id: "WGL043",
|
|
19
|
+
description: "Match-anything regex gate in rules:if",
|
|
20
|
+
|
|
21
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
|
|
24
|
+
for (const [, output] of ctx.outputs) {
|
|
25
|
+
const yaml = getPrimaryOutput(output);
|
|
26
|
+
for (const [job] of extractJobs(yaml)) {
|
|
27
|
+
const section = extractJobSection(yaml, job);
|
|
28
|
+
if (!section) continue;
|
|
29
|
+
for (const rule of extractJobRules(section)) {
|
|
30
|
+
if (rule.if && MATCH_ANYTHING.test(rule.if)) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "WGL043",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
message: `Job "${job}" gates on a regex that matches everything ("${rule.if}") — the rule reads like a filter but admits every value. Tighten the pattern or remove the gate.`,
|
|
35
|
+
entity: job,
|
|
36
|
+
lexicon: "gitlab",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return diagnostics;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl044 } from "./wgl044";
|
|
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("WGL044: public artifacts", () => {
|
|
20
|
+
test("flags artifacts:public: true", () => {
|
|
21
|
+
const yaml = `build:
|
|
22
|
+
artifacts:
|
|
23
|
+
public: true
|
|
24
|
+
paths:
|
|
25
|
+
- dist/
|
|
26
|
+
script:
|
|
27
|
+
- make
|
|
28
|
+
`;
|
|
29
|
+
const diags = wgl044.check(makeCtx(yaml));
|
|
30
|
+
expect(diags).toHaveLength(1);
|
|
31
|
+
expect(diags[0].checkId).toBe("WGL044");
|
|
32
|
+
expect(diags[0].entity).toBe("build");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not flag private artifacts", () => {
|
|
36
|
+
const yaml = `build:
|
|
37
|
+
artifacts:
|
|
38
|
+
paths:
|
|
39
|
+
- dist/
|
|
40
|
+
script:
|
|
41
|
+
- make
|
|
42
|
+
`;
|
|
43
|
+
const diags = wgl044.check(makeCtx(yaml));
|
|
44
|
+
expect(diags).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL044: Public Artifacts
|
|
3
|
+
*
|
|
4
|
+
* Flags `artifacts:public: true`. Public artifacts are downloadable by anyone,
|
|
5
|
+
* including unauthenticated users for public projects, exposing build output and
|
|
6
|
+
* anything swept into it. Keep artifacts private unless they are deliberately
|
|
7
|
+
* meant to be world-readable.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
const PUBLIC_ARTIFACTS = /^\s+public:\s*true\s*$/m;
|
|
14
|
+
|
|
15
|
+
export const wgl044: PostSynthCheck = {
|
|
16
|
+
id: "WGL044",
|
|
17
|
+
description: "Public artifacts expose build output",
|
|
18
|
+
|
|
19
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
20
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
for (const [, output] of ctx.outputs) {
|
|
23
|
+
const yaml = getPrimaryOutput(output);
|
|
24
|
+
for (const [job] of extractJobs(yaml)) {
|
|
25
|
+
const section = extractJobSection(yaml, job);
|
|
26
|
+
if (!section || !/^\s+artifacts:/m.test(section)) continue;
|
|
27
|
+
if (PUBLIC_ARTIFACTS.test(section)) {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "WGL044",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
message: `Job "${job}" publishes public artifacts (artifacts:public: true), downloadable by anyone. Keep them private unless they are meant to be world-readable.`,
|
|
32
|
+
entity: job,
|
|
33
|
+
lexicon: "gitlab",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl045 } from "./wgl045";
|
|
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("WGL045: credential-bearing artifact path", () => {
|
|
20
|
+
test("flags an artifact path that captures .env", () => {
|
|
21
|
+
const yaml = `build:
|
|
22
|
+
artifacts:
|
|
23
|
+
paths:
|
|
24
|
+
- dist/
|
|
25
|
+
- .env
|
|
26
|
+
script:
|
|
27
|
+
- make
|
|
28
|
+
`;
|
|
29
|
+
const diags = wgl045.check(makeCtx(yaml));
|
|
30
|
+
expect(diags).toHaveLength(1);
|
|
31
|
+
expect(diags[0].checkId).toBe("WGL045");
|
|
32
|
+
expect(diags[0].severity).toBe("error");
|
|
33
|
+
expect(diags[0].message).toContain(".env");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("does not flag ordinary build output paths", () => {
|
|
37
|
+
const yaml = `build:
|
|
38
|
+
artifacts:
|
|
39
|
+
paths:
|
|
40
|
+
- dist/
|
|
41
|
+
- build/output.js
|
|
42
|
+
script:
|
|
43
|
+
- make
|
|
44
|
+
`;
|
|
45
|
+
const diags = wgl045.check(makeCtx(yaml));
|
|
46
|
+
expect(diags).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL045: Credential-Bearing Artifact Path
|
|
3
|
+
*
|
|
4
|
+
* Flags an `artifacts:paths:` entry that looks like it would sweep a credential
|
|
5
|
+
* or sensitive file into the artifact (`.env`, `*.pem`, `id_rsa`, `*.key`,
|
|
6
|
+
* `.npmrc`, `.netrc`, `credentials`). Artifacts flow to later stages and are
|
|
7
|
+
* downloadable, so a captured credential leaks across the boundary. Exclude the
|
|
8
|
+
* sensitive file from the artifact path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
const CREDENTIAL_PATH = /(^|\/)(\.env(\.|$)|\.npmrc|\.netrc|id_rsa|.*\.pem|.*\.key|.*\.p12|credentials?(\.|$))/i;
|
|
15
|
+
|
|
16
|
+
export const wgl045: PostSynthCheck = {
|
|
17
|
+
id: "WGL045",
|
|
18
|
+
description: "Artifact path that may capture a credential file",
|
|
19
|
+
|
|
20
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
21
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [, output] of ctx.outputs) {
|
|
24
|
+
const yaml = getPrimaryOutput(output);
|
|
25
|
+
for (const [job] of extractJobs(yaml)) {
|
|
26
|
+
const section = extractJobSection(yaml, job);
|
|
27
|
+
if (!section) continue;
|
|
28
|
+
// Collect the lines under the artifacts: block (deeper indent than the key).
|
|
29
|
+
const lines = section.split("\n");
|
|
30
|
+
const artifactsIdx = lines.findIndex((l) => /^\s+artifacts:\s*$/.test(l));
|
|
31
|
+
if (artifactsIdx === -1) continue;
|
|
32
|
+
const artifactsIndent = lines[artifactsIdx].search(/\S/);
|
|
33
|
+
const blockLines: string[] = [];
|
|
34
|
+
for (let i = artifactsIdx + 1; i < lines.length; i++) {
|
|
35
|
+
if (lines[i].trim() === "") continue;
|
|
36
|
+
if (lines[i].search(/\S/) <= artifactsIndent) break;
|
|
37
|
+
blockLines.push(lines[i]);
|
|
38
|
+
}
|
|
39
|
+
const hit = blockLines
|
|
40
|
+
.filter((l) => /^\s+-\s+/.test(l))
|
|
41
|
+
.map((l) => l.replace(/^\s+-\s+/, "").trim().replace(/^['"]|['"]$/g, ""))
|
|
42
|
+
.find((p) => CREDENTIAL_PATH.test(p));
|
|
43
|
+
if (hit) {
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
checkId: "WGL045",
|
|
46
|
+
severity: "error",
|
|
47
|
+
message: `Job "${job}" includes a credential-like file ("${hit}") in its artifacts. Exclude it — artifacts are passed downstream and are downloadable.`,
|
|
48
|
+
entity: job,
|
|
49
|
+
lexicon: "gitlab",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return diagnostics;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl046 } from "./wgl046";
|
|
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("WGL046: cache populated in a merge-request pipeline", () => {
|
|
20
|
+
test("flags a cache write reachable from merge requests", () => {
|
|
21
|
+
const yaml = `build:
|
|
22
|
+
rules:
|
|
23
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
24
|
+
cache:
|
|
25
|
+
key: build-cache
|
|
26
|
+
paths:
|
|
27
|
+
- node_modules/
|
|
28
|
+
script:
|
|
29
|
+
- npm ci
|
|
30
|
+
`;
|
|
31
|
+
const diags = wgl046.check(makeCtx(yaml));
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
expect(diags[0].checkId).toBe("WGL046");
|
|
34
|
+
expect(diags[0].entity).toBe("build");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not flag a pull-only cache", () => {
|
|
38
|
+
const yaml = `build:
|
|
39
|
+
rules:
|
|
40
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
41
|
+
cache:
|
|
42
|
+
key: build-cache
|
|
43
|
+
paths:
|
|
44
|
+
- node_modules/
|
|
45
|
+
policy: pull
|
|
46
|
+
script:
|
|
47
|
+
- npm ci
|
|
48
|
+
`;
|
|
49
|
+
const diags = wgl046.check(makeCtx(yaml));
|
|
50
|
+
expect(diags).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("does not flag a cache on a protected-only job", () => {
|
|
54
|
+
const yaml = `build:
|
|
55
|
+
rules:
|
|
56
|
+
- if: $CI_COMMIT_BRANCH == "main"
|
|
57
|
+
cache:
|
|
58
|
+
key: build-cache
|
|
59
|
+
paths:
|
|
60
|
+
- node_modules/
|
|
61
|
+
script:
|
|
62
|
+
- npm ci
|
|
63
|
+
`;
|
|
64
|
+
const diags = wgl046.check(makeCtx(yaml));
|
|
65
|
+
expect(diags).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL046: Cache Populated in a Merge-Request Pipeline (Poisoning Risk)
|
|
3
|
+
*
|
|
4
|
+
* Flags a job that writes a cache (`cache:` with a push policy) and is reachable
|
|
5
|
+
* from merge-request pipelines. An outside contributor's MR can populate the
|
|
6
|
+
* cache; a later protected run that restores the same key then executes
|
|
7
|
+
* attacker-influenced contents. Restrict cache writes to protected refs, or
|
|
8
|
+
* scope cache keys so MR pipelines cannot write entries a protected run reads.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, isMergeRequestReachable } from "./yaml-helpers";
|
|
13
|
+
|
|
14
|
+
export const wgl046: PostSynthCheck = {
|
|
15
|
+
id: "WGL046",
|
|
16
|
+
description: "Cache populated in a merge-request pipeline (poisoning risk)",
|
|
17
|
+
|
|
18
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
19
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
20
|
+
|
|
21
|
+
for (const [, output] of ctx.outputs) {
|
|
22
|
+
const yaml = getPrimaryOutput(output);
|
|
23
|
+
for (const [job] of extractJobs(yaml)) {
|
|
24
|
+
const section = extractJobSection(yaml, job);
|
|
25
|
+
if (!section || !/^\s+cache:/m.test(section)) continue;
|
|
26
|
+
if (!isMergeRequestReachable(section)) continue;
|
|
27
|
+
// pull-only caches cannot poison; flag push/pull-push (the default when policy is absent)
|
|
28
|
+
const policyMatch = section.match(/^\s+policy:\s*(\S+)/m);
|
|
29
|
+
const policy = policyMatch ? policyMatch[1].replace(/^['"]|['"]$/g, "") : "pull-push";
|
|
30
|
+
if (policy === "pull") continue;
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "WGL046",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
message: `Job "${job}" writes a cache and is reachable from merge-request pipelines. An MR can poison the cache for a later protected run that restores the same key — restrict cache writes to protected refs or scope the key.`,
|
|
35
|
+
entity: job,
|
|
36
|
+
lexicon: "gitlab",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return diagnostics;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl047 } from "./wgl047";
|
|
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("WGL047: curl | bash runtime execution", () => {
|
|
20
|
+
test("flags curl piped to bash", () => {
|
|
21
|
+
const yaml = `build:
|
|
22
|
+
script:
|
|
23
|
+
- curl -sSL https://example.com/install.sh | bash
|
|
24
|
+
`;
|
|
25
|
+
const diags = wgl047.check(makeCtx(yaml));
|
|
26
|
+
expect(diags).toHaveLength(1);
|
|
27
|
+
expect(diags[0].checkId).toBe("WGL047");
|
|
28
|
+
expect(diags[0].entity).toBe("build");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("does not flag download-then-run on separate lines", () => {
|
|
32
|
+
const yaml = `build:
|
|
33
|
+
script:
|
|
34
|
+
- curl -sSLo install.sh https://example.com/install.sh
|
|
35
|
+
- bash install.sh
|
|
36
|
+
`;
|
|
37
|
+
const diags = wgl047.check(makeCtx(yaml));
|
|
38
|
+
expect(diags).toHaveLength(0);
|
|
39
|
+
});
|
|
40
|
+
});
|