Ctrl K
ComunidadGuías…

test/scripts/workflow-secrets.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import { filter, flatMap, map, unique } from "#fp";

const WORKFLOW_DIR = ".github/workflows";
const ACTION_DIR = ".github/actions";
const RESOLVE_ACTION_PATH = `${ACTION_DIR}/resolve-site-credentials/action.yml`;

/** A run input whose name says it carries a credential. */
const CREDENTIAL_INPUT = /inputs\.\w*(key|token|secret|password)\w*/gi;
/** A value the person who starts or triggers a run controls. */
const CALLER_VALUE = /\$\{\{[^}]*\b(inputs|github\.event|github\.head_ref)\b/;
/** The first line of a step's script. */
const RUN_KEY = /^(\s*(?:- )?)run:(.*)$/;

type YamlFile = { path: string; text: string };

const yamlFile = (path: string): YamlFile => ({
  path,
  text: Deno.readTextFileSync(path),
});

const workflowFiles = (): YamlFile[] =>
  [...Deno.readDirSync(WORKFLOW_DIR)]
    .map((entry) => yamlFile(`${WORKFLOW_DIR}/${entry.name}`))
    .sort((a, b) => a.path.localeCompare(b.path));

const actionFiles = (): YamlFile[] =>
  [...Deno.readDirSync(ACTION_DIR)]
    .map((entry) => yamlFile(`${ACTION_DIR}/${entry.name}/action.yml`))
    .sort((a, b) => a.path.localeCompare(b.path));

const isComment = (line: string): boolean => line.trim().startsWith("#");

const codeLines = (text: string): string[] =>
  text.split("\n").filter((line) => !isComment(line));

const indentOf = (line: string): number =>
  line.length - line.trimStart().length;

type RunScan = { scriptIndent: number | null; scriptLines: string[] };

/** Collects the lines of every `run:` script: the text on the key's own line,
 * and the deeper-indented block below it. */
const collectRunLine = (scan: RunScan, line: string): RunScan => {
  const runKey = RUN_KEY.exec(line);
  if (runKey) {
    const [, prefix, firstLine] = runKey;
    scan.scriptIndent = prefix!.length;
    scan.scriptLines.push(firstLine!);
    return scan;
  }
  if (scan.scriptIndent === null) return scan;
  if (line.trim() !== "" && indentOf(line) <= scan.scriptIndent) {
    scan.scriptIndent = null;
    return scan;
  }
  scan.scriptLines.push(line);
  return scan;
};

const runScriptLines = (text: string): string[] =>
  codeLines(text).reduce<RunScan>(collectRunLine, {
    scriptIndent: null,
    scriptLines: [],
  }).scriptLines;

/** Names each script line that expands a caller's value, as "path: line". */
const callerValuesInScripts = ({ path, text }: YamlFile): string[] =>
  runScriptLines(text)
    .filter((line) => CALLER_VALUE.test(line))
    .map((line) => `${path}: ${line.trim()}`);

const credentialInputs = ({ path, text }: YamlFile): string[] =>
  [...codeLines(text).join("\n").matchAll(CREDENTIAL_INPUT)].map(
    ([name]) => `${path}: ${name}`,
  );

/** The text of each job, which starts at a key two spaces under `jobs:`. */
const jobBlocks = (text: string): string[] => {
  const jobs = codeLines(text)
    .join("\n")
    .split(/^jobs:\n/m)[1];
  if (jobs === undefined) throw new Error("The workflow declares no jobs");
  return jobs.split(/^ {2}(?=[\w-]+:)/m).filter((block) => block !== "");
};

const readsSecrets = (job: string): boolean => /\bsecrets\.[A-Z_]+/.test(job);

const hasEnvironment = (job: string): boolean =>
  /^ {4}environment: \S+/m.test(job);

const jobName = (job: string): string => job.slice(0, job.indexOf(":"));

/** Names each job that reads a secret outside an environment. */
const secretJobsWithoutEnvironment = ({ path, text }: YamlFile): string[] =>
  jobBlocks(text)
    .filter((job) => readsSecrets(job) && !hasEnvironment(job))
    .map((job) => `${path}: ${jobName(job)}`);

const secretNames = (text: string): string[] =>
  unique([...text.matchAll(/\bsecrets\.([A-Z_]+)/g)].map(([, name]) => name!));

const isDeployJob = (job: string): boolean =>
  /^ {4}environment: (staging|production)$/m.test(job);

/** The steps of a job, each without the `- ` that starts it. */
const jobSteps = (job: string): string[] => job.split(/^ {6}- /m).slice(1);

const isSecretCheck = (step: string): boolean =>
  step.startsWith("name: Check the secrets this run needs\n");

const usesRepositoryAction = (step: string): boolean =>
  step.includes("uses: ./.github/actions/");

/** The check for values that are not set, then the check for wrong values. */
const CHECK_SCRIPTS = [
  "scripts/ci/require-env.sh",
  "scripts/ci/check-credentials.sh",
];

/** Says what is wrong with the secret check of one deploy job. */
const secretCheckProblems = (job: string): string[] => {
  const steps = jobSteps(job);
  const checkAt = steps.findIndex(isSecretCheck);
  if (checkAt < 0) return ["has no secret check"];
  const firstAction = steps.findIndex(usesRepositoryAction);
  const check = steps[checkAt]!;
  const checked = secretNames(check);
  const order =
    firstAction >= 0 && firstAction < checkAt
      ? ["runs a repository action before the secret check"]
      : [];
  return order.concat(
    CHECK_SCRIPTS.filter((script) => !check.includes(script)).map(
      (script) => `does not run ${script}`,
    ),
    secretNames(job)
      .filter((name) => !checked.includes(name))
      .map((name) => `does not check ${name}`),
  );
};

const problemsOfJob =
  (path: string) =>
  (job: string): string[] =>
    secretCheckProblems(job).map(
      (problem) => `${path}: ${jobName(job)} ${problem}`,
    );

/** Names each deploy job whose secret check is absent, late, or incomplete. */
const deploySecretCheckProblems = ({ path, text }: YamlFile): string[] =>
  flatMap(problemsOfJob(path))(jobBlocks(text).filter(isDeployJob));

const DEPLOY_JOBS_FIXTURE = `jobs:
  unchecked:
    environment: production
    steps:
      - run: deploy
        env:
          KEY: \${{ secrets.DEPLOY_KEY }}
  late:
    environment: staging
    steps:
      - uses: ./.github/actions/build
        with:
          zone: \${{ secrets.ZONE }}
      - name: Check the secrets this run needs
        env:
          KEY: \${{ secrets.DEPLOY_KEY }}
        run: scripts/ci/require-env.sh KEY
  complete:
    environment: production
    steps:
      - name: Check the secrets this run needs
        env:
          KEY: \${{ secrets.DEPLOY_KEY }}
        run: |
          scripts/ci/require-env.sh KEY
          scripts/ci/check-credentials.sh bunny
      - uses: ./.github/actions/build
        with:
          key: \${{ secrets.DEPLOY_KEY }}
  sandbox:
    environment: sandbox
    steps:
      - run: test
        env:
          KEY: \${{ secrets.SANDBOX_KEY }}`;

const SCRIPT_FIXTURE = `      - name: Example
        env:
          NAME: \${{ inputs.name }}
        run: |
          echo "\${{ github.event.issue.title }}"

          echo "$NAME"
      - run: echo \${{ inputs.name }}
      - name: Next
        with:
          ref: \${{ inputs.ref }}`;

const JOBS_FIXTURE = `jobs:
  open:
    runs-on: ubuntu-latest
    steps:
      - run: deploy
        env:
          KEY: \${{ secrets.DEPLOY_KEY }}
  guarded:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: deploy
        env:
          KEY: \${{ secrets.DEPLOY_KEY }}
  plain:
    runs-on: ubuntu-latest
    steps:
      - run: test`;

/** A line of the credentials action that masks or outputs a database value. */
const DATABASE_MASK_OR_OUTPUT = /^echo "(::add-mask::\$db_|db_\w+=)/;

describe("workflow secrets", () => {
  // A repository secret is open to every branch a person pushes. An
  // environment secret is open only to the branches the environment allows.
  test("every job that reads a secret names an environment", () => {
    expect(flatMap(secretJobsWithoutEnvironment)(workflowFiles())).toEqual([]);
  });

  // A deploy that finds a missing secret part of the way through leaves a
  // site in update mode. The check runs before the build and names every
  // secret the job reads, so a new secret cannot skip it.
  test("every deploy job checks each secret it reads before the build", () => {
    expect(flatMap(deploySecretCheckProblems)(workflowFiles())).toEqual([]);
  });

  test("the scan names a deploy job with an absent, late, or partial check", () => {
    const found = deploySecretCheckProblems({
      path: "example.yml",
      text: DEPLOY_JOBS_FIXTURE,
    });

    expect(found).toEqual([
      "example.yml: unchecked has no secret check",
      "example.yml: late runs a repository action before the secret check",
      "example.yml: late does not run scripts/ci/check-credentials.sh",
      "example.yml: late does not check ZONE",
    ]);
  });

  // GitHub writes a run input to the public job log. Only a secret is masked.
  test("no workflow reads a credential from a run input", () => {
    expect(flatMap(credentialInputs)(workflowFiles())).toEqual([]);
  });

  // GitHub pastes an expression into the script before the shell reads it,
  // so a caller's value there runs as code. An `env:` entry stays data.
  test("no script expands a value the caller controls", () => {
    const files = [...workflowFiles(), ...actionFiles()];
    expect(flatMap(callerValuesInScripts)(files)).toEqual([]);
  });

  test("the scan finds a caller's value in a script and ignores env", () => {
    const found = callerValuesInScripts({
      path: "example.yml",
      text: SCRIPT_FIXTURE,
    });

    // The block script line and the one-line script, not the env or with.
    const [, , , , blockLine, , , oneLine] = SCRIPT_FIXTURE.split("\n");
    expect(found).toEqual([
      `example.yml: ${blockLine!.trim()}`,
      `example.yml: ${oneLine!.trim().replace("- run: ", "")}`,
    ]);
  });

  test("the scan names a job that reads a secret with no environment", () => {
    const found = secretJobsWithoutEnvironment({
      path: "example.yml",
      text: JOBS_FIXTURE,
    });

    expect(found).toEqual(["example.yml: open"]);
  });

  // A step output is not a secret. GitHub prints it in the env of the step
  // that reads it unless a mask names the value first.
  test("the credentials action masks each credential before it is an output", () => {
    const lines = map((line: string) => line.trim())(
      yamlFile(RESOLVE_ACTION_PATH).text.split("\n"),
    );

    expect(
      filter((line: string) => DATABASE_MASK_OR_OUTPUT.test(line))(lines),
    ).toEqual([
      'echo "::add-mask::$db_url"',
      'echo "::add-mask::$db_token"',
      'echo "db_url=$db_url" >> "$GITHUB_OUTPUT"',
      'echo "db_token=$db_token" >> "$GITHUB_OUTPUT"',
    ]);
  });
});