AID-H-023: Publisher Integrity & Workflow Hardening

Version 1.20261015 · Actionable technique

A critical supply chain defense that ensures software packages (e.g., NPM, PyPI) are published only from a secure, auditable, and authorized source. This technique reduces credential-only publishing attacks where a stolen developer credential is used to publish a malicious version of a legitimate package. It achieves this by prohibiting publications from local developer machines and mandating that all releases originate from a hardened CI/CD pipeline that authenticates using short-lived, identity-based tokens and generates verifiable provenance attestations.

Pillars: infra

Lifecycle: building, validation

Scope boundary

Owns authorized publisher identity, registry namespace, protected CI publication, short-lived publication authority, and independent post-publication readback of the exact producer release bytes and provenance. It does not admit third-party dependency populations or authorize their use.

AID-H-022: AID-H-023 proves who published a package and through which authorized release workflow; AID-H-022 reviews dependency-change risk and contains installation behavior. Trusted provenance does not make a change safe, and safe installation does not prove trusted publication origin.

AID-H-003.001: AID-H-023 establishes publisher and release-path provenance; AID-H-003.001 makes the final admission decision for the exact dependency bytes and complete population consumed by build or deployment. A valid publisher attestation is one admission signal, not authorization to consume the package.

Implementation guidance

AID-H-023-G001

Mandate CI/CD-only publishing using OIDC trusted publishing for npm and PyPI.
Concept:

Use registry trusted publishing so npm and PyPI trust a protected CI/CD workflow through OIDC instead of long-lived publish tokens. This removes developer-machine publishing from the normal path and makes release identity auditable. The resulting provenance or attestation is evidence of publisher identity and build workflow; it does not prove that the source code is benign.

npm trusted publishing workflow
# File: .github/workflows/publish-npm.yml

name: Publish Package to npm
on:
  release:
    types: [created]

jobs:
  build-npm:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
        with:
          node-version: '24'
          package-manager-cache: false
      - run: npm install --global --ignore-scripts npm@11.17.0
      - run: npm ci --ignore-scripts
      - run: npm pack --ignore-scripts --json > npm-pack.json
      - name: Record release evidence and artifact digest
        env:
          AIDEFEND_RELEASE_ENVIRONMENT: production
        run: |
          python3 scripts/write_release_publisher_evidence.py
          sha256sum "$(jq -er '.[0].filename' npm-pack.json)" > npm-release.sha256
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: npm-release
          path: |
            *.tgz
            npm-pack.json
            npm-release.sha256
            artifacts/publisher-integrity-release-evidence.json
          if-no-files-found: error
          retention-days: 1

  publish-npm:
    needs: build-npm
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    environment: production
    steps:
      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
        with:
          node-version: '24'
          registry-url: 'https://registry.npmjs.org'
          package-manager-cache: false
      - run: npm install --global --ignore-scripts npm@11.17.0
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: npm-release
          path: release
      - name: Verify and publish the exact transferred tarball
        run: |
          cd release
          sha256sum --check npm-release.sha256
          TARBALL="$(jq -er '.[0].filename' npm-pack.json)"
          test -f "$TARBALL"
          npm publish "$TARBALL" --ignore-scripts --provenance
PyPI trusted publishing workflow
# File: .github/workflows/publish-pypi.yml
name: Publish Package to PyPI
on:
  release:
    types: [created]

jobs:
  build-pypi:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.12"
      - run: python -m pip install --require-hashes --requirement .github/release-requirements.txt
      - run: python -m build
      - name: Record release evidence and artifact digests
        env:
          AIDEFEND_RELEASE_ENVIRONMENT: pypi-production
        run: |
          python scripts/write_release_publisher_evidence.py
          sha256sum dist/* > pypi-release.sha256
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: pypi-release
          path: |
            dist/
            pypi-release.sha256
            artifacts/publisher-integrity-release-evidence.json
          if-no-files-found: error
          retention-days: 1

  publish-pypi:
    needs: build-pypi
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    environment: pypi-production
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: pypi-release
          path: release
      - name: Verify transferred distributions
        run: |
          cd release
          sha256sum --check pypi-release.sha256
      - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
        with:
          packages-dir: release/dist/
Release evidence
# File: scripts/write_release_publisher_evidence.py
from __future__ import annotations

import hashlib
import json
import os
from pathlib import Path


def sha256_file(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def main() -> None:
    dist_files = sorted(
        path for path in [*Path(".").glob("*.tgz"), *Path("dist").glob("*")]
        if path.is_file()
    )
    if not dist_files:
        raise SystemExit("no release artifacts found")
    evidence = {
        "ci_provider": "github_actions",
        "expected_authentication": "registry_trusted_publishing_oidc",
        "workflow": os.environ["GITHUB_WORKFLOW"],
        "repository": os.environ["GITHUB_REPOSITORY"],
        "ref": os.environ["GITHUB_REF"],
        "commit_sha": os.environ["GITHUB_SHA"],
        "run_id": os.environ["GITHUB_RUN_ID"],
        "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"],
        "expected_environment": os.environ["AIDEFEND_RELEASE_ENVIRONMENT"],
        "artifacts": [
            {"path": str(path), "sha256": sha256_file(path)}
            for path in dist_files
        ],
    }
    Path("artifacts").mkdir(exist_ok=True)
    Path("artifacts/publisher-integrity-release-evidence.json").write_text(
        json.dumps(evidence, indent=2, sort_keys=True) + "\n",
        encoding="utf-8",
    )


if __name__ == "__main__":
    main()

The workflow evidence above is correlation data, not proof that OIDC was used: GITHUB_* values are self-reported by the job. After publication, independently download the exact registry artifact, run the ecosystem-native verification from the AID-H-023-G003, and compare an authenticated registry trusted-publisher configuration readback through the supported owner API/CLI or independently captured authenticated settings view with the approved repository, workflow filename, environment, and allowed action. Missing registry readback or provenance is INSUFFICIENT_DATA; a mismatch is FAIL.

Action: Prohibit package publishing from developer machines. Bind npm and PyPI releases to protected CI workflows with OIDC trusted publishing, protected environments, reviewed workflow changes, artifact digests, and artifacts/publisher-integrity-release-evidence.json; complete the publication only after the independent exact-release registry readback verifies the published bytes and publisher configuration.

AID-H-023-G002

Harden the CI/CD workflow definition to prevent tampering and enforce least privilege.

Apply this control to every repository and protected environment that can publish a package. Reuse the branch/ruleset enforcement and authenticated readback mechanism from AID-H-021.001 rather than creating a second repository-control implementation. This guidance owns the release-specific composition: workflow ownership, immutable action references, least-privilege job permissions, protected environment, and registry-publisher binding.

Normalize release-control readback
# File: release_security/evaluate_release_controls.py
from __future__ import annotations

import re

SHA = re.compile(r"^[0-9a-f]{40}$")
SHA256 = re.compile(r"^[0-9a-f]{64}$")
FIELDS = {
    "repository", "default_branch", "branch_ruleset_id", "branch_ruleset_sha256",
    "workflow_path", "workflow_sha256", "codeowners_sha256",
    "workflow_owned_by_release_security", "all_actions_sha_pinned",
    "default_workflow_permissions", "can_actions_approve_pull_requests",
    "environment", "environment_has_required_reviewers",
    "environment_prevents_self_review", "publisher_binding_matches",
    "readback_actor", "readback_at",
}


def evaluate(snapshot: dict) -> dict:
    if not isinstance(snapshot, dict) or set(snapshot) != FIELDS:
        return {"status": "ERROR", "reason": "release_readback_schema_mismatch"}
    for key in ("branch_ruleset_sha256", "workflow_sha256", "codeowners_sha256"):
        if not isinstance(snapshot[key], str) or not SHA256.fullmatch(snapshot[key]):
            return {"status": "ERROR", "reason": "invalid_readback_digest", "field": key}
    if not isinstance(snapshot["branch_ruleset_id"], int) or snapshot["branch_ruleset_id"] <= 0:
        return {"status": "ERROR", "reason": "branch_ruleset_missing"}
    required_true = (
        "workflow_owned_by_release_security", "all_actions_sha_pinned",
        "environment_has_required_reviewers", "environment_prevents_self_review",
        "publisher_binding_matches",
    )
    if any(type(snapshot[key]) is not bool for key in required_true):
        return {"status": "ERROR", "reason": "release_control_type_mismatch"}
    if type(snapshot["can_actions_approve_pull_requests"]) is not bool:
        return {"status": "ERROR", "reason": "workflow_permission_type_mismatch"}
    failures = [key for key in required_true if snapshot[key] is not True]
    if snapshot["default_workflow_permissions"] != "read":
        failures.append("default_workflow_permissions")
    if snapshot["can_actions_approve_pull_requests"]:
        failures.append("actions_may_approve_pull_requests")
    return {
        "schema_version": "aidefend.h023-release-controls.v1",
        "status": "FAIL" if failures else "PASS",
        "repository": snapshot["repository"],
        "workflow_path": snapshot["workflow_path"],
        "workflow_sha256": snapshot["workflow_sha256"],
        "environment": snapshot["environment"],
        "branch_ruleset_id": snapshot["branch_ruleset_id"],
        "branch_ruleset_sha256": snapshot["branch_ruleset_sha256"],
        "failures": failures,
        "readback_actor": snapshot["readback_actor"],
        "readback_at": snapshot["readback_at"],
    }
Populate the snapshot from authoritative controls
Verification and evidence

On every release and workflow change, store the raw authenticated API responses, workflow/CODEOWNERS bytes, parser version, normalized snapshot, decision, and enforcement readback in append-only storage. An independent identity replays the evaluation and confirms the platform actually blocked an unapproved workflow change and withheld environment approval. Missing API coverage or a provider field that cannot be read is INSUFFICIENT_DATA, not PASS.

Action: Make the normalized release-control decision a required pre-publish check. Block publication unless the repository ruleset, workflow ownership, action pins, default token permissions, protected environment, and registry trusted-publisher binding all read back as the approved state.

AID-H-023-G003

Independently read back the publisher’s own exact registry release and verify its bytes, native provenance, and trusted CI publisher binding.
Independent verification of the publisher’s own release

After publication, independently download the exact package/version produced by the protected CI workflow, compare its digest with the signed pre-publication release manifest, and verify native provenance, then separately read back the authenticated trusted-publisher configuration. npm and PyPI are alternative adapters. This is publisher assurance, not third-party package admission; AID-H-003.001 owns the latter. A failed readback means the publication is not verified, blocks subsequent release completion or publication work, and raises a release incident; it does not pretend already-published bytes were recalled.

npm exact-release readback
#!/usr/bin/env bash
set -euo pipefail
umask 077
: "$NPM_PACKAGE"
: "$NPM_VERSION"
: "$EXPECTED_REPOSITORY"
: "${EXPECTED_WORKFLOW:?exact workflow path from the signed producer manifest}"
: "${AIDEFEND_H023_EXPECTED_ARTIFACT_SHA256:?digest from the signed producer release manifest}"
: "${AIDEFEND_H023_NPM_OPERATION_TIMEOUT_SECONDS:?exported from the verified package-lock policy}"
: "${AIDEFEND_H023_NPM_OPERATION_KILL_AFTER_SECONDS:?exported from the verified package-lock policy}"
positive_decimal() {
  local value="$1"
  [[ "$value" =~ ^([0-9]+([.][0-9]*)?|[.][0-9]+)$ ]] \
    && awk -v value="$value" 'BEGIN { exit !(value > 0) }'
}
for name in \
  AIDEFEND_H023_NPM_OPERATION_TIMEOUT_SECONDS \
  AIDEFEND_H023_NPM_OPERATION_KILL_AFTER_SECONDS; do
  if ! positive_decimal "${!name}"; then
    echo "$name must be a finite positive number" >&2
    exit 2
  fi
done
run_npm() {
  timeout --signal=TERM \
    --kill-after="${AIDEFEND_H023_NPM_OPERATION_KILL_AFTER_SECONDS}s" \
    "${AIDEFEND_H023_NPM_OPERATION_TIMEOUT_SECONDS}s" \
    npm "$@"
}
WORKDIR=$(mktemp -d)
trap 'rm -rf "$WORKDIR"' EXIT
cd "$WORKDIR"
run_npm init --yes >/dev/null
run_npm install --ignore-scripts --save-exact "$NPM_PACKAGE@$NPM_VERSION"
run_npm audit signatures --json --include-attestations > npm-signature-verification.json
ACTUAL_REPOSITORY=$(run_npm view "$NPM_PACKAGE@$NPM_VERSION" repository.url --json | jq -er 'if type == "string" then . else .url end')
python3 /opt/aidefend/supply_chain/verify_npm_provenance_policy.py \
  npm-signature-verification.json --package "$NPM_PACKAGE" --version "$NPM_VERSION" \
  --repository "https://github.com/$EXPECTED_REPOSITORY" --workflow "$EXPECTED_WORKFLOW" \
  --metadata-repository "$ACTUAL_REPOSITORY" > npm-provenance-policy.json
run_npm pack "$NPM_PACKAGE@$NPM_VERSION" --ignore-scripts --json > npm-pack.json
TARBALL=$(jq -er '.[0].filename' npm-pack.json)
sha256sum "$TARBALL" > npm-artifact.sha256
printf '%s  %s\n' "$AIDEFEND_H023_EXPECTED_ARTIFACT_SHA256" "$TARBALL" | sha256sum --check --strict -

Use the pinned native verifier image and the signed producer release manifest to select the package, version, repository and expected artifact digest. The npm lockfile is a verifier input; its dependency entries do not earn consumer-admission coverage here. Retain native verifier output and exit status. Install the exact supply_chain/verify_npm_provenance_policy.py adapter from AID-H-003.001-G003 in this worker's immutable image. This is reuse of the native-output parser, without granting dependency-admission credit. The npm_verification function below checks the signed producer manifest, actual downloaded digest and successful verifier exit, then emits the precise verification record for the pinned verification worker to sign. Call it before this temporary workspace is removed, and retain the native bytes under its recorded digest. Record the actual pinned npm version, never a version copied from package metadata.

PyPI exact-release readback
#!/usr/bin/env bash
set -euo pipefail
umask 077

required=(
  AIDEFEND_H023_PYPI_FILE_URL
  AIDEFEND_H023_EXPECTED_REPOSITORY_URL
  AIDEFEND_H023_EXPECTED_ARTIFACT_SHA256
  AIDEFEND_H023_ATTESTATION_TIMEOUT_SECONDS
  AIDEFEND_H023_ATTESTATION_KILL_AFTER_SECONDS
  AIDEFEND_H023_DOWNLOAD_TIMEOUT_SECONDS
  AIDEFEND_H023_DOWNLOAD_MAX_BYTES
)
for name in "${required[@]}"; do
  if [[ -z "${!name:-}" ]]; then
    echo "$name is required from the verified signed lock policy" >&2
    exit 2
  fi
done

positive_decimal() {
  local value="$1"
  [[ "$value" =~ ^([0-9]+([.][0-9]*)?|[.][0-9]+)$ ]] \
    && awk -v value="$value" 'BEGIN { exit !(value > 0) }'
}
for name in \
  AIDEFEND_H023_ATTESTATION_TIMEOUT_SECONDS \
  AIDEFEND_H023_ATTESTATION_KILL_AFTER_SECONDS \
  AIDEFEND_H023_DOWNLOAD_TIMEOUT_SECONDS; do
  if ! positive_decimal "${!name}"; then
    echo "$name must be a finite positive number" >&2
    exit 2
  fi
done
if ! [[ "$AIDEFEND_H023_DOWNLOAD_MAX_BYTES" =~ ^[1-9][0-9]*$ ]]; then
  echo "download maximum bytes must be a positive integer" >&2
  exit 2
fi
if ! [[ "$AIDEFEND_H023_EXPECTED_ARTIFACT_SHA256" =~ ^[0-9a-f]{64}$ ]] \
  || [[ "$AIDEFEND_H023_EXPECTED_ARTIFACT_SHA256" =~ ^0{64}$ ]]; then
  echo "expected artifact SHA-256 must be lowercase, nonzero, and complete" >&2
  exit 2
fi

python3 - \
  "$AIDEFEND_H023_PYPI_FILE_URL" \
  "$AIDEFEND_H023_EXPECTED_REPOSITORY_URL" <<'PY'
import sys
from urllib.parse import urlsplit

for label, raw in zip(("distribution", "repository"), sys.argv[1:], strict=True):
    try:
        parsed = urlsplit(raw)
        port = parsed.port
    except ValueError as exc:
        raise SystemExit(f"{label} URL is invalid: {exc}")
    if (
        parsed.scheme != "https"
        or not parsed.hostname
        or parsed.username is not None
        or parsed.password is not None
        or parsed.query
        or parsed.fragment
        or port not in (None, 443)
        or not parsed.path
        or parsed.netloc != parsed.netloc.lower()
    ):
        raise SystemExit(f"{label} URL must be a canonical HTTPS URL")
    expected_host = "files.pythonhosted.org" if label == "distribution" else "github.com"
    if parsed.hostname != expected_host:
        raise SystemExit(f"{label} URL must use the exact {expected_host} authority")
PY

mkdir -p artifacts
rm -f \
  artifacts/pypi-attestation-verification.txt \
  artifacts/pypi-attestation-verification.stderr \
  artifacts/pypi-attestation-verification.exitcode \
  artifacts/pypi-distribution \
  artifacts/pypi-artifact.sha256

set +e
timeout --signal=TERM \
  --kill-after="${AIDEFEND_H023_ATTESTATION_KILL_AFTER_SECONDS}s" \
  "${AIDEFEND_H023_ATTESTATION_TIMEOUT_SECONDS}s" \
  pypi-attestations verify pypi \
    --repository "$AIDEFEND_H023_EXPECTED_REPOSITORY_URL" \
    "$AIDEFEND_H023_PYPI_FILE_URL" \
    > artifacts/pypi-attestation-verification.txt \
    2> artifacts/pypi-attestation-verification.stderr
attestation_rc=$?
set -e
printf '%s\n' "$attestation_rc" \
  > artifacts/pypi-attestation-verification.exitcode
if [[ "$attestation_rc" -ne 0 ]]; then
  echo "PyPI attestation verification failed or exceeded its deadline" >&2
  exit 1
fi

curl --fail --silent --show-error \
  --proto '=https' --proto-redir '=https' --tlsv1.2 \
  --location --max-redirs 0 \
  --max-time "$AIDEFEND_H023_DOWNLOAD_TIMEOUT_SECONDS" \
  --max-filesize "$AIDEFEND_H023_DOWNLOAD_MAX_BYTES" \
  --output artifacts/pypi-distribution \
  "$AIDEFEND_H023_PYPI_FILE_URL"
printf '%s  %s\n' \
  "$AIDEFEND_H023_EXPECTED_ARTIFACT_SHA256" \
  artifacts/pypi-distribution \
  | sha256sum --check --strict -
sha256sum artifacts/pypi-distribution > artifacts/pypi-artifact.sha256

Bind the exact distribution URL and digest to the producer’s signed release manifest. The independent worker derives repository, workflow and source commit from cryptographically verified provenance or certificate extensions, rather than package metadata or job environment variables. PyPI publish predicates may be empty; parsing them cannot invent a commit or environment. If the pinned adapter cannot verify a required field, normalize it to null and keep the publication unverified. Preserve raw responses, certificates and verifier output.

Compare signed independent readback facts
# File: supply_chain/finalize_publisher_receipt.py
from __future__ import annotations
import hashlib
import argparse
import json
import re
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

SHA256 = re.compile(r"^[0-9a-f]{64}$")
COMMIT = re.compile(r"^[0-9a-f]{40}$")
IDENTITY = {"ecosystem", "package", "version", "artifact_sha256"}
PROVENANCE = {"repository", "workflow", "source_commit"}

def canonical(value):
    return (json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n").encode()

def verified(role: str, raw: bytes, signature: bytes) -> dict:
    if role not in {"release", "registry", "verification", "publisher_config"}:
        raise ValueError("unknown evidence role")
    public_key = Path("/opt/aidefend/trust/publisher-readback").joinpath(role + ".pub").read_bytes()
    Ed25519PublicKey.from_public_bytes(public_key).verify(signature, raw)
    def unique(pairs):
        value = {}
        for key, item in pairs:
            if key in value: raise ValueError("duplicate signed field")
            value[key] = item
        return value
    value = json.loads(raw, object_pairs_hook=unique,
                       parse_constant=lambda v: (_ for _ in ()).throw(ValueError(v)))
    if not isinstance(value, dict) or canonical(value) != raw:
        raise ValueError("noncanonical signed evidence")
    return value

def npm_configuration(response, package: str, release_sha256: str) -> dict:
    # Accept the authenticated raw GET response or npm 11.17's actual CLI JSON
    # document stream. The CLI flattens claims and emits one document per binding.
    if isinstance(response, bytes):
        original = response
        source = response.decode("utf-8")
        def unique(pairs):
            value = {}
            for key, item in pairs:
                if key in value: raise ValueError("duplicate publisher setting")
                value[key] = item
            return value
        decoder = json.JSONDecoder(object_pairs_hook=unique,
            parse_constant=lambda v: (_ for _ in ()).throw(ValueError(v)))
        documents = []
        while source.strip():
            source = source.lstrip()
            document, end = decoder.raw_decode(source)
            documents.append(document)
            source = source[end:]
        if len(documents) == 1 and isinstance(documents[0], list):
            configurations = documents[0]
        else:
            configurations = documents
    else:
        original = canonical(response)
        configurations = response if isinstance(response, list) else [response]
    if len(configurations) != 1 or not isinstance(configurations[0], dict):
        raise PermissionError("publisher population is absent or not the single approved binding")
    config = configurations[0]
    if config.get("type") != "github": raise ValueError("this adapter supports GitHub only")
    if "claims" in config:
        if any(field in config for field in ("repository", "file", "environment")):
            raise ValueError("mixed API and CLI configuration shapes")
        claims = config["claims"]
        repository = claims["repository"]
        filename = claims["workflow_ref"]["file"]
        environment = claims.get("environment")
    else:
        repository = config["repository"]
        filename = config["file"]
        environment = config.get("environment")
    permissions = config.get("permissions")
    if (not isinstance(repository, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository)
        or any(part in {".", ".."} for part in repository.split("/"))
        or not isinstance(filename, str) or "/" in filename or "\\" in filename
        or not filename.endswith((".yml", ".yaml"))
        or (permissions is not None and (not isinstance(permissions, list)
            or any(not isinstance(item, str) for item in permissions)))):
        raise ValueError("registry publisher response binding differs")
    return {"ecosystem": "npm", "package": package,
            "repository": "https://github.com/" + repository,
            "workflow": ".github/workflows/" + filename,
            "environment": environment,
            "trusted_publishing_enabled": True,
            "allowed_actions": None if permissions is None else (["publish"] if "createPackage" in permissions else []),
            "release_sha256": release_sha256,
            "configuration_output_sha256": hashlib.sha256(original).hexdigest()}

def npm_verification(native_output_bytes, release_bytes, release_signature, *,
                     verifier_returncode, verifier_version, artifact_sha256):
    # The protected worker runs the native verifier first. Reuse the exact
    # H-003.001-G003 output adapter; this parser does not replay Sigstore trust.
    from supply_chain.verify_npm_provenance_policy import verify_claims
    if type(verifier_returncode) is not int or verifier_returncode != 0:
        raise PermissionError("native npm verification did not succeed")
    if not isinstance(verifier_version, str) or not verifier_version:
        raise ValueError("pinned verifier version missing")
    release = verified("release", release_bytes, release_signature)
    if release.get("ecosystem") != "npm" or release.get("artifact_sha256") != artifact_sha256:
        raise PermissionError("downloaded npm bytes differ from the signed release")
    claims = verify_claims(json.loads(native_output_bytes), release["package"], release["version"],
                           release["repository"], release["workflow"])
    return {**{field: release[field] for field in IDENTITY},
            **{field: claims[field] for field in PROVENANCE},
            "verifier": "npm audit signatures", "verifier_version": verifier_version,
            "verifier_returncode": verifier_returncode,
            "verification_output_sha256": hashlib.sha256(native_output_bytes).hexdigest(),
            "release_sha256": hashlib.sha256(release_bytes).hexdigest()}

def finalize(release_bytes, release_signature, registry_bytes, registry_signature,
             verification_bytes, verification_signature,
             publisher_config_bytes=None, publisher_config_signature=None):
    # Four independently pinned roles. Registry package metadata does not carry
    # the build commit or prove current publisher-control settings.
    release = verified("release", release_bytes, release_signature)
    registry = verified("registry", registry_bytes, registry_signature)
    verification = verified("verification", verification_bytes, verification_signature)
    if set(release) != IDENTITY | PROVENANCE | {"environment"} or release["ecosystem"] not in {"npm", "pypi"}:
        raise ValueError("producer release schema differs")
    if (any(not isinstance(v, str) or not v for v in release.values())
        or not SHA256.fullmatch(release["artifact_sha256"])
        or not COMMIT.fullmatch(release["source_commit"])
        or not release["workflow"].startswith(".github/workflows/")):
        raise ValueError("producer release binding is incomplete")
    if set(registry) != IDENTITY or any(registry[k] != release[k] for k in IDENTITY):
        raise PermissionError("actual published identity or bytes differ")
    fields = IDENTITY | PROVENANCE | {"verifier", "verifier_version", "verifier_returncode",
                                      "verification_output_sha256", "release_sha256"}
    if set(verification) != fields or type(verification["verifier_returncode"]) is not int or verification["verifier_returncode"] != 0:
        raise PermissionError("native cryptographic verification failed")
    release_digest = hashlib.sha256(release_bytes).hexdigest()
    if (any(verification[k] != release[k] for k in IDENTITY)
        or verification["release_sha256"] != release_digest):
        raise PermissionError("native verification refers to another release")
    if (not isinstance(verification["verification_output_sha256"], str)
        or not SHA256.fullmatch(verification["verification_output_sha256"])
        or not isinstance(verification["verifier"], str) or not verification["verifier"]
        or not isinstance(verification["verifier_version"], str) or not verification["verifier_version"]):
        raise ValueError("native verifier metadata is invalid")
    missing = []
    for field in PROVENANCE:
        if verification[field] is None: missing.append("provenance." + field)
        elif verification[field] != release[field]:
            raise PermissionError("verified provenance " + field + " differs")
    config_digest = None
    if publisher_config_bytes is None and publisher_config_signature is None:
        missing.append("publisher_configuration")
    elif publisher_config_bytes is None or publisher_config_signature is None:
        raise ValueError("publisher configuration envelope is incomplete")
    else:
        config = verified("publisher_config", publisher_config_bytes, publisher_config_signature)
        config_fields = {"ecosystem", "package", "repository", "workflow", "environment",
                         "trusted_publishing_enabled", "allowed_actions", "release_sha256",
                         "configuration_output_sha256"}
        if set(config) != config_fields: raise ValueError("publisher configuration schema differs")
        if (config["release_sha256"] != release_digest
            or any(config[k] != release[k] for k in ("ecosystem", "package"))):
            raise PermissionError("publisher configuration refers to another release")
        if (not isinstance(config["configuration_output_sha256"], str)
            or not SHA256.fullmatch(config["configuration_output_sha256"])):
            raise ValueError("publisher raw readback digest invalid")
        for field in ("repository", "workflow", "environment"):
            if config[field] is None: missing.append("publisher_configuration." + field)
            elif config[field] != release[field]: raise PermissionError("publisher configuration " + field + " differs")
        if config["trusted_publishing_enabled"] is None:
            missing.append("publisher_configuration.trusted_publishing_enabled")
        elif config["trusted_publishing_enabled"] is not True:
            raise PermissionError("trusted publishing is not enabled")
        action = "publish" if release["ecosystem"] == "npm" else "upload"
        if config["allowed_actions"] is None: missing.append("publisher_configuration.allowed_actions")
        elif not isinstance(config["allowed_actions"], list) or action not in config["allowed_actions"]:
            raise PermissionError("required publishing action is not authorized")
        config_digest = hashlib.sha256(publisher_config_bytes).hexdigest()
    return {"schema_version": "aidefend.publisher-readback.v2", "control": "AID-H-023",
            "release_sha256": release_digest,
            "registry_readback_sha256": hashlib.sha256(registry_bytes).hexdigest(),
            "verification_sha256": hashlib.sha256(verification_bytes).hexdigest(),
            "publisher_configuration_sha256": config_digest,
            "artifact_sha256": release["artifact_sha256"],
            "unverified_fields": sorted(missing),
            "decision": "UNVERIFIED_PUBLICATION" if missing else "VERIFIED_PUBLICATION"}

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    for role in ("release", "registry", "verification"):
        parser.add_argument("--" + role, type=Path, required=True)
        parser.add_argument("--" + role + "-signature", type=Path, required=True)
    parser.add_argument("--publisher-config", type=Path)
    parser.add_argument("--publisher-config-signature", type=Path)
    args = parser.parse_args()
    def read_optional(path): return path.read_bytes() if path is not None else None
    result = finalize(args.release.read_bytes(), args.release_signature.read_bytes(),
                      args.registry.read_bytes(), args.registry_signature.read_bytes(),
                      args.verification.read_bytes(), args.verification_signature.read_bytes(),
                      read_optional(args.publisher_config), read_optional(args.publisher_config_signature))
    print(canonical(result).decode(), end="")
    raise SystemExit(0 if result["decision"] == "VERIFIED_PUBLICATION" else 3)

The independent readback worker signs three distinct facts: exact registry release identity and downloaded artifact digest; locally executed native-verifier results with verified repository/workflow/commit; and current authenticated publisher configuration. Use separately pinned registry, verification and publisher_config keys. These signatures attest the worker's actual reads, not a signature by npm or PyPI over control-plane settings. Verification and configuration snapshots bind to the signed release manifest digest. A known mismatch rejects the release; unavailable required fields return UNVERIFIED_PUBLICATION, never release completion. The v2 receipt replaces the v1 combined registry schema; consumers must update all three adapters together. Sign and independently read back the final receipt; it grants no dependency admission.

Read current publisher configuration through a supported interface

npm 11.15 or later provides npm trust list "$NPM_PACKAGE" --json using the authenticated package-owner session and GET /-/package/<escaped-name>/trust. The GitHub config carries repository, workflow filename, optional environment and allowed actions; it does not carry the source commit. The supplied npm_configuration adapter accepts either the authenticated raw GET response array, or the exact byte stream from npm trust list "$NPM_PACKAGE" --json. The CLI flattens claims into repository, file and environment, and emits a separate JSON document for each binding rather than one array. Preserve those raw bytes; multiple or absent bindings cannot satisfy this single-approved-binding example. The adapter hashes the bytes actually captured and leaves missing permissions or environment unverified. It never substitutes public package metadata for private publisher settings. Use a separately provisioned readback identity/session; do not hand its credentials to the publishing job. For PyPI or older npm clients without a supported machine readback, independently inspect the authenticated project publisher settings and preserve the captured configuration under the pinned configuration-worker identity. Do not claim a nonexistent API or infer settings from provenance alone. If the selected environment requires automated readback and none is available, withhold completion and report the uncovered fields.

Run the receipt comparison
python3 supply_chain/finalize_publisher_receipt.py \
  --release evidence/release.json --release-signature evidence/release.json.sig \
  --registry evidence/registry.json --registry-signature evidence/registry.json.sig \
  --verification evidence/verification.json --verification-signature evidence/verification.json.sig \
  --publisher-config evidence/publisher-config.json \
  --publisher-config-signature evidence/publisher-config.json.sig

Omitting configuration evidence produces an unverified receipt and exit 3. For the npm worker, call npm_verification(native_bytes, signed_release_bytes, release_signature, verifier_returncode=actual_exit, verifier_version=pinned_version, artifact_sha256=downloaded_sha256); canonicalize and sign the returned record with the independently pinned verification worker. Call npm_configuration(cli_stdout_bytes, package, sha256(signed_release_bytes)) on the authenticated configuration readback; canonicalize and sign its result under publisher_config. The release role public key is pinned by the module. The normalizer is not a network client: the pinned independent worker must perform native verification and authenticated reads before signing these exact facts.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-023: Publisher Integrity & Workflow Hardening | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version