@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,45 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl037 } from "./wgl037";
|
|
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("WGL037: regex gate on untrusted ref", () => {
|
|
20
|
+
test("flags a =~ gate on the ref name", () => {
|
|
21
|
+
const yaml = `deploy:
|
|
22
|
+
stage: deploy
|
|
23
|
+
rules:
|
|
24
|
+
- if: $CI_COMMIT_REF_NAME =~ /^release/
|
|
25
|
+
script:
|
|
26
|
+
- ./deploy.sh
|
|
27
|
+
`;
|
|
28
|
+
const diags = wgl037.check(makeCtx(yaml));
|
|
29
|
+
expect(diags).toHaveLength(1);
|
|
30
|
+
expect(diags[0].checkId).toBe("WGL037");
|
|
31
|
+
expect(diags[0].entity).toBe("deploy");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("does not flag an exact == match", () => {
|
|
35
|
+
const yaml = `deploy:
|
|
36
|
+
stage: deploy
|
|
37
|
+
rules:
|
|
38
|
+
- if: $CI_COMMIT_REF_NAME == "main"
|
|
39
|
+
script:
|
|
40
|
+
- ./deploy.sh
|
|
41
|
+
`;
|
|
42
|
+
const diags = wgl037.check(makeCtx(yaml));
|
|
43
|
+
expect(diags).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL037: Security Gate on a Regex Match of an Untrusted Ref
|
|
3
|
+
*
|
|
4
|
+
* Flags a `rules:if` that gates execution on a regex match (`=~`) over an
|
|
5
|
+
* attacker-controllable ref variable (e.g. `$CI_COMMIT_REF_NAME =~ /^release/`).
|
|
6
|
+
* An outside contributor can craft a branch name that satisfies the pattern
|
|
7
|
+
* (`release-evil`), passing a gate meant for trusted refs. Match the full ref
|
|
8
|
+
* with `==`, or gate on a protected condition instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers";
|
|
13
|
+
import { UNTRUSTED_CI_VARIABLES } from "../rules/data/untrusted-variables";
|
|
14
|
+
|
|
15
|
+
const REF_VARS = ["CI_COMMIT_REF_NAME", "CI_COMMIT_REF_SLUG", "CI_COMMIT_BRANCH", "CI_COMMIT_TAG", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]
|
|
16
|
+
.filter((v) => UNTRUSTED_CI_VARIABLES.includes(v));
|
|
17
|
+
|
|
18
|
+
export const wgl037: PostSynthCheck = {
|
|
19
|
+
id: "WGL037",
|
|
20
|
+
description: "Security gate on a regex match of an untrusted ref variable",
|
|
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
|
+
// Look at `if:` lines that use a regex match against an untrusted ref var.
|
|
31
|
+
const ifLines = section.split("\n").filter((l) => /if:\s/.test(l));
|
|
32
|
+
const flagged = ifLines.some((l) => /=~/.test(l) && REF_VARS.some((v) => l.includes(`$${v}`)));
|
|
33
|
+
if (flagged) {
|
|
34
|
+
diagnostics.push({
|
|
35
|
+
checkId: "WGL037",
|
|
36
|
+
severity: "warning",
|
|
37
|
+
message: `Job "${job}" gates on a regex match (=~) of an attacker-controllable ref variable. A crafted branch/tag name can satisfy the pattern — match the full ref with == or gate on a protected condition.`,
|
|
38
|
+
entity: job,
|
|
39
|
+
lexicon: "gitlab",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return diagnostics;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl038 } from "./wgl038";
|
|
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("WGL038: secret reachable from merge requests", () => {
|
|
20
|
+
test("flags a user secret used in a merge-request-reachable job", () => {
|
|
21
|
+
const yaml = `deploy:
|
|
22
|
+
rules:
|
|
23
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
24
|
+
script:
|
|
25
|
+
- curl -H "Auth: $DEPLOY_TOKEN" https://api.example.com
|
|
26
|
+
`;
|
|
27
|
+
const diags = wgl038.check(makeCtx(yaml));
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
expect(diags[0].checkId).toBe("WGL038");
|
|
30
|
+
expect(diags[0].message).toContain("DEPLOY_TOKEN");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("does not flag a built-in CI_ token", () => {
|
|
34
|
+
const yaml = `deploy:
|
|
35
|
+
rules:
|
|
36
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
37
|
+
script:
|
|
38
|
+
- echo "$CI_JOB_TOKEN" | docker login --password-stdin
|
|
39
|
+
`;
|
|
40
|
+
const diags = wgl038.check(makeCtx(yaml));
|
|
41
|
+
expect(diags).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("does not flag a secret gated to a protected branch", () => {
|
|
45
|
+
const yaml = `deploy:
|
|
46
|
+
rules:
|
|
47
|
+
- if: $CI_COMMIT_BRANCH == "main"
|
|
48
|
+
script:
|
|
49
|
+
- curl -H "Auth: $DEPLOY_TOKEN" https://api.example.com
|
|
50
|
+
`;
|
|
51
|
+
const diags = wgl038.check(makeCtx(yaml));
|
|
52
|
+
expect(diags).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL038: Secret Reachable from a Merge-Request Pipeline
|
|
3
|
+
*
|
|
4
|
+
* Flags a user-defined secret-like variable (`*_PASSWORD`, `*_SECRET`,
|
|
5
|
+
* `*_TOKEN`, `*_API_KEY`, …) referenced in a job reachable from merge-request
|
|
6
|
+
* pipelines. Those pipelines can run an outside contributor's code, so a secret
|
|
7
|
+
* they can read is a secret they can exfiltrate. Gate secret-using jobs to
|
|
8
|
+
* protected refs, or make the variable protected so MR pipelines can't see it.
|
|
9
|
+
*
|
|
10
|
+
* Built-in `CI_*` variables are project-managed and excluded.
|
|
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 SECRET_VAR = /\$\{?((?!CI_)[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?))\}?/g;
|
|
17
|
+
|
|
18
|
+
export const wgl038: PostSynthCheck = {
|
|
19
|
+
id: "WGL038",
|
|
20
|
+
description: "Secret-like variable reachable from a merge-request pipeline",
|
|
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 || !isMergeRequestReachable(section)) continue;
|
|
30
|
+
const found = new Set<string>();
|
|
31
|
+
let m: RegExpExecArray | null;
|
|
32
|
+
SECRET_VAR.lastIndex = 0;
|
|
33
|
+
while ((m = SECRET_VAR.exec(section)) !== null) found.add(m[1]);
|
|
34
|
+
if (found.size > 0) {
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
checkId: "WGL038",
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `Job "${job}" reads secret-like variable(s) ${[...found].map((v) => `$${v}`).join(", ")} and is reachable from merge-request pipelines, which can run untrusted code. Gate it to protected refs or mark the variable protected.`,
|
|
39
|
+
entity: job,
|
|
40
|
+
lexicon: "gitlab",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return diagnostics;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl039 } from "./wgl039";
|
|
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("WGL039: secret echoed to logs", () => {
|
|
20
|
+
test("flags echo of a secret variable", () => {
|
|
21
|
+
const yaml = `debug:
|
|
22
|
+
script:
|
|
23
|
+
- echo $API_TOKEN
|
|
24
|
+
`;
|
|
25
|
+
const diags = wgl039.check(makeCtx(yaml));
|
|
26
|
+
expect(diags).toHaveLength(1);
|
|
27
|
+
expect(diags[0].checkId).toBe("WGL039");
|
|
28
|
+
expect(diags[0].entity).toBe("debug");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("does not flag a normal echo", () => {
|
|
32
|
+
const yaml = `build:
|
|
33
|
+
script:
|
|
34
|
+
- echo "building the project"
|
|
35
|
+
`;
|
|
36
|
+
const diags = wgl039.check(makeCtx(yaml));
|
|
37
|
+
expect(diags).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL039: Secret Echoed to Job Logs
|
|
3
|
+
*
|
|
4
|
+
* Flags a `script:` command that prints a secret-like variable (`echo`,
|
|
5
|
+
* `printf`, `print`, `cat`) — even masked variables leak when transformed
|
|
6
|
+
* (base64, reversed) before printing, and job logs are broadly readable.
|
|
7
|
+
* Remove the debug print or pipe the value where it is needed without echoing 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 PRINT_SECRET = /\b(echo|printf|print|cat)\b[^\n]*\$\{?[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?)\}?/i;
|
|
14
|
+
|
|
15
|
+
export const wgl039: PostSynthCheck = {
|
|
16
|
+
id: "WGL039",
|
|
17
|
+
description: "Secret-like variable printed to job logs",
|
|
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 (PRINT_SECRET.test(command) && !seen.has(job)) {
|
|
27
|
+
seen.add(job);
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "WGL039",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
message: `Job "${job}" prints a secret-like variable to the job log. Logs are broadly readable and masking can be defeated by transforms — remove the print.`,
|
|
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 { wgl040 } from "./wgl040";
|
|
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("WGL040: hardcoded registry credential", () => {
|
|
20
|
+
test("flags docker login with a literal password", () => {
|
|
21
|
+
const yaml = `publish:
|
|
22
|
+
script:
|
|
23
|
+
- docker login -u ci -p hunter2 registry.example.com
|
|
24
|
+
`;
|
|
25
|
+
const diags = wgl040.check(makeCtx(yaml));
|
|
26
|
+
expect(diags).toHaveLength(1);
|
|
27
|
+
expect(diags[0].checkId).toBe("WGL040");
|
|
28
|
+
expect(diags[0].severity).toBe("error");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("does not flag a variable password", () => {
|
|
32
|
+
const yaml = `publish:
|
|
33
|
+
script:
|
|
34
|
+
- docker login -u ci -p $CI_REGISTRY_PASSWORD registry.example.com
|
|
35
|
+
`;
|
|
36
|
+
const diags = wgl040.check(makeCtx(yaml));
|
|
37
|
+
expect(diags).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("does not flag --password-stdin", () => {
|
|
41
|
+
const yaml = `publish:
|
|
42
|
+
script:
|
|
43
|
+
- echo "$REG_PASS" | docker login -u ci --password-stdin registry.example.com
|
|
44
|
+
`;
|
|
45
|
+
const diags = wgl040.check(makeCtx(yaml));
|
|
46
|
+
expect(diags).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -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
|
+
};
|