@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
package/src/codegen/docs.ts
CHANGED
|
@@ -435,6 +435,136 @@ Flags jobs whose \`extends:\` references a template or hidden job not defined in
|
|
|
435
435
|
|
|
436
436
|
Detects cycles in the \`needs:\` dependency graph. If job A needs B and B needs A (directly or transitively), GitLab rejects the pipeline. Reports the full cycle chain in the diagnostic message.
|
|
437
437
|
|
|
438
|
+
### WGL029 — Unpinned include:project / component
|
|
439
|
+
|
|
440
|
+
**Severity:** warning
|
|
441
|
+
|
|
442
|
+
Flags an \`include:project\` or CI/CD \`component:\` resolved by a moving ref — a branch, a missing \`ref:\` (defaults to the default branch), or a floating component version — instead of a pinned tag or commit SHA.
|
|
443
|
+
|
|
444
|
+
### WGL030 — Mutable or insecure include:remote
|
|
445
|
+
|
|
446
|
+
**Severity:** error (HTTP) / warning (HTTPS)
|
|
447
|
+
|
|
448
|
+
Flags \`include:remote\` URLs fetched over HTTP (no transport integrity) or over HTTPS but inherently mutable. Prefer a pinned \`include:project\` or component. Generalizes WGL017 to includes.
|
|
449
|
+
|
|
450
|
+
### WGL031 — Container image without a digest
|
|
451
|
+
|
|
452
|
+
**Severity:** warning
|
|
453
|
+
|
|
454
|
+
Flags \`image:\` and \`services:\` references not pinned to an immutable \`@sha256:\` digest. Variable-based references (e.g. \`$CI_REGISTRY_IMAGE:tag\`) are skipped.
|
|
455
|
+
|
|
456
|
+
### WGL032 — Look-alike include/component source
|
|
457
|
+
|
|
458
|
+
**Severity:** warning
|
|
459
|
+
|
|
460
|
+
Flags an \`include:project\` / \`component:\` source that is a near-miss (edit distance 1–2) of a well-known GitLab CI source but not an exact match — a likely typo or impersonation. Backed by a vendored reference list.
|
|
461
|
+
|
|
462
|
+
### WGL033 — OIDC id_token without a scoped audience
|
|
463
|
+
|
|
464
|
+
**Severity:** warning
|
|
465
|
+
|
|
466
|
+
Flags an \`id_tokens:\` declaration with no \`aud:\` or a wildcard audience. The audience binds the minted OIDC token to a relying party; without it a leaked token is accepted anywhere.
|
|
467
|
+
|
|
468
|
+
### WGL034 — OIDC id_token mintable from a merge-request pipeline
|
|
469
|
+
|
|
470
|
+
**Severity:** warning
|
|
471
|
+
|
|
472
|
+
Flags a job that declares \`id_tokens:\` and is reachable from merge-request pipelines, which outside contributors can trigger. Restrict OIDC jobs to protected refs or require approval.
|
|
473
|
+
|
|
474
|
+
> The project-level \`CI_JOB_TOKEN\` allowlist and a variable's protected status are project settings, not emitted pipeline YAML, so they are out of scope for these post-synth checks (see issue #298's caveat).
|
|
475
|
+
|
|
476
|
+
### WGL035 — Untrusted CI variable in a script
|
|
477
|
+
|
|
478
|
+
**Severity:** warning
|
|
479
|
+
|
|
480
|
+
Flags an attacker-controllable predefined variable (branch/tag name, commit or MR title/description, author) referenced in a \`script:\` command, where a crafted value can inject shell commands. Quote it and avoid using it in sensitive commands.
|
|
481
|
+
|
|
482
|
+
### WGL036 — Privileged DinD reachable from merge requests
|
|
483
|
+
|
|
484
|
+
**Severity:** warning
|
|
485
|
+
|
|
486
|
+
Flags a Docker-in-Docker (privileged) job reachable from merge-request pipelines, which outside contributors can trigger. Restrict privileged services to protected refs. Complements WGL026.
|
|
487
|
+
|
|
488
|
+
### WGL037 — Regex gate on an untrusted ref
|
|
489
|
+
|
|
490
|
+
**Severity:** warning
|
|
491
|
+
|
|
492
|
+
Flags a \`rules:if\` that gates on a regex match (\`=~\`) over 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.
|
|
493
|
+
|
|
494
|
+
### WGL038 — Secret reachable from a merge-request pipeline
|
|
495
|
+
|
|
496
|
+
**Severity:** warning
|
|
497
|
+
|
|
498
|
+
Flags a user-defined secret-like variable read by a job reachable from merge-request pipelines, which can run untrusted code. Gate the job to protected refs or mark the variable protected. (Built-in \`CI_*\` variables are excluded.)
|
|
499
|
+
|
|
500
|
+
### WGL039 — Secret echoed to job logs
|
|
501
|
+
|
|
502
|
+
**Severity:** warning
|
|
503
|
+
|
|
504
|
+
Flags a \`script:\` command that prints a secret-like variable (\`echo\`/\`printf\`/\`cat\`). Logs are broadly readable and masking can be defeated by transforms.
|
|
505
|
+
|
|
506
|
+
### WGL040 — Hardcoded registry credential
|
|
507
|
+
|
|
508
|
+
**Severity:** error
|
|
509
|
+
|
|
510
|
+
Flags a \`docker login\` (or compatible) passing a literal password via \`-p\`/\`--password\` instead of a variable or \`--password-stdin\`. Extends WGL016 to scripts.
|
|
511
|
+
|
|
512
|
+
> Variable *masking* and *protected* status are GitLab project/CI-settings, not emitted YAML — WGL016 already nudges toward masked variables; this group covers the exposure paths visible in the pipeline.
|
|
513
|
+
|
|
514
|
+
### WGL041 — Unsound rules:if condition
|
|
515
|
+
|
|
516
|
+
**Severity:** warning
|
|
517
|
+
|
|
518
|
+
Flags a tautological \`rules:if\` where both sides of \`==\`/\`!=\` are identical, so the condition is always true or always false. Generalizes WGL011 to conditions presented as gates.
|
|
519
|
+
|
|
520
|
+
### WGL042 — Unreachable rules after an unconditional match
|
|
521
|
+
|
|
522
|
+
**Severity:** warning
|
|
523
|
+
|
|
524
|
+
Flags rules listed after an unconditional rule (no \`if:\`, not \`when: never\`). GitLab takes the first matching rule, so a catch-all makes everything after it dead. Put specific rules first.
|
|
525
|
+
|
|
526
|
+
### WGL043 — Match-anything regex gate
|
|
527
|
+
|
|
528
|
+
**Severity:** warning
|
|
529
|
+
|
|
530
|
+
Flags a \`rules:if\` whose \`=~\` regex matches every value (empty, dot-star, anchored dot-star) — a filter that admits everything. Tighten the pattern or remove the gate.
|
|
531
|
+
|
|
532
|
+
> The auto-fix acceptance item is N/A at the post-synth layer (PostSynthDiagnostic has no fix channel); it belongs to the declarative source-lint rules.
|
|
533
|
+
|
|
534
|
+
### WGL044 — Public artifacts
|
|
535
|
+
|
|
536
|
+
**Severity:** warning
|
|
537
|
+
|
|
538
|
+
Flags \`artifacts:public: true\`, which makes build output downloadable by anyone. Keep artifacts private unless they are meant to be world-readable.
|
|
539
|
+
|
|
540
|
+
### WGL045 — Credential-bearing artifact path
|
|
541
|
+
|
|
542
|
+
**Severity:** error
|
|
543
|
+
|
|
544
|
+
Flags an \`artifacts:paths:\` entry that looks like a credential or sensitive file (\`.env\`, \`*.pem\`, \`id_rsa\`, \`*.key\`, \`.npmrc\`, \`.netrc\`, \`credentials\`). Artifacts flow downstream and are downloadable — exclude the file.
|
|
545
|
+
|
|
546
|
+
### WGL046 — Cache poisoning from a merge-request pipeline
|
|
547
|
+
|
|
548
|
+
**Severity:** warning
|
|
549
|
+
|
|
550
|
+
Flags a job that writes a cache (push policy) 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.
|
|
551
|
+
|
|
552
|
+
> Artifact / \`dependencies:\` flow across a *protected* boundary depends on a ref's protected status, a project setting not present in emitted YAML — out of scope here.
|
|
553
|
+
|
|
554
|
+
### WGL047 — Software fetched and piped to a shell
|
|
555
|
+
|
|
556
|
+
**Severity:** warning
|
|
557
|
+
|
|
558
|
+
Flags a \`script:\` command that pipes a network download straight into a shell (\`curl ... | bash\`). The fetched code is unpinned and unverified — download to a file, verify a checksum/signature, then run it.
|
|
559
|
+
|
|
560
|
+
### WGL048 — Pipeline without a name
|
|
561
|
+
|
|
562
|
+
**Severity:** info
|
|
563
|
+
|
|
564
|
+
Flags a pipeline that defines a \`workflow:\` block but no \`workflow:name\`. A pipeline name aids identification in the GitLab UI and audit output.
|
|
565
|
+
|
|
566
|
+
> Two #304 items stay out of scope: a broad privileged-service / DinD check (already covered by WGL026 for DinD-TLS and WGL036 for MR-reachable DinD; a runner's privileged isolation is not in emitted YAML) and an optional include/component allowlist policy (configuration, overlapping WGL032's vendored source list).
|
|
567
|
+
|
|
438
568
|
## Running lint
|
|
439
569
|
|
|
440
570
|
\`\`\`bash
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl029 } from "./wgl029";
|
|
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("WGL029: unpinned include:project / component", () => {
|
|
20
|
+
test("flags include:project on a branch ref", () => {
|
|
21
|
+
const yaml = `include:
|
|
22
|
+
- project: my-group/ci-templates
|
|
23
|
+
ref: main
|
|
24
|
+
file: /templates/build.yml
|
|
25
|
+
|
|
26
|
+
stages:
|
|
27
|
+
- build
|
|
28
|
+
`;
|
|
29
|
+
const diags = wgl029.check(makeCtx(yaml));
|
|
30
|
+
expect(diags).toHaveLength(1);
|
|
31
|
+
expect(diags[0].checkId).toBe("WGL029");
|
|
32
|
+
expect(diags[0].message).toContain("main");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("flags include:project with no ref", () => {
|
|
36
|
+
const yaml = `include:
|
|
37
|
+
- project: my-group/ci-templates
|
|
38
|
+
file: /templates/build.yml
|
|
39
|
+
|
|
40
|
+
stages:
|
|
41
|
+
- build
|
|
42
|
+
`;
|
|
43
|
+
const diags = wgl029.check(makeCtx(yaml));
|
|
44
|
+
expect(diags).toHaveLength(1);
|
|
45
|
+
expect(diags[0].message).toContain("default branch");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("does not flag a pinned tag ref", () => {
|
|
49
|
+
const yaml = `include:
|
|
50
|
+
- project: my-group/ci-templates
|
|
51
|
+
ref: v1.2.3
|
|
52
|
+
file: /templates/build.yml
|
|
53
|
+
|
|
54
|
+
stages:
|
|
55
|
+
- build
|
|
56
|
+
`;
|
|
57
|
+
const diags = wgl029.check(makeCtx(yaml));
|
|
58
|
+
expect(diags).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("flags a floating component version and not a pinned one", () => {
|
|
62
|
+
const floating = `include:
|
|
63
|
+
- component: gitlab.example.com/my-group/my-comp@main
|
|
64
|
+
|
|
65
|
+
stages:
|
|
66
|
+
- build
|
|
67
|
+
`;
|
|
68
|
+
const pinned = `include:
|
|
69
|
+
- component: gitlab.example.com/my-group/my-comp@1.0.0
|
|
70
|
+
|
|
71
|
+
stages:
|
|
72
|
+
- build
|
|
73
|
+
`;
|
|
74
|
+
expect(wgl029.check(makeCtx(floating))).toHaveLength(1);
|
|
75
|
+
expect(wgl029.check(makeCtx(pinned))).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL029: Unpinned include:project / component Reference
|
|
3
|
+
*
|
|
4
|
+
* Flags an `include:project` or CI/CD `component:` resolved by a moving ref — a
|
|
5
|
+
* branch, no `ref:` at all (defaults to the project's default branch), or a
|
|
6
|
+
* floating component version — instead of a pinned tag or commit SHA. A
|
|
7
|
+
* repointed ref turns the included configuration into a supply-chain entry point.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractIncludes, isPinnedRef } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
export const wgl029: PostSynthCheck = {
|
|
14
|
+
id: "WGL029",
|
|
15
|
+
description: "include:project / component resolved by a moving ref instead of a pinned tag or SHA",
|
|
16
|
+
|
|
17
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const [, output] of ctx.outputs) {
|
|
21
|
+
const yaml = getPrimaryOutput(output);
|
|
22
|
+
for (const entry of extractIncludes(yaml)) {
|
|
23
|
+
if (entry.kind === "project") {
|
|
24
|
+
if (!entry.ref) {
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
checkId: "WGL029",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
message: `include:project "${entry.value}" has no ref: and resolves to the project's default branch (a moving target). Pin it to a tag or commit SHA.`,
|
|
29
|
+
entity: entry.value,
|
|
30
|
+
lexicon: "gitlab",
|
|
31
|
+
});
|
|
32
|
+
} else if (!isPinnedRef(entry.ref)) {
|
|
33
|
+
diagnostics.push({
|
|
34
|
+
checkId: "WGL029",
|
|
35
|
+
severity: "warning",
|
|
36
|
+
message: `include:project "${entry.value}" is pinned to the moving ref "${entry.ref}". Pin it to a tag or commit SHA.`,
|
|
37
|
+
entity: entry.value,
|
|
38
|
+
lexicon: "gitlab",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
} else if (entry.kind === "component") {
|
|
42
|
+
const at = entry.value.lastIndexOf("@");
|
|
43
|
+
const version = at === -1 ? "" : entry.value.slice(at + 1);
|
|
44
|
+
if (!version || !isPinnedRef(version)) {
|
|
45
|
+
diagnostics.push({
|
|
46
|
+
checkId: "WGL029",
|
|
47
|
+
severity: "warning",
|
|
48
|
+
message: `component "${entry.value}" uses ${version ? `the moving version "${version}"` : "no pinned version"}. Pin it to a released version tag or commit SHA.`,
|
|
49
|
+
entity: entry.value,
|
|
50
|
+
lexicon: "gitlab",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return diagnostics;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl030 } from "./wgl030";
|
|
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("WGL030: mutable or insecure include:remote", () => {
|
|
20
|
+
test("errors on an HTTP remote include", () => {
|
|
21
|
+
const yaml = `include:
|
|
22
|
+
- remote: http://example.com/ci.yml
|
|
23
|
+
|
|
24
|
+
stages:
|
|
25
|
+
- build
|
|
26
|
+
`;
|
|
27
|
+
const diags = wgl030.check(makeCtx(yaml));
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
expect(diags[0].checkId).toBe("WGL030");
|
|
30
|
+
expect(diags[0].severity).toBe("error");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("warns on an HTTPS remote include", () => {
|
|
34
|
+
const yaml = `include:
|
|
35
|
+
- remote: https://example.com/ci.yml
|
|
36
|
+
|
|
37
|
+
stages:
|
|
38
|
+
- build
|
|
39
|
+
`;
|
|
40
|
+
const diags = wgl030.check(makeCtx(yaml));
|
|
41
|
+
expect(diags).toHaveLength(1);
|
|
42
|
+
expect(diags[0].severity).toBe("warning");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("does not flag a project include", () => {
|
|
46
|
+
const yaml = `include:
|
|
47
|
+
- project: my-group/ci-templates
|
|
48
|
+
ref: v1.0.0
|
|
49
|
+
file: /x.yml
|
|
50
|
+
|
|
51
|
+
stages:
|
|
52
|
+
- build
|
|
53
|
+
`;
|
|
54
|
+
const diags = wgl030.check(makeCtx(yaml));
|
|
55
|
+
expect(diags).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL030: Mutable or Insecure include:remote
|
|
3
|
+
*
|
|
4
|
+
* Flags `include:remote` URLs that are fetched over HTTP (no transport
|
|
5
|
+
* integrity) or over HTTPS but inherently mutable (a raw URL can be repointed
|
|
6
|
+
* at any time). Prefer `include:project` pinned to a tag/SHA, or a pinned
|
|
7
|
+
* component, over a remote URL. Generalizes WGL017 (non-HTTPS) to includes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractIncludes } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
export const wgl030: PostSynthCheck = {
|
|
14
|
+
id: "WGL030",
|
|
15
|
+
description: "include:remote is insecure (HTTP) or mutable",
|
|
16
|
+
|
|
17
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const [, output] of ctx.outputs) {
|
|
21
|
+
const yaml = getPrimaryOutput(output);
|
|
22
|
+
for (const entry of extractIncludes(yaml)) {
|
|
23
|
+
const isRemote = entry.kind === "remote" || (entry.kind === "string" && /^https?:\/\//.test(entry.value));
|
|
24
|
+
if (!isRemote) continue;
|
|
25
|
+
|
|
26
|
+
if (/^http:\/\//.test(entry.value)) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "WGL030",
|
|
29
|
+
severity: "error",
|
|
30
|
+
message: `include:remote "${entry.value}" is fetched over HTTP — an attacker on the network can swap the included config. Use HTTPS, and prefer a pinned include:project or component.`,
|
|
31
|
+
entity: entry.value,
|
|
32
|
+
lexicon: "gitlab",
|
|
33
|
+
});
|
|
34
|
+
} else {
|
|
35
|
+
diagnostics.push({
|
|
36
|
+
checkId: "WGL030",
|
|
37
|
+
severity: "warning",
|
|
38
|
+
message: `include:remote "${entry.value}" is a mutable URL — its contents can change after review. Prefer include:project pinned to a tag/SHA, or a pinned component.`,
|
|
39
|
+
entity: entry.value,
|
|
40
|
+
lexicon: "gitlab",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return diagnostics;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl031 } from "./wgl031";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("WGL031: container image without digest", () => {
|
|
20
|
+
test("flags an image pinned to a tag", () => {
|
|
21
|
+
const yaml = `build-job:
|
|
22
|
+
image:
|
|
23
|
+
name: node:20
|
|
24
|
+
script:
|
|
25
|
+
- echo build
|
|
26
|
+
`;
|
|
27
|
+
const diags = wgl031.check(makeCtx(yaml));
|
|
28
|
+
expect(diags).toHaveLength(1);
|
|
29
|
+
expect(diags[0].checkId).toBe("WGL031");
|
|
30
|
+
expect(diags[0].message).toContain("node:20");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("flags a service image pinned to a tag", () => {
|
|
34
|
+
const yaml = `build-job:
|
|
35
|
+
services:
|
|
36
|
+
- name: postgres:16
|
|
37
|
+
script:
|
|
38
|
+
- echo build
|
|
39
|
+
`;
|
|
40
|
+
const diags = wgl031.check(makeCtx(yaml));
|
|
41
|
+
expect(diags).toHaveLength(1);
|
|
42
|
+
expect(diags[0].message).toContain("postgres:16");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("does not flag an image pinned to a digest", () => {
|
|
46
|
+
const yaml = `build-job:
|
|
47
|
+
image:
|
|
48
|
+
name: node@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
49
|
+
script:
|
|
50
|
+
- echo build
|
|
51
|
+
`;
|
|
52
|
+
const diags = wgl031.check(makeCtx(yaml));
|
|
53
|
+
expect(diags).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("does not flag a variable-based image reference", () => {
|
|
57
|
+
const yaml = `build-job:
|
|
58
|
+
image:
|
|
59
|
+
name: $CI_REGISTRY_IMAGE:latest
|
|
60
|
+
script:
|
|
61
|
+
- echo build
|
|
62
|
+
`;
|
|
63
|
+
const diags = wgl031.check(makeCtx(yaml));
|
|
64
|
+
expect(diags).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL031: Container Image Without an Immutable Digest
|
|
3
|
+
*
|
|
4
|
+
* Flags `image:` and `services:` references that are not pinned to an immutable
|
|
5
|
+
* `@sha256:` digest. A mutable tag can be repointed to a different image after
|
|
6
|
+
* review. Images built from a variable (e.g. `$CI_REGISTRY_IMAGE:tag`) are
|
|
7
|
+
* skipped — their concrete reference is not knowable from the static config.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractImageRefs } from "./yaml-helpers";
|
|
12
|
+
|
|
13
|
+
export const wgl031: PostSynthCheck = {
|
|
14
|
+
id: "WGL031",
|
|
15
|
+
description: "Container image not pinned to an immutable digest",
|
|
16
|
+
|
|
17
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
18
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
19
|
+
|
|
20
|
+
for (const [, output] of ctx.outputs) {
|
|
21
|
+
const yaml = getPrimaryOutput(output);
|
|
22
|
+
for (const { job, image, source } of extractImageRefs(yaml)) {
|
|
23
|
+
if (image.includes("@sha256:")) continue;
|
|
24
|
+
if (image.includes("$")) continue; // variable-based reference — not statically knowable
|
|
25
|
+
diagnostics.push({
|
|
26
|
+
checkId: "WGL031",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
message: `Job "${job}" ${source === "service" ? "service image" : "image"} "${image}" is not pinned to a digest — reference it by @sha256:... so the image cannot change after review.`,
|
|
29
|
+
entity: job,
|
|
30
|
+
lexicon: "gitlab",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return diagnostics;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl032, nearestKnownSource } from "./wgl032";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("WGL032: look-alike include source", () => {
|
|
20
|
+
test("nearestKnownSource flags a near-miss", () => {
|
|
21
|
+
expect(nearestKnownSource("gitlab-org/gitlab")).toBeUndefined();
|
|
22
|
+
expect(nearestKnownSource("gitlab-org/gitlabb")).toBe("gitlab-org/gitlab");
|
|
23
|
+
expect(nearestKnownSource("totally/unrelated")).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("flags a typo-squatted include:project", () => {
|
|
27
|
+
const yaml = `include:
|
|
28
|
+
- project: gitlab-org/gitlabb
|
|
29
|
+
ref: v1.0.0
|
|
30
|
+
file: /x.yml
|
|
31
|
+
|
|
32
|
+
stages:
|
|
33
|
+
- build
|
|
34
|
+
`;
|
|
35
|
+
const diags = wgl032.check(makeCtx(yaml));
|
|
36
|
+
expect(diags).toHaveLength(1);
|
|
37
|
+
expect(diags[0].checkId).toBe("WGL032");
|
|
38
|
+
expect(diags[0].message).toContain("gitlab-org/gitlab");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("does not flag an exact known source", () => {
|
|
42
|
+
const yaml = `include:
|
|
43
|
+
- project: gitlab-org/gitlab
|
|
44
|
+
ref: v1.0.0
|
|
45
|
+
file: /x.yml
|
|
46
|
+
|
|
47
|
+
stages:
|
|
48
|
+
- build
|
|
49
|
+
`;
|
|
50
|
+
const diags = wgl032.check(makeCtx(yaml));
|
|
51
|
+
expect(diags).toHaveLength(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL032: Include/Component Source Resembling a Well-Known Project
|
|
3
|
+
*
|
|
4
|
+
* Flags an `include:project` path or `component:` source that is a near-miss
|
|
5
|
+
* (edit distance 1–2) of a well-known GitLab CI source but not an exact match —
|
|
6
|
+
* a likely typo or an impersonation of the trusted project. Advisory; backed by
|
|
7
|
+
* a vendored reference list.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput, extractIncludes } from "./yaml-helpers";
|
|
12
|
+
import { KNOWN_INCLUDE_SOURCES, editDistance } from "../rules/data/known-include-sources";
|
|
13
|
+
|
|
14
|
+
const KNOWN = new Set(KNOWN_INCLUDE_SOURCES);
|
|
15
|
+
|
|
16
|
+
/** Reduce a component address (`host/group/name@ver`) or project path to its group/name path. */
|
|
17
|
+
function sourcePath(value: string, kind: string): string {
|
|
18
|
+
let v = value;
|
|
19
|
+
if (kind === "component") {
|
|
20
|
+
const at = v.lastIndexOf("@");
|
|
21
|
+
if (at !== -1) v = v.slice(0, at);
|
|
22
|
+
const slash = v.indexOf("/");
|
|
23
|
+
if (slash !== -1 && v.slice(0, slash).includes(".")) v = v.slice(slash + 1); // drop host
|
|
24
|
+
}
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function nearestKnownSource(path: string): string | undefined {
|
|
29
|
+
if (KNOWN.has(path)) return undefined;
|
|
30
|
+
let best: string | undefined;
|
|
31
|
+
let bestDist = 3;
|
|
32
|
+
for (const known of KNOWN) {
|
|
33
|
+
const d = editDistance(path, known, 2);
|
|
34
|
+
if (d >= 1 && d <= 2 && d < bestDist) {
|
|
35
|
+
best = known;
|
|
36
|
+
bestDist = d;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return best;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const wgl032: PostSynthCheck = {
|
|
43
|
+
id: "WGL032",
|
|
44
|
+
description: "Include/component source resembles a well-known project (possible impersonation)",
|
|
45
|
+
|
|
46
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
47
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
48
|
+
|
|
49
|
+
for (const [, output] of ctx.outputs) {
|
|
50
|
+
const yaml = getPrimaryOutput(output);
|
|
51
|
+
for (const entry of extractIncludes(yaml)) {
|
|
52
|
+
if (entry.kind !== "project" && entry.kind !== "component") continue;
|
|
53
|
+
const path = sourcePath(entry.value, entry.kind);
|
|
54
|
+
const lookAlike = nearestKnownSource(path);
|
|
55
|
+
if (!lookAlike) continue;
|
|
56
|
+
diagnostics.push({
|
|
57
|
+
checkId: "WGL032",
|
|
58
|
+
severity: "warning",
|
|
59
|
+
message: `Include source "${path}" closely resembles the well-known "${lookAlike}". Confirm the source is who you intend — this may be a typo or impersonation.`,
|
|
60
|
+
entity: entry.value,
|
|
61
|
+
lexicon: "gitlab",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return diagnostics;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wgl033 } from "./wgl033";
|
|
4
|
+
|
|
5
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
6
|
+
return {
|
|
7
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
8
|
+
entities: new Map(),
|
|
9
|
+
buildResult: {
|
|
10
|
+
outputs: new Map([["gitlab", yaml]]),
|
|
11
|
+
entities: new Map(),
|
|
12
|
+
warnings: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
sourceFileCount: 1,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("WGL033: OIDC id_token audience scoping", () => {
|
|
20
|
+
test("flags a wildcard audience", () => {
|
|
21
|
+
const yaml = `deploy:
|
|
22
|
+
stage: deploy
|
|
23
|
+
id_tokens:
|
|
24
|
+
GCP_TOKEN:
|
|
25
|
+
aud: '*'
|
|
26
|
+
script:
|
|
27
|
+
- echo deploy
|
|
28
|
+
`;
|
|
29
|
+
const diags = wgl033.check(makeCtx(yaml));
|
|
30
|
+
expect(diags).toHaveLength(1);
|
|
31
|
+
expect(diags[0].checkId).toBe("WGL033");
|
|
32
|
+
expect(diags[0].entity).toBe("deploy");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("flags an empty audience", () => {
|
|
36
|
+
const yaml = `deploy:
|
|
37
|
+
id_tokens:
|
|
38
|
+
GCP_TOKEN:
|
|
39
|
+
aud: ''
|
|
40
|
+
script:
|
|
41
|
+
- echo deploy
|
|
42
|
+
`;
|
|
43
|
+
const diags = wgl033.check(makeCtx(yaml));
|
|
44
|
+
expect(diags).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("does not flag a scoped audience", () => {
|
|
48
|
+
const yaml = `deploy:
|
|
49
|
+
id_tokens:
|
|
50
|
+
GCP_TOKEN:
|
|
51
|
+
aud: https://iam.googleapis.com
|
|
52
|
+
script:
|
|
53
|
+
- echo deploy
|
|
54
|
+
`;
|
|
55
|
+
const diags = wgl033.check(makeCtx(yaml));
|
|
56
|
+
expect(diags).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
});
|