@intentius/chant-lexicon-gitlab 0.124.30 → 0.124.32
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/codegen/docs-cli.js +2 -0
- package/dist/codegen/docs.js +157 -0
- package/dist/codegen/fetch.js +93 -0
- package/dist/codegen/generate-cli.js +30 -0
- package/dist/codegen/generate-lexicon.js +36 -0
- package/dist/codegen/generate-typescript.js +105 -0
- package/dist/codegen/generate.js +125 -0
- package/dist/codegen/naming.js +48 -0
- package/dist/codegen/package.js +41 -0
- package/dist/codegen/parse.js +539 -0
- package/dist/codegen/patches.js +84 -0
- package/dist/codegen/typecheck.js +4 -0
- package/dist/components/ci-jobs.js +16 -0
- package/dist/components/generate-op-pipeline.js +288 -0
- package/dist/components/generate-op-waves-pipeline.js +125 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +369 -0
- package/dist/composites/catalog.js +418 -0
- package/dist/composites/docker-build.js +38 -0
- package/dist/composites/index.js +5 -0
- package/dist/composites/mr-plan-report.js +42 -0
- package/dist/composites/node-pipeline.js +67 -0
- package/dist/composites/python-pipeline.js +45 -0
- package/dist/composites/review-app.js +32 -0
- package/dist/coverage.js +35 -0
- package/dist/generated/index.js +25 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/graph-edges.js +202 -0
- package/dist/import/generator.js +196 -0
- package/dist/import/parser.js +111 -0
- package/dist/index.js +26 -0
- package/dist/integrity.json +3 -3
- package/dist/intrinsics.js +33 -0
- package/dist/lint/audit-catalog.js +53 -0
- package/dist/lint/audit-lineage.js +64 -0
- package/dist/lint/post-synth/index.js +84 -0
- package/dist/lint/post-synth/wgl010.js +34 -0
- package/dist/lint/post-synth/wgl011.js +48 -0
- package/dist/lint/post-synth/wgl012.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl012.js +68 -0
- package/dist/lint/post-synth/wgl013.js +57 -0
- package/dist/lint/post-synth/wgl014.js +43 -0
- package/dist/lint/post-synth/wgl015.js +70 -0
- package/dist/lint/post-synth/wgl016.js +70 -0
- package/dist/lint/post-synth/wgl017.js +38 -0
- package/dist/lint/post-synth/wgl018.js +34 -0
- package/dist/lint/post-synth/wgl019.js +43 -0
- package/dist/lint/post-synth/wgl020.js +47 -0
- package/dist/lint/post-synth/wgl021.js +52 -0
- package/dist/lint/post-synth/wgl022.js +38 -0
- package/dist/lint/post-synth/wgl023.js +44 -0
- package/dist/lint/post-synth/wgl024.js +41 -0
- package/dist/lint/post-synth/wgl025.js +42 -0
- package/dist/lint/post-synth/wgl026.js +58 -0
- package/dist/lint/post-synth/wgl027.js +49 -0
- package/dist/lint/post-synth/wgl028.js +58 -0
- package/dist/lint/post-synth/wgl029.js +55 -0
- package/dist/lint/post-synth/wgl030.js +43 -0
- package/dist/lint/post-synth/wgl031.js +33 -0
- package/dist/lint/post-synth/wgl032.js +64 -0
- package/dist/lint/post-synth/wgl033.js +32 -0
- package/dist/lint/post-synth/wgl034.js +37 -0
- package/dist/lint/post-synth/wgl035.js +40 -0
- package/dist/lint/post-synth/wgl036.js +38 -0
- package/dist/lint/post-synth/wgl037.js +41 -0
- package/dist/lint/post-synth/wgl038.js +43 -0
- package/dist/lint/post-synth/wgl039.js +34 -0
- package/dist/lint/post-synth/wgl040.js +35 -0
- package/dist/lint/post-synth/wgl041.js +53 -0
- package/dist/lint/post-synth/wgl042.js +41 -0
- package/dist/lint/post-synth/wgl043.js +39 -0
- package/dist/lint/post-synth/wgl044.js +35 -0
- package/dist/lint/post-synth/wgl045.js +54 -0
- package/dist/lint/post-synth/wgl046.js +40 -0
- package/dist/lint/post-synth/wgl047.js +34 -0
- package/dist/lint/post-synth/wgl048.js +32 -0
- package/dist/lint/post-synth/wgl049.js +53 -0
- package/dist/lint/post-synth/wgl050.js +42 -0
- package/dist/lint/post-synth/yaml-helpers.js +432 -0
- package/dist/lint/rules/artifact-no-expiry.js +53 -0
- package/dist/lint/rules/data/known-include-sources.js +44 -0
- package/dist/lint/rules/data/untrusted-variables.js +30 -0
- package/dist/lint/rules/deprecated-only-except.js +46 -0
- package/dist/lint/rules/import-source.js +61 -0
- package/dist/lint/rules/index.js +7 -0
- package/dist/lint/rules/missing-script.js +67 -0
- package/dist/lint/rules/missing-stage.js +60 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +33 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/context-tools.js +262 -0
- package/dist/migrate/from-github/actions/index.js +17 -0
- package/dist/migrate/from-github/actions/registry.js +61 -0
- package/dist/migrate/from-github/actions/tier-1.js +298 -0
- package/dist/migrate/from-github/actions/tier-2.js +241 -0
- package/dist/migrate/from-github/actions/tier-3.js +96 -0
- package/dist/migrate/from-github/composites/patterns.js +151 -0
- package/dist/migrate/from-github/composites/rewriter.js +22 -0
- package/dist/migrate/from-github/diagnostics.js +53 -0
- package/dist/migrate/from-github/emit-yaml.js +121 -0
- package/dist/migrate/from-github/expressions.js +275 -0
- package/dist/migrate/from-github/index.js +96 -0
- package/dist/migrate/from-github/provenance.js +35 -0
- package/dist/migrate/from-github/rules.js +74 -0
- package/dist/migrate/from-github/security.js +189 -0
- package/dist/migrate/from-github/stages.js +156 -0
- package/dist/migrate/from-github/transformer.js +633 -0
- package/dist/op/activities/gitlab.js +38 -0
- package/dist/op/activities/index.js +10 -0
- package/dist/op/builders.js +26 -0
- package/dist/package-cli.js +21 -0
- package/dist/plugin.js +536 -0
- package/dist/rules/wgl012.ts +2 -2
- package/dist/serializer.js +197 -0
- package/dist/testdata/create-fixture.js +40 -0
- package/dist/testdata/load-fixtures.js +20 -0
- package/dist/validate-cli.js +16 -0
- package/dist/validate.js +29 -0
- package/dist/variables.js +30 -0
- package/package.json +8 -8
- package/src/codegen/fetch.ts +2 -2
- package/src/codegen/package.ts +2 -2
- package/src/components/__fixtures__/pr-loop-build.gitlab.golden.yml +3 -3
- package/src/components/__fixtures__/pr-loop-waves.gitlab.golden.yml +101 -0
- package/src/components/__fixtures__/pr-loop.gitlab.golden.yml +3 -3
- package/src/components/generate-pipeline.ts +60 -27
- package/src/components/generate-pr-pipeline.test.ts +14 -9
- package/src/components/generate-pr-waves-pipeline.test.ts +88 -0
- package/src/lint/post-synth/wgl012.ts +2 -2
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractIncludes } from "./yaml-helpers.js";
|
|
10
|
+
import { KNOWN_INCLUDE_SOURCES, editDistance } from "../rules/data/known-include-sources.js";
|
|
11
|
+
const KNOWN = new Set(KNOWN_INCLUDE_SOURCES);
|
|
12
|
+
/** Reduce a component address (`host/group/name@ver`) or project path to its group/name path. */
|
|
13
|
+
function sourcePath(value, kind) {
|
|
14
|
+
let v = value;
|
|
15
|
+
if (kind === "component") {
|
|
16
|
+
const at = v.lastIndexOf("@");
|
|
17
|
+
if (at !== -1)
|
|
18
|
+
v = v.slice(0, at);
|
|
19
|
+
const slash = v.indexOf("/");
|
|
20
|
+
if (slash !== -1 && v.slice(0, slash).includes("."))
|
|
21
|
+
v = v.slice(slash + 1); // drop host
|
|
22
|
+
}
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
export function nearestKnownSource(path) {
|
|
26
|
+
if (KNOWN.has(path))
|
|
27
|
+
return undefined;
|
|
28
|
+
let best;
|
|
29
|
+
let bestDist = 3;
|
|
30
|
+
for (const known of KNOWN) {
|
|
31
|
+
const d = editDistance(path, known, 2);
|
|
32
|
+
if (d >= 1 && d <= 2 && d < bestDist) {
|
|
33
|
+
best = known;
|
|
34
|
+
bestDist = d;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return best;
|
|
38
|
+
}
|
|
39
|
+
export const wgl032 = {
|
|
40
|
+
id: "WGL032",
|
|
41
|
+
description: "Include/component source resembles a well-known project (possible impersonation)",
|
|
42
|
+
check(ctx) {
|
|
43
|
+
const diagnostics = [];
|
|
44
|
+
for (const [, output] of ctx.outputs) {
|
|
45
|
+
const yaml = getPrimaryOutput(output);
|
|
46
|
+
for (const entry of extractIncludes(yaml)) {
|
|
47
|
+
if (entry.kind !== "project" && entry.kind !== "component")
|
|
48
|
+
continue;
|
|
49
|
+
const path = sourcePath(entry.value, entry.kind);
|
|
50
|
+
const lookAlike = nearestKnownSource(path);
|
|
51
|
+
if (!lookAlike)
|
|
52
|
+
continue;
|
|
53
|
+
diagnostics.push({
|
|
54
|
+
checkId: "WGL032",
|
|
55
|
+
severity: "warning",
|
|
56
|
+
message: `Include source "${path}" closely resembles the well-known "${lookAlike}". Confirm the source is who you intend — this may be a typo or impersonation.`,
|
|
57
|
+
entity: entry.value,
|
|
58
|
+
lexicon: "gitlab",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return diagnostics;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractIdTokens } from "./yaml-helpers.js";
|
|
10
|
+
export const wgl033 = {
|
|
11
|
+
id: "WGL033",
|
|
12
|
+
description: "OIDC id_token without a scoped audience",
|
|
13
|
+
check(ctx) {
|
|
14
|
+
const diagnostics = [];
|
|
15
|
+
for (const [, output] of ctx.outputs) {
|
|
16
|
+
const yaml = getPrimaryOutput(output);
|
|
17
|
+
for (const { job, name, aud } of extractIdTokens(yaml)) {
|
|
18
|
+
const broad = aud.length === 0 || aud.some((a) => a === "*" || a === "" || a.includes("*"));
|
|
19
|
+
if (broad) {
|
|
20
|
+
diagnostics.push({
|
|
21
|
+
checkId: "WGL033",
|
|
22
|
+
severity: "warning",
|
|
23
|
+
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.`,
|
|
24
|
+
entity: job,
|
|
25
|
+
lexicon: "gitlab",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return diagnostics;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractIdTokens, extractJobSection, isMergeRequestReachable } from "./yaml-helpers.js";
|
|
11
|
+
export const wgl034 = {
|
|
12
|
+
id: "WGL034",
|
|
13
|
+
description: "OIDC id_token mintable from a merge-request pipeline",
|
|
14
|
+
check(ctx) {
|
|
15
|
+
const diagnostics = [];
|
|
16
|
+
for (const [, output] of ctx.outputs) {
|
|
17
|
+
const yaml = getPrimaryOutput(output);
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const { job } of extractIdTokens(yaml)) {
|
|
20
|
+
if (seen.has(job))
|
|
21
|
+
continue;
|
|
22
|
+
const section = extractJobSection(yaml, job);
|
|
23
|
+
if (section && isMergeRequestReachable(section)) {
|
|
24
|
+
seen.add(job);
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
checkId: "WGL034",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
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.`,
|
|
29
|
+
entity: job,
|
|
30
|
+
lexicon: "gitlab",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return diagnostics;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractScriptCommands } from "./yaml-helpers.js";
|
|
11
|
+
import { UNTRUSTED_CI_VARIABLES, variableReferenced } from "../rules/data/untrusted-variables.js";
|
|
12
|
+
export const wgl035 = {
|
|
13
|
+
id: "WGL035",
|
|
14
|
+
description: "Untrusted CI variable interpolated into a script command",
|
|
15
|
+
check(ctx) {
|
|
16
|
+
const diagnostics = [];
|
|
17
|
+
for (const [, output] of ctx.outputs) {
|
|
18
|
+
const yaml = getPrimaryOutput(output);
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
for (const { job, command } of extractScriptCommands(yaml)) {
|
|
21
|
+
for (const name of UNTRUSTED_CI_VARIABLES) {
|
|
22
|
+
if (!variableReferenced(command, name))
|
|
23
|
+
continue;
|
|
24
|
+
const key = `${job}:${name}`;
|
|
25
|
+
if (seen.has(key))
|
|
26
|
+
continue;
|
|
27
|
+
seen.add(key);
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "WGL035",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
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.`,
|
|
32
|
+
entity: job,
|
|
33
|
+
lexicon: "gitlab",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return diagnostics;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, isMergeRequestReachable } from "./yaml-helpers.js";
|
|
13
|
+
const DIND_RE = /docker:[\w.-]*dind|dind|privileged:\s*true/;
|
|
14
|
+
export const wgl036 = {
|
|
15
|
+
id: "WGL036",
|
|
16
|
+
description: "Privileged service / DinD reachable from merge-request pipelines",
|
|
17
|
+
check(ctx) {
|
|
18
|
+
const diagnostics = [];
|
|
19
|
+
for (const [, output] of ctx.outputs) {
|
|
20
|
+
const yaml = getPrimaryOutput(output);
|
|
21
|
+
for (const [job] of extractJobs(yaml)) {
|
|
22
|
+
const section = extractJobSection(yaml, job);
|
|
23
|
+
if (!section)
|
|
24
|
+
continue;
|
|
25
|
+
if (DIND_RE.test(section) && isMergeRequestReachable(section)) {
|
|
26
|
+
diagnostics.push({
|
|
27
|
+
checkId: "WGL036",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
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.`,
|
|
30
|
+
entity: job,
|
|
31
|
+
lexicon: "gitlab",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return diagnostics;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers.js";
|
|
11
|
+
import { UNTRUSTED_CI_VARIABLES } from "../rules/data/untrusted-variables.js";
|
|
12
|
+
const REF_VARS = ["CI_COMMIT_REF_NAME", "CI_COMMIT_REF_SLUG", "CI_COMMIT_BRANCH", "CI_COMMIT_TAG", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"]
|
|
13
|
+
.filter((v) => UNTRUSTED_CI_VARIABLES.includes(v));
|
|
14
|
+
export const wgl037 = {
|
|
15
|
+
id: "WGL037",
|
|
16
|
+
description: "Security gate on a regex match of an untrusted ref variable",
|
|
17
|
+
check(ctx) {
|
|
18
|
+
const diagnostics = [];
|
|
19
|
+
for (const [, output] of ctx.outputs) {
|
|
20
|
+
const yaml = getPrimaryOutput(output);
|
|
21
|
+
for (const [job] of extractJobs(yaml)) {
|
|
22
|
+
const section = extractJobSection(yaml, job);
|
|
23
|
+
if (!section)
|
|
24
|
+
continue;
|
|
25
|
+
// Look at `if:` lines that use a regex match against an untrusted ref var.
|
|
26
|
+
const ifLines = section.split("\n").filter((l) => /if:\s/.test(l));
|
|
27
|
+
const flagged = ifLines.some((l) => /=~/.test(l) && REF_VARS.some((v) => l.includes(`$${v}`)));
|
|
28
|
+
if (flagged) {
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "WGL037",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
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.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "gitlab",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, isMergeRequestReachable } from "./yaml-helpers.js";
|
|
13
|
+
const SECRET_VAR = /\$\{?((?!CI_)[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?))\}?/g;
|
|
14
|
+
export const wgl038 = {
|
|
15
|
+
id: "WGL038",
|
|
16
|
+
description: "Secret-like variable reachable from a merge-request pipeline",
|
|
17
|
+
check(ctx) {
|
|
18
|
+
const diagnostics = [];
|
|
19
|
+
for (const [, output] of ctx.outputs) {
|
|
20
|
+
const yaml = getPrimaryOutput(output);
|
|
21
|
+
for (const [job] of extractJobs(yaml)) {
|
|
22
|
+
const section = extractJobSection(yaml, job);
|
|
23
|
+
if (!section || !isMergeRequestReachable(section))
|
|
24
|
+
continue;
|
|
25
|
+
const found = new Set();
|
|
26
|
+
let m;
|
|
27
|
+
SECRET_VAR.lastIndex = 0;
|
|
28
|
+
while ((m = SECRET_VAR.exec(section)) !== null)
|
|
29
|
+
found.add(m[1]);
|
|
30
|
+
if (found.size > 0) {
|
|
31
|
+
diagnostics.push({
|
|
32
|
+
checkId: "WGL038",
|
|
33
|
+
severity: "warning",
|
|
34
|
+
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.`,
|
|
35
|
+
entity: job,
|
|
36
|
+
lexicon: "gitlab",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return diagnostics;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractScriptCommands } from "./yaml-helpers.js";
|
|
10
|
+
const PRINT_SECRET = /\b(echo|printf|print|cat)\b[^\n]*\$\{?[A-Z][A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?)\}?/i;
|
|
11
|
+
export const wgl039 = {
|
|
12
|
+
id: "WGL039",
|
|
13
|
+
description: "Secret-like variable printed to job logs",
|
|
14
|
+
check(ctx) {
|
|
15
|
+
const diagnostics = [];
|
|
16
|
+
for (const [, output] of ctx.outputs) {
|
|
17
|
+
const yaml = getPrimaryOutput(output);
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const { job, command } of extractScriptCommands(yaml)) {
|
|
20
|
+
if (PRINT_SECRET.test(command) && !seen.has(job)) {
|
|
21
|
+
seen.add(job);
|
|
22
|
+
diagnostics.push({
|
|
23
|
+
checkId: "WGL039",
|
|
24
|
+
severity: "warning",
|
|
25
|
+
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.`,
|
|
26
|
+
entity: job,
|
|
27
|
+
lexicon: "gitlab",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return diagnostics;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractScriptCommands } from "./yaml-helpers.js";
|
|
10
|
+
// docker/podman/buildah/helm login with -p/--password followed by a non-variable literal
|
|
11
|
+
const LITERAL_LOGIN = /\b(?:docker|podman|buildah|helm|crane|skopeo)\b[^\n]*\blogin\b[^\n]*(?:-p|--password)[=\s]+(?!["']?\$)["']?[^\s"']+/i;
|
|
12
|
+
export const wgl040 = {
|
|
13
|
+
id: "WGL040",
|
|
14
|
+
description: "Hardcoded credential in a registry login command",
|
|
15
|
+
check(ctx) {
|
|
16
|
+
const diagnostics = [];
|
|
17
|
+
for (const [, output] of ctx.outputs) {
|
|
18
|
+
const yaml = getPrimaryOutput(output);
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
for (const { job, command } of extractScriptCommands(yaml)) {
|
|
21
|
+
if (LITERAL_LOGIN.test(command) && !seen.has(job)) {
|
|
22
|
+
seen.add(job);
|
|
23
|
+
diagnostics.push({
|
|
24
|
+
checkId: "WGL040",
|
|
25
|
+
severity: "error",
|
|
26
|
+
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.`,
|
|
27
|
+
entity: job,
|
|
28
|
+
lexicon: "gitlab",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return diagnostics;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, extractJobRules } from "./yaml-helpers.js";
|
|
10
|
+
function strip(s) {
|
|
11
|
+
return s.trim().replace(/^['"]|['"]$/g, "").trim();
|
|
12
|
+
}
|
|
13
|
+
/** Classify an `if:` expression as a tautology, returning a reason or undefined. */
|
|
14
|
+
export function tautologyReason(expr) {
|
|
15
|
+
const e = strip(expr).replace(/^\$\{?/, "$").trim();
|
|
16
|
+
const eq = e.match(/^(\S.*?)\s*==\s*(\S.*?)$/);
|
|
17
|
+
if (eq && strip(eq[1]) === strip(eq[2]))
|
|
18
|
+
return "always true (both sides of == are identical)";
|
|
19
|
+
const ne = e.match(/^(\S.*?)\s*!=\s*(\S.*?)$/);
|
|
20
|
+
if (ne && strip(ne[1]) === strip(ne[2]))
|
|
21
|
+
return "always false (both sides of != are identical)";
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
export const wgl041 = {
|
|
25
|
+
id: "WGL041",
|
|
26
|
+
description: "Logically unsound (tautological) rules:if condition",
|
|
27
|
+
check(ctx) {
|
|
28
|
+
const diagnostics = [];
|
|
29
|
+
for (const [, output] of ctx.outputs) {
|
|
30
|
+
const yaml = getPrimaryOutput(output);
|
|
31
|
+
for (const [job] of extractJobs(yaml)) {
|
|
32
|
+
const section = extractJobSection(yaml, job);
|
|
33
|
+
if (!section)
|
|
34
|
+
continue;
|
|
35
|
+
for (const rule of extractJobRules(section)) {
|
|
36
|
+
if (!rule.if)
|
|
37
|
+
continue;
|
|
38
|
+
const reason = tautologyReason(rule.if);
|
|
39
|
+
if (reason) {
|
|
40
|
+
diagnostics.push({
|
|
41
|
+
checkId: "WGL041",
|
|
42
|
+
severity: "warning",
|
|
43
|
+
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.`,
|
|
44
|
+
entity: job,
|
|
45
|
+
lexicon: "gitlab",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return diagnostics;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, extractJobRules } from "./yaml-helpers.js";
|
|
11
|
+
export const wgl042 = {
|
|
12
|
+
id: "WGL042",
|
|
13
|
+
description: "Unreachable rules after an unconditional match",
|
|
14
|
+
check(ctx) {
|
|
15
|
+
const diagnostics = [];
|
|
16
|
+
for (const [, output] of ctx.outputs) {
|
|
17
|
+
const yaml = getPrimaryOutput(output);
|
|
18
|
+
for (const [job] of extractJobs(yaml)) {
|
|
19
|
+
const section = extractJobSection(yaml, job);
|
|
20
|
+
if (!section)
|
|
21
|
+
continue;
|
|
22
|
+
const rules = extractJobRules(section);
|
|
23
|
+
for (let i = 0; i < rules.length - 1; i++) {
|
|
24
|
+
const r = rules[i];
|
|
25
|
+
const unconditional = !r.if && r.when !== "never";
|
|
26
|
+
if (unconditional) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "WGL042",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
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.`,
|
|
31
|
+
entity: job,
|
|
32
|
+
lexicon: "gitlab",
|
|
33
|
+
});
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, extractJobRules } from "./yaml-helpers.js";
|
|
11
|
+
// `=~` followed by a regex literal that matches anything.
|
|
12
|
+
const MATCH_ANYTHING = /=~\s*\/(?:|\.\*|\^\.\*\$|\^\.\*|\.\*\$)\//;
|
|
13
|
+
export const wgl043 = {
|
|
14
|
+
id: "WGL043",
|
|
15
|
+
description: "Match-anything regex gate in rules:if",
|
|
16
|
+
check(ctx) {
|
|
17
|
+
const diagnostics = [];
|
|
18
|
+
for (const [, output] of ctx.outputs) {
|
|
19
|
+
const yaml = getPrimaryOutput(output);
|
|
20
|
+
for (const [job] of extractJobs(yaml)) {
|
|
21
|
+
const section = extractJobSection(yaml, job);
|
|
22
|
+
if (!section)
|
|
23
|
+
continue;
|
|
24
|
+
for (const rule of extractJobRules(section)) {
|
|
25
|
+
if (rule.if && MATCH_ANYTHING.test(rule.if)) {
|
|
26
|
+
diagnostics.push({
|
|
27
|
+
checkId: "WGL043",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
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.`,
|
|
30
|
+
entity: job,
|
|
31
|
+
lexicon: "gitlab",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return diagnostics;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers.js";
|
|
10
|
+
const PUBLIC_ARTIFACTS = /^\s+public:\s*true\s*$/m;
|
|
11
|
+
export const wgl044 = {
|
|
12
|
+
id: "WGL044",
|
|
13
|
+
description: "Public artifacts expose build output",
|
|
14
|
+
check(ctx) {
|
|
15
|
+
const diagnostics = [];
|
|
16
|
+
for (const [, output] of ctx.outputs) {
|
|
17
|
+
const yaml = getPrimaryOutput(output);
|
|
18
|
+
for (const [job] of extractJobs(yaml)) {
|
|
19
|
+
const section = extractJobSection(yaml, job);
|
|
20
|
+
if (!section || !/^\s+artifacts:/m.test(section))
|
|
21
|
+
continue;
|
|
22
|
+
if (PUBLIC_ARTIFACTS.test(section)) {
|
|
23
|
+
diagnostics.push({
|
|
24
|
+
checkId: "WGL044",
|
|
25
|
+
severity: "warning",
|
|
26
|
+
message: `Job "${job}" publishes public artifacts (artifacts:public: true), downloadable by anyone. Keep them private unless they are meant to be world-readable.`,
|
|
27
|
+
entity: job,
|
|
28
|
+
lexicon: "gitlab",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return diagnostics;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractJobs, extractJobSection } from "./yaml-helpers.js";
|
|
11
|
+
const CREDENTIAL_PATH = /(^|\/)(\.env(\.|$)|\.npmrc|\.netrc|id_rsa|.*\.pem|.*\.key|.*\.p12|credentials?(\.|$))/i;
|
|
12
|
+
export const wgl045 = {
|
|
13
|
+
id: "WGL045",
|
|
14
|
+
description: "Artifact path that may capture a credential file",
|
|
15
|
+
check(ctx) {
|
|
16
|
+
const diagnostics = [];
|
|
17
|
+
for (const [, output] of ctx.outputs) {
|
|
18
|
+
const yaml = getPrimaryOutput(output);
|
|
19
|
+
for (const [job] of extractJobs(yaml)) {
|
|
20
|
+
const section = extractJobSection(yaml, job);
|
|
21
|
+
if (!section)
|
|
22
|
+
continue;
|
|
23
|
+
// Collect the lines under the artifacts: block (deeper indent than the key).
|
|
24
|
+
const lines = section.split("\n");
|
|
25
|
+
const artifactsIdx = lines.findIndex((l) => /^\s+artifacts:\s*$/.test(l));
|
|
26
|
+
if (artifactsIdx === -1)
|
|
27
|
+
continue;
|
|
28
|
+
const artifactsIndent = lines[artifactsIdx].search(/\S/);
|
|
29
|
+
const blockLines = [];
|
|
30
|
+
for (let i = artifactsIdx + 1; i < lines.length; i++) {
|
|
31
|
+
if (lines[i].trim() === "")
|
|
32
|
+
continue;
|
|
33
|
+
if (lines[i].search(/\S/) <= artifactsIndent)
|
|
34
|
+
break;
|
|
35
|
+
blockLines.push(lines[i]);
|
|
36
|
+
}
|
|
37
|
+
const hit = blockLines
|
|
38
|
+
.filter((l) => /^\s+-\s+/.test(l))
|
|
39
|
+
.map((l) => l.replace(/^\s+-\s+/, "").trim().replace(/^['"]|['"]$/g, ""))
|
|
40
|
+
.find((p) => CREDENTIAL_PATH.test(p));
|
|
41
|
+
if (hit) {
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
checkId: "WGL045",
|
|
44
|
+
severity: "error",
|
|
45
|
+
message: `Job "${job}" includes a credential-like file ("${hit}") in its artifacts. Exclude it — artifacts are passed downstream and are downloadable.`,
|
|
46
|
+
entity: job,
|
|
47
|
+
lexicon: "gitlab",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return diagnostics;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
import { getPrimaryOutput, extractJobs, extractJobSection, isMergeRequestReachable } from "./yaml-helpers.js";
|
|
11
|
+
export const wgl046 = {
|
|
12
|
+
id: "WGL046",
|
|
13
|
+
description: "Cache populated in a merge-request pipeline (poisoning risk)",
|
|
14
|
+
check(ctx) {
|
|
15
|
+
const diagnostics = [];
|
|
16
|
+
for (const [, output] of ctx.outputs) {
|
|
17
|
+
const yaml = getPrimaryOutput(output);
|
|
18
|
+
for (const [job] of extractJobs(yaml)) {
|
|
19
|
+
const section = extractJobSection(yaml, job);
|
|
20
|
+
if (!section || !/^\s+cache:/m.test(section))
|
|
21
|
+
continue;
|
|
22
|
+
if (!isMergeRequestReachable(section))
|
|
23
|
+
continue;
|
|
24
|
+
// pull-only caches cannot poison; flag push/pull-push (the default when policy is absent)
|
|
25
|
+
const policyMatch = section.match(/^\s+policy:\s*(\S+)/m);
|
|
26
|
+
const policy = policyMatch ? policyMatch[1].replace(/^['"]|['"]$/g, "") : "pull-push";
|
|
27
|
+
if (policy === "pull")
|
|
28
|
+
continue;
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
checkId: "WGL046",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
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.`,
|
|
33
|
+
entity: job,
|
|
34
|
+
lexicon: "gitlab",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return diagnostics;
|
|
39
|
+
},
|
|
40
|
+
};
|