/** Blanket permission scalars that grant far more than any one job needs. */
const BLANKET_PERMISSIONS = new Set(["write-all", "read-all"]);
/** Matches every `secrets.NAME` reference inside a job's declared text. */
const SECRET_REFERENCE = /secrets\.(\w+)/g;
/** Static credential names and inputs that keyless or scoped auth should replace. */
const STATIC_CREDENTIAL_KEYS = [
    "aws-access-key-id",
    "aws-secret-access-key",
    "NPM_TOKEN",
    "GCP_SERVICE_ACCOUNT_JSON",
    "GOOGLE_APPLICATION_CREDENTIALS",
    "AZURE_CREDENTIALS",
    "ADMIN_PAT",
    "BOT_ADMIN_PAT",
    "GH_ADMIN_PAT",
];
/** Static cloud credential inputs that OIDC (`id-token: write`) can replace. */
const OIDC_REPLACEABLE_KEYS = new Set([
    "aws-access-key-id",
    "aws-secret-access-key",
    "GCP_SERVICE_ACCOUNT_JSON",
    "GOOGLE_APPLICATION_CREDENTIALS",
    "AZURE_CREDENTIALS",
]);
/** Matches a repository-token reference in either of its spellings. */
const REPOSITORY_TOKEN = /\b(?:GITHUB_TOKEN|GH_TOKEN)\b|github\.token/;
/** A checkout ref that replaces trusted base code with untrusted PR-head code. */
const PULL_REQUEST_HEAD_REF = /github\.event\.pull_request\.head\.(?:sha|ref)|github\.head_ref/;
/** Commands that execute code from the checkout rather than only inspecting metadata. */
const CHECKED_OUT_CODE_EXECUTION = [
    /\b(?:npm|yarn|pnpm|bun|npx|bunx)\s+(?:install|ci|test|run|exec|x)\b/,
    /\b(?:make|pytest|rspec|bundle|cargo|go|mvn|gradle)\s+(?:test|build|run|install)\b/,
    /\b(?:bash|sh)\s+\.\//,
    /(?:^|\s)\.\/(?:scripts\/)?[\w./-]+/,
];
/**
 * Flatten a job's `secrets:` block to `key: value` lines for text search.
 *
 * The `inherit` scalar is deliberately excluded: it carries no secret names to
 * search for, and is already reported on its own by
 * {@link inheritedSecretViolations}.
 * @param secrets - The job's `secrets` block
 * @returns Newline-joined `key: value` lines (empty when there is nothing to flatten)
 */
function secretsBlockText(secrets) {
    if (typeof secrets !== "object" || secrets === null) {
        return "";
    }
    return Object.entries(secrets)
        .map(([name, entry]) => `${name}: ${typeof entry === "string" ? entry : String(entry)}`)
        .join("\n");
}
/**
 * Flatten every string a job declares — step commands, actions, `env`,
 * `with`, and reusable-call `secrets:` mappings — into one searchable blob so
 * a credential reference is found wherever it is stated.
 * @param job - The parsed job
 * @returns Newline-joined declared text
 */
function jobText(job) {
    return [
        job.uses,
        job.env,
        job.inputs,
        secretsBlockText(job.secrets),
        ...job.steps.flatMap(step => [step.run, step.uses, step.inputs]),
    ]
        .filter(part => part !== "")
        .join("\n");
}
/**
 * Whether a permissions block grants OIDC token minting, which is what makes a
 * keyless cloud login possible.
 * @param permissions - A job- or workflow-level permissions block
 * @returns True when `id-token` is granted write
 */
function grantsOidc(permissions) {
    return (typeof permissions === "object" &&
        permissions !== null &&
        permissions["id-token"] === "write");
}
/**
 * Whether a permissions block grants write authority to the repository token.
 * @param permissions - A job- or workflow-level permissions block
 * @returns True when at least one declared scope is writable
 */
function grantsWriteAuthority(permissions) {
    if (permissions === "write-all") {
        return true;
    }
    if (typeof permissions !== "object" || permissions === null) {
        return false;
    }
    return Object.values(permissions).some(value => value === "write");
}
/**
 * Report a blanket `write-all`/`read-all` grant at either scope.
 * @param scope - Human-readable scope label for the evidence line
 * @param permissions - The permissions block to inspect
 * @returns Evidence lines (empty when the grant is scoped)
 */
function blanketGrantViolations(scope, permissions) {
    if (typeof permissions === "string" && BLANKET_PERMISSIONS.has(permissions)) {
        return [
            `${scope} declares \`permissions: ${permissions}\`, granting every ` +
                "scope the repository token can carry instead of only what the job needs",
        ];
    }
    return [];
}
/**
 * Report an inherited secret block, which hands a called workflow every secret
 * the repository holds rather than the ones it needs.
 * @param where - Evidence location label
 * @param job - The parsed job
 * @returns Evidence lines (empty when secrets are mapped explicitly)
 */
function inheritedSecretViolations(where, job) {
    return job.secrets === "inherit"
        ? [
            `${where} declares \`secrets: inherit\`, handing the called workflow ` +
                "every secret in the repository rather than the ones it needs",
        ]
        : [];
}
/**
 * Report a job that uses the repository token with no `permissions:` block at
 * either scope, so it runs at the repository default instead of a stated
 * minimum.
 * @param where - Evidence location label
 * @param workflow - The workflow declaring the job
 * @param job - The parsed job
 * @param text - The job's flattened declared text
 * @returns Evidence lines (empty when permissions are declared)
 */
function unscopedTokenViolations(where, workflow, job, text) {
    const token = REPOSITORY_TOKEN.exec(text)?.[0];
    const unscoped = token !== undefined &&
        job.permissions === null &&
        workflow.permissions === null;
    return unscoped
        ? [
            `${where} uses ${token} with no \`permissions:\` block at either ` +
                "scope, so it runs with the repository default rather than a stated minimum",
        ]
        : [];
}
/**
 * Report static long-lived cloud keys in a job that could have authenticated
 * keylessly through OIDC.
 * @param where - Evidence location label
 * @param workflow - The workflow declaring the job
 * @param job - The parsed job
 * @param text - The job's flattened declared text
 * @returns Evidence lines (empty when the job is keyless or already OIDC-enabled)
 */
function staticKeyViolations(where, workflow, job, text) {
    const oidcGranted = grantsOidc(job.permissions) || grantsOidc(workflow.permissions);
    const staticKey = STATIC_CREDENTIAL_KEYS.find(key => text.includes(key) && !(oidcGranted && OIDC_REPLACEABLE_KEYS.has(key)));
    if (staticKey === undefined) {
        return [];
    }
    return [
        `${where} authenticates with the static long-lived credential ` +
            `\`${staticKey}\` instead of a scoped or federated deployment credential`,
    ];
}
/**
 * Whether the job maps any secret material into the run.
 * @param job - Parsed workflow job
 * @param text - Flattened job text
 * @returns True when secret authority is declared in YAML
 */
function hasDeclaredSecretAuthority(job, text) {
    return (job.secrets !== null ||
        text.includes("secrets.") ||
        STATIC_CREDENTIAL_KEYS.some(key => text.includes(key)));
}
/**
 * Whether the job checks out attacker-controlled PR-head code.
 * @param job - Parsed workflow job
 * @returns True when actions/checkout is aimed at the PR head
 */
function checksOutPullRequestHead(job) {
    return job.steps.some(step => step.uses.toLowerCase().split("@")[0] === "actions/checkout" &&
        PULL_REQUEST_HEAD_REF.test(step.inputs));
}
/**
 * Whether checked-out code runs after the PR-head checkout step.
 * @param job - Parsed workflow job
 * @returns True when a later shell step executes project code
 */
function runsCheckedOutCodeAfterPullRequestCheckout(job) {
    const checkoutIndex = job.steps.findIndex(step => step.uses.toLowerCase().split("@")[0] === "actions/checkout" &&
        PULL_REQUEST_HEAD_REF.test(step.inputs));
    return (checkoutIndex >= 0 &&
        job.steps
            .slice(checkoutIndex + 1)
            .some(step => CHECKED_OUT_CODE_EXECUTION.some(pattern => pattern.test(step.run))));
}
/**
 * Report CICD-SEC-4 poisoned-pipeline execution that runs untrusted PR code
 * inside a privileged `pull_request_target` context.
 * @param where - Evidence location label
 * @param workflow - The workflow declaring the job
 * @param job - The parsed job
 * @param text - Flattened job text
 * @returns Evidence lines (empty when the provable dangerous shape is absent)
 */
function poisonedPipelineViolations(where, workflow, job, text) {
    if (!workflow.on.events.includes("pull_request_target")) {
        return [];
    }
    if (!checksOutPullRequestHead(job) ||
        !runsCheckedOutCodeAfterPullRequestCheckout(job)) {
        return [];
    }
    const authority = grantsWriteAuthority(job.permissions) ||
        grantsWriteAuthority(workflow.permissions) ||
        hasDeclaredSecretAuthority(job, text);
    if (!authority) {
        return [];
    }
    return [
        `${where} runs under \`pull_request_target\`, checks out ` +
            "`github.event.pull_request.head` code, then executes that checked-out " +
            "code while write permissions or secrets are declared in scope (CICD-SEC-4)",
    ];
}
/**
 * Detect per-job credential over-authority inside one workflow.
 * @param workflow - The parsed workflow
 * @param job - The job to inspect
 * @returns Evidence lines for this job (empty when scoped correctly)
 */
function jobCredentialViolations(workflow, job) {
    const where = `${workflow.file} job \`${job.id}\``;
    const text = jobText(job);
    return [
        ...blanketGrantViolations(where, job.permissions),
        ...inheritedSecretViolations(where, job),
        ...unscopedTokenViolations(where, workflow, job, text),
        ...staticKeyViolations(where, workflow, job, text),
        ...poisonedPipelineViolations(where, workflow, job, text),
    ];
}
/**
 * Pair every secret reference with each deployment environment that uses it.
 * @param workflows - Parsed workflows
 * @returns One pair per (secret, environment) reference
 */
function secretEnvironmentPairs(workflows) {
    const jobs = workflows.flatMap(workflow => workflow.jobs);
    return jobs
        .filter(job => job.environment.length > 0)
        .flatMap(job => jobSecretEnvironmentPairs(job));
}
/**
 * Pair one job's secret references with the environments that job deploys to.
 * @param job - A job declaring at least one environment
 * @returns One pair per (secret, environment) reference in this job
 */
function jobSecretEnvironmentPairs(job) {
    const secrets = [...jobText(job).matchAll(SECRET_REFERENCE)]
        .map(match => match[1] ?? "")
        // GITHUB_TOKEN is minted fresh per job per run, so it cannot be "shared"
        // between environments — reporting it as such is impossible by construction.
        .filter(secret => secret !== "GITHUB_TOKEN");
    return secrets.flatMap(secret => job.environment.map(environment => ({ secret, environment })));
}
/**
 * Sort and de-duplicate a list of names for stable evidence text.
 * @param names - Raw names
 * @returns Unique names in locale order
 */
function uniqueSorted(names) {
    return [...new Set(names)].sort((a, b) => a.localeCompare(b));
}
/**
 * Note each secret NAME that appears under more than one deployment environment.
 *
 * This is informational, never a standing blocker. GitHub environment secrets
 * are *supposed* to reuse one name across environments — that is the recommended
 * least-privilege pattern, with a distinct value stored per environment — and
 * whether a given name resolves to a repository-scoped secret (genuinely shared)
 * or an environment-scoped one (correctly isolated) is not decidable from the
 * YAML. Blocking on it would fail correct repositories for doing the right thing.
 * @param workflows - Parsed workflows
 * @returns Observation lines (empty when no name is reused)
 */
function sharedSecretObservations(workflows) {
    const pairs = secretEnvironmentPairs(workflows);
    return uniqueSorted(pairs.map(pair => pair.secret)).flatMap(secret => {
        const environments = uniqueSorted(pairs.filter(pair => pair.secret === secret).map(pair => pair.environment));
        return environments.length > 1
            ? [
                `secret name \`${secret}\` appears under ${environments.length} ` +
                    `deployment environments (${environments.join(", ")}). If it is an ` +
                    "environment-scoped secret this is the recommended pattern; if it " +
                    "is repository-scoped, one credential spans every environment. " +
                    "Which one it is cannot be determined from the workflow files.",
            ]
            : [];
    });
}
/**
 * Detect credential over-authority across the repository's workflows.
 *
 * Violations are ordered by consequence: blanket grants and inherited secrets
 * first (they are unbounded), then narrower over-authority. Anything the
 * workflow files cannot settle lands in `informational`, which carries no
 * `blocker` key and therefore can never flip a verdict.
 * @param workflows - Parsed workflows
 * @returns Violations and informational observations
 */
export function detectCredentialFindings(workflows) {
    return {
        violations: [
            ...workflows.flatMap(workflow => blanketGrantViolations(workflow.file, workflow.permissions)),
            ...workflows.flatMap(workflow => workflow.jobs.flatMap(job => jobCredentialViolations(workflow, job))),
        ],
        informational: sharedSecretObservations(workflows),
    };
}
//# sourceMappingURL=doctor-readiness-credentials.js.map