AID-H-023: Publisher Integrity & Workflow Hardening
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.
- AID-H-023-G002: Harden the CI/CD workflow definition to prevent tampering and enforce least privilege.
- 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.
AID-H-023-G001
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 --provenancePyPI 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
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
- Read repository rulesets/branch protection, Actions permissions, environment protection rules, and the protected workflow through an authenticated GitHub App or equivalent CI-provider API identity that cannot modify them.
- Parse
CODEOWNERSand prove that the exact publish-workflow path is owned by the release-security team; prove required reviewers and self-review prevention on the exact environment named by the workflow. - Parse every
uses:node and require a full 40-hex commit SHA. Treat reusable workflows and composite actions as transitive release dependencies and pin them too. - Compare the npm or PyPI trusted-publisher repository, workflow filename, environment, and allowed publishing action with the approved record. A workflow file in source control is not proof of the registry-side binding. npm 11.15+ supports authenticated npm trust list; PyPI or older clients without machine readback require independently captured authenticated settings, or the required fields remain unverified.
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
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.sigOmitting 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
- GitLab Community Edition CI/CD (with OIDC support)
- Open Policy Agent (OPA)
- Conftest
- npm CLI
- PyPA build
- twine
- pypa/gh-action-pypi-publish
- slsa-github-generator
- pypi-attestations (pinned PEP 740 verifier; CLI interface is experimental)
Commercial / hosted tools
- JFrog Artifactory
- Sonatype Nexus Repository
- GitHub Actions
- CircleCI
- CloudBees CI
Threat mappings
MITRE ATLAS
- AML.T0115 Publish Poisoned AI Artifacts
- AML.T0010.005 AI Supply Chain Compromise: AI Agent Tool
- AML.T0010 AI Supply Chain Compromise
- AML.T0010.001 AI Supply Chain Compromise: AI Software
- AML.T0012 Valid Accounts
- AML.T0074 Masquerading
- AML.T0115.001 Publish Poisoned AI Artifacts: Models
- AML.T0115.002 Publish Poisoned AI Artifacts: AI Agent Tools
- AML.T0055 Unsecured Credentials (OIDC tokens eliminate long-lived credentials)
MAESTRO
- Supply Chain Attacks (Cross-Layer)
- Compromised Framework Components (L3)
OWASP LLM Top 10 2026
- LLM04:2026 Supply Chain
OWASP ML Top 10 2023
- ML06:2023 AI Supply Chain Attacks
OWASP Top 10 for Agentic Applications 2026
- ASI04:2026 Agentic Supply Chain Vulnerabilities
NIST Adversarial Machine Learning 2025
- NISTAML.051 Model Poisoning (Supply Chain)
Cisco Integrated AI Security and Safety Framework
- AISubtech-9.3.1 Malicious Package / Tool Injection (protected AI-package publishing and exact release readback expose unauthorized producer or workflow substitution)
- AISubtech-9.3.3 Dependency Replacement / Rug Pull (protected AI-package publishing and exact release readback expose unauthorized producer or workflow substitution)
- AITech-9.3 Dependency / Plugin Compromise (protected AI-package publishing and exact release readback expose unauthorized producer or workflow substitution)
Google Secure AI Framework 2.0 - Risks
- MST: Model Source Tampering (publisher integrity prevents credential-based publication of tampered packages)
Databricks AI Security Framework 3.0
- Model 7.3: ML Supply chain vulnerabilities
- Model 7.4: Source code control attack
- Algorithms 5.4: Malicious libraries
- Platform 12.5: Poor security in the software development lifecycle
- Agents - Tools MCP Server 13.21: Supply Chain Attacks
- Agents - Tools MCP Server 13.18: Tool Poisoning