AID-H-003.001: Software Dependency & Package Security
Pillars: infra
Lifecycle: building
Scope boundary
Owns the authoritative build or deployment admission decision for the complete exact population of third-party software packages and build-tool bytes, including identity, source, digest, signature, vulnerability, yanked status, license, and lock resolution.
AID-H-022.002: AID-H-003.001 admits the exact resolved bytes consumed by build or deployment; AID-H-022.002 reviews the proposed dependency change before merge for publisher, repository, name-confusion, and install-behavior risk. Pre-merge approval is supporting evidence and never pre-authorizes later resolved artifacts.
AID-H-022.001: AID-H-003.001 decides which immutable dependency bytes are approved; AID-H-022.001 installs those approved bytes inside an isolated deterministic environment and exports the consumed dependency layer. Admission and installation containment are sequential controls with different receipts.
AID-H-003.002: AID-H-003.001 admits third-party packages and build tools before use; AID-H-003.002 gates, signs, and distributes the produced release artifact. Dependency admission does not establish that the final model release satisfies promotion policy.
AID-H-003.009: AID-H-003.001 evaluates software dependency packages; AID-H-003.009 statically scans model artifacts for malware, unsafe serialization, and loader hazards. Package security scanning is not a substitute for format-aware model-file analysis.
AID-H-003.010: AID-H-003.001 admits the exact package and build-tool population before build or deployment; AID-H-003.010 continuously manages vulnerabilities after AI application and platform software is deployed. A successful admission decision ages and must not be treated as continuing evidence that the deployed fleet remains remediated.
AID-H-023: AID-H-003.001 verifies the exact third-party packages consumed by build or deployment; AID-H-023 secures the producer’s publication and reads back its own released bytes. Publisher provenance is one required admission signal when policy specifies it, never proof of benign package code or a second authorization to consume it.
Implementation guidance
- AID-H-003.001-G001: Run SCA scanners (Syft/Grype, Trivy) on every build pipeline.
- AID-H-003.001-G002: Pin exact versions & hashes in requirements/lock files; block implicit upgrades.
- AID-H-003.001-G003: Verify producer signatures and provenance on the exact third-party package or image bytes before dependency admission.
- AID-H-003.001-G004: Fail the build if a dependency is yanked or contains critical CVEs.
AID-H-003.001-G001
Production implementation: scan one immutable OCI archive with a pinned toolchain
Build once, export an OCI archive, and give every scanner the same bytes. The organization publishes a scanner image containing policy-approved Syft, Grype, and Trivy versions; CI accepts it only by registry digest.
set -euo pipefail
umask 077
: "${VERIFIED_H003_SCA_TMPFS_SIZE:?verified SCA tmpfs size required}"
case "$SCA_TOOLCHAIN_IMAGE" in *@sha256:*) ;; *) echo "digest-pinned scanner image required" >&2; exit 2 ;; esac
test -s artifacts/model-service.oci.tar
mkdir -p evidence
docker run --rm --network none --read-only \
--tmpfs "/tmp:rw,noexec,nosuid,size=${VERIFIED_H003_SCA_TMPFS_SIZE}" \
-v "$PWD/artifacts:/input:ro" -v "$PWD/evidence:/evidence" \
"$SCA_TOOLCHAIN_IMAGE" sh -ceu '
syft oci-archive:/input/model-service.oci.tar \
-o cyclonedx-json=/evidence/sbom.cdx.json
grype sbom:/evidence/sbom.cdx.json \
--fail-on "$GRYPE_FAIL_SEVERITY" \
-o json > /evidence/grype.json
trivy image --input /input/model-service.oci.tar \
--scanners vuln --severity "$TRIVY_SEVERITIES" \
--ignore-unfixed=false --exit-code 1 \
--format json --output /evidence/trivy.json
syft version > /evidence/syft.version
grype version > /evidence/grype.version
trivy --version > /evidence/trivy.version
'
sha256sum artifacts/model-service.oci.tar evidence/* > evidence/sha256sums.txt
Action: Values such as severity thresholds come from the signature-verified release policy injected into the isolated scanner. Sign the archive digest, SBOM, raw findings, tool versions, policy digest, and complete target-platform manifest. Scanner disagreement, database staleness, tool error, or missing platform coverage blocks the build.
Before you begin
Apply AID-H-003.001-G001 only when a build, training, evaluation, serving, or tooling environment resolves third-party packages, images, plugins, or transitive dependencies.
Keep the action safely bounded
block resolution or build on an unpinned, unhashed, yanked, disallowed, policy-exceeding vulnerable, unavailable, or unaccounted dependency.
AID-H-003.001-G002
Before you begin
Apply AID-H-003.001-G002 only when a build, training, evaluation, serving, or tooling environment resolves third-party packages, images, plugins, or transitive dependencies.
Production implementation: generate and consume a hash-complete lock
Run resolution and wheel download only in an immutable organization-approved toolchain image on a signature-verified egress-controlled network that permits the approved Python mirror and no other destinations. The resolver stage intentionally has mirror access; only the later installation stage is network-isolated. Do not publish fake or abbreviated lockfile hashes.
set -euo pipefail
umask 077
: "${VERIFIED_H003_LOCK_BUILD_TMPFS_SIZE:?verified lock-build tmpfs size required}"
: "${VERIFIED_H003_LOCK_RESOLVER_NETWORK:?egress-controlled resolver network required}"
: "${APPROVED_PYPI_MIRROR:?approved Python mirror URL required}"
case "$PYTHON_LOCK_TOOLCHAIN_IMAGE" in *@sha256:*) ;; *) echo "digest-pinned lock toolchain image required" >&2; exit 2 ;; esac
case "$VERIFIED_H003_LOCK_RESOLVER_NETWORK" in none|host|'') echo "resolver network must be a named egress-controlled network" >&2; exit 2 ;; esac
case "$APPROVED_PYPI_MIRROR" in https://*) ;; *) echo "approved Python mirror must use HTTPS" >&2; exit 2 ;; esac
docker run --rm --network "$VERIFIED_H003_LOCK_RESOLVER_NETWORK" --read-only \
--tmpfs "/tmp:rw,noexec,nosuid,size=${VERIFIED_H003_LOCK_BUILD_TMPFS_SIZE}" \
-e "PIP_INDEX_URL=$APPROVED_PYPI_MIRROR" -e "PIP_NO_INPUT=1" \
-v "$PWD:/work" -w /work "$PYTHON_LOCK_TOOLCHAIN_IMAGE" \
python -m piptools compile \
--generate-hashes --resolver=backtracking \
--strip-extras --output-file=requirements.txt requirements.in
docker run --rm --network "$VERIFIED_H003_LOCK_RESOLVER_NETWORK" --read-only \
--tmpfs "/tmp:rw,noexec,nosuid,size=${VERIFIED_H003_LOCK_BUILD_TMPFS_SIZE}" \
-e "PIP_INDEX_URL=$APPROVED_PYPI_MIRROR" -e "PIP_NO_INPUT=1" \
-v "$PWD:/work" -w /work "$PYTHON_LOCK_TOOLCHAIN_IMAGE" \
python -m pip download \
--require-hashes --only-binary=:all: \
--dest wheelhouse -r requirements.txt
docker run --rm --network none --read-only \
--tmpfs "/tmp:rw,noexec,nosuid,size=${VERIFIED_H003_LOCK_BUILD_TMPFS_SIZE}" \
-v "$PWD:/work:ro" -v "$PWD/wheelhouse:/wheelhouse:ro" -w /work "$PYTHON_LOCK_TOOLCHAIN_IMAGE" \
python -m pip install \
--no-index --find-links=/wheelhouse \
--require-hashes -r requirements.txtVerification: Reject every non-comment requirement without exactly one pinned version and at least one full lower-case SHA-256 hash; reject editable, VCS, local-path, unapproved URL, or index override entries. Generate a canonical wheelhouse manifest containing normalized package name, version, filename, byte size, and SHA-256 for the complete direct/transitive and target-platform population; sign it and re-verify before installation.
Keep the action safely bounded
Action: Pin every direct and transitive dependency to an exact version and verified hash. Block resolution or build on an unpinned, unhashed, yanked, disallowed, policy-exceeding vulnerable, unavailable, or unaccounted dependency.
AID-H-003.001-G003
Consumer-side signature and provenance verification
Apply this method to every member of the exact complete resolved dependency population, including transitive and target-platform artifacts. Each npm or PyPI command below verifies one selected package/version or distribution; the protected admission orchestrator must enumerate the signed lockfile or wheelhouse closure, run the applicable adapter for each member, and reconcile the complete result set before releasing any bytes. The protected admission job selects policy-required signer, issuer, source repository, workflow, provenance predicate and expected digest from a signed dependency policy. It never signs a consumer-selected artifact to make it trusted. H-023 owns the producer’s publication workflow; H-003.002 owns final model-release signing.
npm native verification
#!/usr/bin/env bash
set -euo pipefail
umask 077
: "$NPM_PACKAGE"
: "$NPM_VERSION"
: "$EXPECTED_REPOSITORY"
: "${EXPECTED_WORKFLOW:?exact .github/workflows path from signed policy}"
: "${AIDEFEND_H003_DEPENDENCY_EXPECTED_ARTIFACT_SHA256:?exact tarball digest from signed dependency policy}"
: "${AIDEFEND_H003_DEPENDENCY_NPM_OPERATION_TIMEOUT_SECONDS:?exported from the verified package-lock policy}"
: "${AIDEFEND_H003_DEPENDENCY_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_H003_DEPENDENCY_NPM_OPERATION_TIMEOUT_SECONDS \
AIDEFEND_H003_DEPENDENCY_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_H003_DEPENDENCY_NPM_OPERATION_KILL_AFTER_SECONDS}s" \
"${AIDEFEND_H003_DEPENDENCY_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)
printf '%s %s\n' "$AIDEFEND_H003_DEPENDENCY_EXPECTED_ARTIFACT_SHA256" "$TARBALL" | sha256sum --check --strict -
sha256sum "$TARBALL" > npm-artifact.sha256
PyPI native verification
#!/usr/bin/env bash
set -euo pipefail
umask 077
required=(
AIDEFEND_H003_DEPENDENCY_PYPI_FILE_URL
AIDEFEND_H003_DEPENDENCY_EXPECTED_REPOSITORY_URL
AIDEFEND_H003_DEPENDENCY_EXPECTED_ARTIFACT_SHA256
AIDEFEND_H003_DEPENDENCY_ATTESTATION_TIMEOUT_SECONDS
AIDEFEND_H003_DEPENDENCY_ATTESTATION_KILL_AFTER_SECONDS
AIDEFEND_H003_DEPENDENCY_DOWNLOAD_TIMEOUT_SECONDS
AIDEFEND_H003_DEPENDENCY_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_H003_DEPENDENCY_ATTESTATION_TIMEOUT_SECONDS \
AIDEFEND_H003_DEPENDENCY_ATTESTATION_KILL_AFTER_SECONDS \
AIDEFEND_H003_DEPENDENCY_DOWNLOAD_TIMEOUT_SECONDS; do
if ! positive_decimal "${!name}"; then
echo "$name must be a finite positive number" >&2
exit 2
fi
done
if ! [[ "$AIDEFEND_H003_DEPENDENCY_DOWNLOAD_MAX_BYTES" =~ ^[1-9][0-9]*$ ]]; then
echo "download maximum bytes must be a positive integer" >&2
exit 2
fi
if ! [[ "$AIDEFEND_H003_DEPENDENCY_EXPECTED_ARTIFACT_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|| [[ "$AIDEFEND_H003_DEPENDENCY_EXPECTED_ARTIFACT_SHA256" =~ ^0{64}$ ]]; then
echo "expected artifact SHA-256 must be lowercase, nonzero, and complete" >&2
exit 2
fi
python3 - \
"$AIDEFEND_H003_DEPENDENCY_PYPI_FILE_URL" \
"$AIDEFEND_H003_DEPENDENCY_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_H003_DEPENDENCY_ATTESTATION_KILL_AFTER_SECONDS}s" \
"${AIDEFEND_H003_DEPENDENCY_ATTESTATION_TIMEOUT_SECONDS}s" \
pypi-attestations verify pypi \
--repository "$AIDEFEND_H003_DEPENDENCY_EXPECTED_REPOSITORY_URL" \
"$AIDEFEND_H003_DEPENDENCY_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_H003_DEPENDENCY_DOWNLOAD_TIMEOUT_SECONDS" \
--max-filesize "$AIDEFEND_H003_DEPENDENCY_DOWNLOAD_MAX_BYTES" \
--output artifacts/pypi-distribution \
"$AIDEFEND_H003_DEPENDENCY_PYPI_FILE_URL"
printf '%s %s\n' \
"$AIDEFEND_H003_DEPENDENCY_EXPECTED_ARTIFACT_SHA256" \
artifacts/pypi-distribution \
| sha256sum --check --strict -
sha256sum artifacts/pypi-distribution > artifacts/pypi-artifact.sha256
OCI dependency verification
set -euo pipefail
: "${APPROVED_DEPENDENCY_REF:?immutable OCI dependency reference from verified policy}"
cosign verify --certificate-identity "$(cat /opt/aidefend/trust/dependency-producer.identity)" \
--certificate-oidc-issuer "$(cat /opt/aidefend/trust/dependency-producer.issuer)" \
"$APPROVED_DEPENDENCY_REF" > dependency-signature.json
cosign verify-attestation --type slsaprovenance \
--certificate-identity "$(cat /opt/aidefend/trust/dependency-producer.identity)" \
--certificate-oidc-issuer "$(cat /opt/aidefend/trust/dependency-producer.issuer)" \
"$APPROVED_DEPENDENCY_REF" > dependency-provenance.json
Match verified npm provenance to policy
# File: supply_chain/verify_npm_provenance_policy.py
from __future__ import annotations
import argparse
import base64
import json
from pathlib import Path, PurePosixPath
import re
from urllib.parse import urlsplit
COMMIT = re.compile(r"^[0-9a-f]{40}$")
def github_repository(raw: str) -> str:
# npm repository metadata permits HTTPS and Git SSH spellings. Normalize
# only the exact GitHub authority; provenance may append a Git @ref to path.
if not isinstance(raw, str): raise ValueError("repository is not a string")
value = raw.removeprefix("git+")
if value.startswith("git@github.com:"):
value = "ssh://git@github.com/" + value[len("git@github.com:"):]
parsed = urlsplit(value)
try: port = parsed.port
except ValueError as exc: raise ValueError("repository port invalid") from exc
if (parsed.hostname != "github.com" or parsed.query or parsed.fragment
or parsed.password is not None or port is not None
or (parsed.scheme == "https" and parsed.username is not None)
or (parsed.scheme == "ssh" and parsed.username != "git")
or parsed.scheme not in {"https", "ssh"}):
raise ValueError("unsupported repository URI")
repository = parsed.path.split("@", 1)[0].removesuffix(".git").strip("/")
if (not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository)
or any(part in {".", ".."} for part in repository.split("/"))):
raise ValueError("unsupported repository path")
return "https://github.com/" + repository
def workflow_path(raw: str, repository: str, *, legacy: bool = False) -> str:
if not isinstance(raw, str): raise ValueError("workflow is not a string")
value = raw
if legacy:
# Older npm GitHub provenance qualifies entryPoint with owner/repo and @ref.
# Strip only this verified repository's exact prefix, never another owner.
prefix = urlsplit(repository).path.strip("/") + "/"
if value.startswith(prefix): value = value[len(prefix):]
value = value.split("@", 1)[0]
parts = PurePosixPath(value).parts
if (not value.startswith(".github/workflows/") or len(parts) != 3
or "\\" in value or ".." in parts or "@" in value or str(PurePosixPath(value)) != value
or not value.endswith((".yml", ".yaml"))):
raise ValueError("workflow must be an exact .github/workflows path")
return value
def verify_claims(native_output: dict, package: str, version: str,
expected_repository: str, expected_workflow: str) -> dict:
# Input comes only from the protected, successfully completed native CLI.
# Parsing arbitrary JSON cannot substitute for its cryptographic verification.
if not isinstance(native_output, dict): raise ValueError("native output is not an object")
if any(not isinstance(native_output.get(k), list) for k in ("invalid", "missing", "verified")):
raise ValueError("native CLI output schema differs")
if native_output["invalid"] or native_output["missing"]:
raise PermissionError("native signature/provenance verification failed")
expected_repository = github_repository(expected_repository)
expected_workflow = workflow_path(expected_workflow, expected_repository)
targets = [row for row in native_output["verified"] if isinstance(row, dict)
and row.get("name") == package and row.get("version") == version]
if len(targets) != 1: raise PermissionError("exact dependency is absent or duplicated")
# npm emits packument dist.attestations metadata and attestationBundles separately.
bundles = targets[0].get("attestationBundles")
if not isinstance(bundles, list) or not bundles:
raise PermissionError("required provenance missing")
matched = []
commits = set()
for entry in bundles:
if not isinstance(entry, dict): raise ValueError("attestation entry is not an object")
predicate_type = entry.get("predicateType")
if predicate_type not in {"https://slsa.dev/provenance/v0.2", "https://slsa.dev/provenance/v1"}: continue
raw = base64.b64decode(entry["bundle"]["dsseEnvelope"]["payload"], validate=True)
statement = json.loads(raw)
if statement.get("predicateType") != predicate_type:
raise PermissionError("predicate association differs")
predicate = statement["predicate"]
if predicate_type.endswith("/v0.2"):
source = predicate["invocation"]["configSource"]
repository = github_repository(source["uri"])
workflow = workflow_path(source["entryPoint"], repository, legacy=True)
commit = source.get("digest", {}).get("sha1")
else:
definition = predicate["buildDefinition"]
workflow_record = definition["externalParameters"]["workflow"]
repository = github_repository(workflow_record["repository"])
workflow = workflow_path(workflow_record["path"], repository)
sources = []
for dependency in definition.get("resolvedDependencies", []):
try: source_repository = github_repository(dependency["uri"])
except ValueError: continue # Non-GitHub build dependencies are not the source checkout.
if source_repository == repository: sources.append(dependency)
if len(sources) != 1: raise PermissionError("source commit is absent or ambiguous")
commit = sources[0].get("digest", {}).get("gitCommit")
if repository != expected_repository or workflow != expected_workflow:
raise PermissionError("cryptographically verified producer workflow differs")
if not isinstance(commit, str) or not COMMIT.fullmatch(commit):
raise PermissionError("verified source commit is missing")
matched.append(statement)
commits.add(commit)
if not matched: raise PermissionError("supported provenance predicate absent")
if len(commits) != 1: raise PermissionError("provenance source commits disagree")
return {"package": package, "version": version, "repository": expected_repository,
"workflow": expected_workflow, "source_commit": next(iter(commits)),
"verified_provenance_count": len(matched)}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("native_output", type=Path)
parser.add_argument("--package", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--repository", required=True)
parser.add_argument("--workflow", required=True)
parser.add_argument("--metadata-repository")
args = parser.parse_args()
result = verify_claims(json.loads(args.native_output.read_bytes()), args.package,
args.version, args.repository, args.workflow)
if args.metadata_repository is not None and github_repository(args.metadata_repository) != result["repository"]:
raise PermissionError("registry repository metadata differs from verified provenance")
print(json.dumps(result, sort_keys=True, allow_nan=False))
Call this parser only on the successful native verifier output from the same protected workspace and compare the actual downloaded tarball to the signed dependency manifest. Require --include-attestations; a pinned CLI lacking that feature is unsupported and must fail closed. Select repository and workflow from the signed admission policy, not unsigned npm view metadata. The verifier installation runs with lifecycle scripts disabled in a quarantined verifier workspace with policy-bounded egress to the pinned registry, provenance and transparency endpoints; lifecycle scripts and ordinary build or production access remain disabled. AID-H-022.001 owns the separate no-network execution stage; it does not release those bytes to build or production. Third-party consumers compare cryptographically verified provenance claims and do not require access to the supplier’s private registry settings.
Final dependency admission
Reconcile every native-verification result with the signed exact lock/wheelhouse/image manifest and actual consumed artifact digest; reject unknown, missing or duplicate members, unexpected repositories/workflows, unavailable required attestations, mutable aliases, and digest changes. Cryptographic success is not a benignness or vulnerability verdict: combine it with G001 scanning, G002 exact resolution, G004 vulnerability/yanked checks and the policy’s license decisions before the final dependency store, build or deployment can consume those bytes. Store and independently read back the complete artifact/result population. This method issues no model-release or publisher approval.
Install the Python adapter below /opt/aidefend/supply_chain/ in the immutable verifier image before invoking the npm example. npm emits attestations metadata and a separate attestationBundles array. Pin and contract-test the native CLI version. The GitHub adapter compares one exact .github/workflows/filename.yml path for both SLSA versions; v0.2 normalization strips only the verified repository prefix and ref. Other build providers require their own pinned adapters. Preserve complete lockfile closure and exact tarball digest checks; native verification does not authorize a different downloaded artifact.
AID-H-003.001-G004
Production correction: vulnerability and yanked-release gates are separate
pip-audit audits known vulnerabilities; it does not by itself prove that every locked distribution is non-yanked. Audit the hash-complete lock, and separately query the approved PEP 691 mirror metadata for the exact name, version, filename, and hash population.
#!/usr/bin/env bash
set -euo pipefail
umask 077
: "${VERIFIED_H003_DEPENDENCY_GATE_TIMEOUT_SECONDS:?verified runtime profile}"
: "${VERIFIED_H003_DEPENDENCY_GATE_KILL_AFTER_SECONDS:?verified runtime profile}"
positive_decimal() {
local value="$1"
[[ "$value" =~ ^([0-9]+([.][0-9]*)?|[.][0-9]+)$ ]] \
&& awk -v value="$value" 'BEGIN { exit !(value > 0) }'
}
positive_decimal "$VERIFIED_H003_DEPENDENCY_GATE_TIMEOUT_SECONDS"
positive_decimal "$VERIFIED_H003_DEPENDENCY_GATE_KILL_AFTER_SECONDS"
run_bounded() {
timeout --signal=TERM \
--kill-after="${VERIFIED_H003_DEPENDENCY_GATE_KILL_AFTER_SECONDS}s" \
"${VERIFIED_H003_DEPENDENCY_GATE_TIMEOUT_SECONDS}s" "$@"
}
run_bounded python -m pip_audit \
--require-hashes --strict \
--format json --output pip-audit.json \
--requirement requirements.txt
run_bounded python supply_chain/verify_pep691_lock.py \
--lock-manifest evidence/wheelhouse-manifest.json \
--lock-signature evidence/wheelhouse-manifest.sig \
--simple-index "$APPROVED_SIMPLE_INDEX"
# File: supply_chain/verify_pep691_lock.py
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
from pathlib import Path
import re
import subprocess
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from packaging.utils import canonicalize_name, parse_wheel_filename
from packaging.version import Version
SHA256 = re.compile(r"^[0-9a-f]{64}$")
LOCK_FIELDS = {"name", "version", "filename", "sha256"}
def required_positive_int(name: str) -> int:
value = int(os.environ[name])
if value <= 0:
raise RuntimeError(f"{name} must be a positive integer")
return value
def required_positive_seconds(name: str) -> float:
value = float(os.environ[name])
if not math.isfinite(value) or value <= 0:
raise RuntimeError(f"{name} must be finite and positive")
return value
INDEX_RESPONSE_MAX_BYTES = required_positive_int(
"VERIFIED_H003_PACKAGE_INDEX_RESPONSE_MAX_BYTES"
)
LOCK_MANIFEST_MAX_BYTES = required_positive_int(
"VERIFIED_H003_LOCK_MANIFEST_MAX_BYTES"
)
INDEX_TIMEOUT_SECONDS = required_positive_seconds(
"VERIFIED_H003_PACKAGE_INDEX_FETCH_TIMEOUT_SECONDS"
)
LOCK_VERIFY_TIMEOUT_SECONDS = required_positive_seconds(
"VERIFIED_H003_LOCK_VERIFY_TIMEOUT_SECONDS"
)
EXPECTED_INDEX = os.environ["VERIFIED_H003_SIMPLE_INDEX_URL"]
LOCK_VERIFY_KEY = os.environ["VERIFIED_H003_LOCK_VERIFY_KEY"]
def strict_json(raw: bytes) -> object:
def unique_object(pairs: list[tuple[str, object]]) -> dict:
value: dict = {}
for key, item in pairs:
if key in value:
raise ValueError(f"duplicate JSON key: {key}")
value[key] = item
return value
return json.loads(
raw.decode("utf-8", errors="strict"),
object_pairs_hook=unique_object,
parse_constant=lambda value: (_ for _ in ()).throw(
ValueError(f"invalid JSON constant: {value}")
),
)
def canonical_index(raw: str) -> str:
parsed = urllib.parse.urlsplit(raw)
if (
raw != raw.strip()
or 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 not parsed.path.endswith("/")
):
raise ValueError("simple index must be a canonical HTTPS base URL")
return raw
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, request, file_pointer, code, message, headers, new_url):
return None
OPENER = urllib.request.build_opener(NoRedirect)
def load_signed_lock(path: Path, signature_path: Path) -> list[dict]:
raw = path.read_bytes()
signature = signature_path.read_bytes()
if (
not raw
or len(raw) > LOCK_MANIFEST_MAX_BYTES
or not signature
or not LOCK_VERIFY_KEY
):
raise ValueError("lock snapshot, signature, or trust key is invalid")
digest = hashlib.sha256(raw).hexdigest()
with tempfile.TemporaryDirectory(prefix="aidefend-pep691-lock-") as directory:
root = Path(directory)
os.chmod(root, 0o700)
snapshot = root / "wheelhouse-manifest.json"
signature_snapshot = root / "wheelhouse-manifest.sig"
snapshot.write_bytes(raw)
signature_snapshot.write_bytes(signature)
os.chmod(snapshot, 0o400)
os.chmod(signature_snapshot, 0o400)
subprocess.run(
[
"cosign", "verify-blob",
"--key", LOCK_VERIFY_KEY,
"--bundle", str(signature_snapshot),
str(snapshot),
],
check=True,
capture_output=True,
text=True,
timeout=LOCK_VERIFY_TIMEOUT_SECONDS,
)
if hashlib.sha256(snapshot.read_bytes()).hexdigest() != digest:
raise RuntimeError("verified lock snapshot changed")
value = strict_json(raw)
if not isinstance(value, list) or not value:
raise ValueError("lock population is empty")
seen: set[tuple[str, str, str, str]] = set()
filenames: set[str] = set()
for item in value:
if not isinstance(item, dict) or set(item) != LOCK_FIELDS:
raise ValueError("lock entry schema differs")
if any(not isinstance(item[field], str) or not item[field] for field in LOCK_FIELDS):
raise ValueError("lock entry contains an empty value")
if item["name"] != canonicalize_name(item["name"]):
raise ValueError("lock project name is not canonical")
if (
SHA256.fullmatch(item["sha256"]) is None
or item["sha256"] == "0" * 64
):
raise ValueError("lock SHA-256 is invalid")
wheel_name, wheel_version, _, _ = parse_wheel_filename(item["filename"])
if (
canonicalize_name(wheel_name) != item["name"]
or wheel_version != Version(item["version"])
):
raise ValueError("wheel filename does not match locked name/version")
key = (
item["name"], item["version"], item["filename"], item["sha256"]
)
if key in seen or item["filename"] in filenames:
raise ValueError("lock population contains a duplicate")
seen.add(key)
filenames.add(item["filename"])
return value
def fetch_project(index: str, name: str) -> dict:
project_name = canonicalize_name(name)
url = index + urllib.parse.quote(project_name, safe="") + "/"
request = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.pypi.simple.v1+json",
"Authorization": "Bearer " + os.environ["PACKAGE_MIRROR_TOKEN"],
"Accept-Encoding": "identity",
},
)
try:
with OPENER.open(request, timeout=INDEX_TIMEOUT_SECONDS) as response:
if response.status != 200 or response.geturl() != url:
raise PermissionError("mirror response status or final URL differs")
media_type = response.headers.get_content_type()
if media_type != "application/vnd.pypi.simple.v1+json":
raise ValueError("mirror response is not PEP 691 JSON")
length = response.headers.get("Content-Length")
if length is not None and int(length) > INDEX_RESPONSE_MAX_BYTES:
raise ValueError("mirror response exceeds signed byte bound")
raw = response.read(INDEX_RESPONSE_MAX_BYTES + 1)
except urllib.error.HTTPError as exc:
raise PermissionError(
f"mirror request failed or redirected: HTTP {exc.code}"
) from exc
if len(raw) > INDEX_RESPONSE_MAX_BYTES:
raise ValueError("mirror response exceeds signed byte bound")
project = strict_json(raw)
if (
not isinstance(project, dict)
or not isinstance(project.get("meta"), dict)
or not isinstance(project["meta"].get("api-version"), str)
or canonicalize_name(project.get("name", "")) != project_name
or not isinstance(project.get("files"), list)
):
raise ValueError("PEP 691 project schema differs")
return project
def verify(lock_manifest: Path, signature: Path, index: str) -> None:
index = canonical_index(index)
if index != canonical_index(EXPECTED_INDEX):
raise PermissionError("simple index differs from signed runtime profile")
locked = load_signed_lock(lock_manifest, signature)
for item in locked:
project = fetch_project(index, item["name"])
matches = []
for file in project["files"]:
if not isinstance(file, dict):
raise ValueError("PEP 691 file entry is not an object")
filename = file.get("filename")
hashes = file.get("hashes")
yanked = file.get("yanked", False)
if not isinstance(filename, str) or not isinstance(hashes, dict):
raise ValueError("PEP 691 file entry schema differs")
if (
filename == item["filename"]
and hashes.get("sha256") == item["sha256"]
):
matches.append(file)
if len(matches) != 1:
raise PermissionError(
"locked distribution is absent or ambiguous in mirror"
)
if matches[0].get("yanked", False) is not False:
raise PermissionError("locked distribution is yanked")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--lock-manifest", type=Path, required=True)
parser.add_argument("--lock-signature", type=Path, required=True)
parser.add_argument("--simple-index", required=True)
arguments = parser.parse_args()
verify(
arguments.lock_manifest,
arguments.lock_signature,
arguments.simple_index,
)
Action: Pin pip-audit and its vulnerability database snapshot in the scanner image; bind database freshness to policy. Signature-verify one immutable, duplicate-free lock snapshot; require the exact signed canonical HTTPS mirror origin, deny redirects carrying the bearer token, bound PEP 691 bytes before strict parsing, and reconcile every unique locked wheel. Require the mirror's authenticated metadata and the exact lock/wheelhouse manifest. A yanked release, policy-exceeding vulnerability, incomplete platform closure, mirror error, or stale database blocks the build.
Keep the action safely bounded
block resolution or build on an unpinned, unhashed, yanked, disallowed, policy-exceeding vulnerable, unavailable, or unaccounted dependency.
Open-source tools
- Trivy
- Syft
- Grype
- Cosign
- in-toto
- OWASP Dependency-Check
- pip-audit
- OSV-Scanner
- OWASP Dependency-Track
- npm CLI (native registry signature and provenance verification)
- pypi-attestations (pinned PEP 740 verifier; CLI interface is experimental)
Commercial / hosted tools
- Snyk Open Source
- Mend SCA
- JFrog Xray
- Veracode Software Composition Analysis
- Checkmarx SCA
Threat mappings
MITRE ATLAS
- AML.T0010 AI Supply Chain Compromise
- AML.T0011 User Execution
- AML.T0010.001 AI Supply Chain Compromise: AI Software
- AML.T0010.004 AI Supply Chain Compromise: Container Registry
- AML.T0011.001 User Execution: Malicious Package
- AML.T0074 Masquerading (malicious packages masquerading as legitimate)
- AML.T0060 Publish Hallucinated Entities (dependency scanning detects hallucination-squatted packages)
- AML.T0109 AI Supply Chain Rug Pull
MAESTRO
- Compromised Framework Components (L3)
- Compromised Container Images (L4)
- Supply Chain Attacks (Cross-Layer)
- Supply Chain Attacks (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
- ASI05:2026 Unexpected Code Execution (RCE) (malicious packages can execute arbitrary code)
NIST Adversarial Machine Learning 2025
- NISTAML.051 Model Poisoning (Supply Chain) (compromised dependencies can poison models)
Cisco Integrated AI Security and Safety Framework
- AISubtech-9.3.1 Malicious Package / Tool Injection
- AISubtech-9.3.2 Dependency Name Squatting (Tools / Servers)
- AISubtech-9.3.3 Dependency Replacement / Rug Pull
- AITech-9.3 Dependency / Plugin Compromise
Google Secure AI Framework 2.0 - Risks
- MST: Model Source Tampering (compromised dependencies tamper with model source)
- MDT: Model Deployment Tampering (compromised deployment dependencies)
- IIC: Insecure Integrated Component
Databricks AI Security Framework 3.0
- Algorithms 5.4: Malicious libraries
- Model 7.3: ML Supply chain vulnerabilities
- Platform 12.1: Lack of vulnerability management
- Platform 12.5: Poor security in the software development lifecycle