AID-D-004.001: Static Artifact Integrity Re-verification & Drift Monitoring
AID-M-002.002. Emit findings for unauthorized modification, signature failure, missing or unexpected members, and drift from the approved baseline; do not turn the same release-gate verification into a second integrity PASS.Pillars: infra, model, app
Lifecycle: building, validation, operation
Scope boundary
Owns independently scheduled or event-triggered detective reconciliation of a declared candidate or deployed static-artifact snapshot against authorized manifests or immutable registry records, including complete-population coverage and integrity-drift findings. It does not create the authoritative digest or signature verdict, admit an artifact, or decide promotion.
AID-D-004.008: AID-D-004.001 verifies static stored artifacts against approved manifests; AID-D-004.008 monitors which executable code and modules an applicable runtime actually loads. A valid file before deployment does not prove that only approved modules were loaded in the running process.
AID-D-004.002: AID-D-004.001 verifies artifact bytes and signatures; AID-D-004.002 verifies a fresh platform-backed confidential-runtime attestation measurement. Artifact integrity and attested execution environment identity are distinct cryptographic claims.
AID-M-002.002: AID-M-002.002 owns authoritative whole-artifact digest and signature creation and verification at ingestion, transfer, load, or admission; AID-D-004.001 independently replays those checks over a declared candidate or deployed snapshot and emits findings for drift. The same promotion-time verification cannot earn a second PASS under Detect, and detector coverage cannot replace artifact admission.
Implementation guidance
- AID-D-004.001-G001: Independently re-verify a complete candidate snapshot against the authorized manifest and emit findings without recreating the release-admission verdict.
- AID-D-004.001-G002: Detect drift from approved manifests across deployed models, framework libraries, system binaries, and runtime configs using file integrity monitoring.
AID-D-004.001-G001
Consume, do not recreate, the authoritative integrity manifest
AID-M-002.002 creates and verifies the whole-artifact manifest and signature used by admission. This detector receives that exact immutable manifest and a separately mounted read-only candidate snapshot, re-enumerates the complete file population, and emits a drift finding. It must not publish a replacement manifest or claim a second promotion-time integrity verdict.
Reconcile the exact read-only candidate snapshot
# File: detection/reverify_candidate_snapshot.py
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import stat
from pathlib import Path, PurePosixPath
from typing import Any
SHA256 = re.compile(r"^[a-f0-9]{64}$")
class DetectorError(Exception):
pass
def positive_limit(name: str) -> int:
value = int(os.environ[name])
if value <= 0:
raise DetectorError(name + " must be positive")
return value
MAX_FILES = positive_limit("D004001_MAX_FILES")
MAX_FILE_BYTES = positive_limit("D004001_MAX_FILE_BYTES")
MAX_TOTAL_BYTES = positive_limit("D004001_MAX_TOTAL_BYTES")
MAX_MANIFEST_BYTES = positive_limit("D004001_MAX_MANIFEST_BYTES")
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise DetectorError("duplicate JSON key: " + key)
result[key] = value
return result
def bounded_regular(path: Path, maximum: int) -> bytes:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0))
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or before.st_size <= 0 or before.st_size > maximum:
raise DetectorError("input is not a bounded regular file")
chunks, remaining = [], before.st_size
while remaining:
chunk = os.read(descriptor, min(1024 * 1024, remaining))
if not chunk:
raise DetectorError("input ended before its declared size")
chunks.append(chunk)
remaining -= len(chunk)
if os.read(descriptor, 1):
raise DetectorError("input grew while being captured")
after = os.fstat(descriptor)
if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (
after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns
):
raise DetectorError("input changed while being captured")
return b"".join(chunks)
finally:
os.close(descriptor)
def manifest(path: Path) -> tuple[dict[str, Any], bytes]:
raw = bounded_regular(path, MAX_MANIFEST_BYTES)
try:
value = json.loads(
raw.decode("utf-8"), object_pairs_hook=unique_object,
parse_constant=lambda token: (_ for _ in ()).throw(DetectorError("non-finite JSON number: " + token)),
)
except (UnicodeError, json.JSONDecodeError) as error:
raise DetectorError("manifest is not strict UTF-8 JSON") from error
if not isinstance(value, dict) or set(value) != {"schema_version", "release_id", "artifact_version", "files"}:
raise DetectorError("manifest schema differs")
if value["schema_version"] != "aidefend.artifact_manifest.v1":
raise DetectorError("manifest version differs")
if not isinstance(value["release_id"], str) or not value["release_id"]:
raise DetectorError("release identity is absent")
if not isinstance(value["artifact_version"], str) or not value["artifact_version"]:
raise DetectorError("artifact version is absent")
if not isinstance(value["files"], list) or not value["files"] or len(value["files"]) > MAX_FILES:
raise DetectorError("manifest population is invalid")
return value, raw
def safe_relative(value: Any) -> str:
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
raise DetectorError("manifest path is invalid")
relative = PurePosixPath(value)
if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
raise DetectorError("manifest path escapes the artifact root")
canonical = relative.as_posix()
if canonical != value:
raise DetectorError("manifest path is not canonical")
return canonical
def opened_digest(path: Path, device: int) -> tuple[int, str]:
before = path.lstat()
if not stat.S_ISREG(before.st_mode) or before.st_dev != device or before.st_size > MAX_FILE_BYTES:
raise DetectorError("snapshot member is not an allowed regular file")
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0))
try:
opened = os.fstat(descriptor)
if (opened.st_dev, opened.st_ino, opened.st_size) != (before.st_dev, before.st_ino, before.st_size):
raise DetectorError("snapshot member changed before open")
digest = hashlib.sha256()
remaining = opened.st_size
while remaining:
chunk = os.read(descriptor, min(1024 * 1024, remaining))
if not chunk:
raise DetectorError("snapshot member ended before its declared size")
digest.update(chunk)
remaining -= len(chunk)
if os.read(descriptor, 1):
raise DetectorError("snapshot member grew while being read")
after = os.fstat(descriptor)
if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns, opened.st_ctime_ns) != (
after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns
):
raise DetectorError("snapshot member changed while being read")
return opened.st_size, digest.hexdigest()
finally:
os.close(descriptor)
def capture(root: Path) -> dict[str, dict[str, Any]]:
if root.is_symlink():
raise DetectorError("snapshot root is a symlink")
root = root.resolve(strict=True)
root_state = root.stat()
if not stat.S_ISDIR(root_state.st_mode) or not os.statvfs(root).f_flag & os.ST_RDONLY:
raise DetectorError("candidate snapshot must be a read-only directory")
observed: dict[str, dict[str, Any]] = {}
total_bytes = 0
for path in sorted(root.rglob("*")):
state = path.lstat()
if stat.S_ISDIR(state.st_mode):
if state.st_dev != root_state.st_dev:
raise DetectorError("snapshot crosses a filesystem boundary")
continue
relative = PurePosixPath(path.relative_to(root).as_posix()).as_posix()
size, digest = opened_digest(path, root_state.st_dev)
if relative in observed:
raise DetectorError("duplicate snapshot path")
observed[relative] = {"size": size, "sha256": digest}
total_bytes += size
if len(observed) > MAX_FILES or total_bytes > MAX_TOTAL_BYTES:
raise DetectorError("snapshot exceeds verified collection limits")
if not observed:
raise DetectorError("candidate snapshot is empty")
return observed
def evaluate(manifest_path: Path, root: Path) -> dict[str, Any]:
document, raw = manifest(manifest_path)
expected: dict[str, dict[str, Any]] = {}
for row in document["files"]:
if not isinstance(row, dict) or set(row) != {"path", "size", "sha256"}:
raise DetectorError("manifest file row differs")
relative = safe_relative(row["path"])
size, digest = row["size"], row["sha256"]
if isinstance(size, bool) or not isinstance(size, int) or size < 0 or size > MAX_FILE_BYTES:
raise DetectorError("manifest file size is invalid")
if relative in expected or not isinstance(digest, str) or not SHA256.fullmatch(digest):
raise DetectorError("manifest path or digest is invalid or duplicated")
expected[relative] = {"size": size, "sha256": digest}
observed = capture(root)
missing = sorted(set(expected) - set(observed))
unexpected = sorted(set(observed) - set(expected))
changed = sorted(path for path in set(expected) & set(observed) if expected[path] != observed[path])
population = [
{"path": path, "size": observed[path]["size"], "sha256": observed[path]["sha256"]}
for path in sorted(observed)
]
return {
"schema_version": "aidefend.d004001-candidate-reverification.v1",
"control": "AID-D-004.001",
"detector_status": "FINDING" if missing or unexpected or changed else "CLEAR",
"release_id": document["release_id"],
"artifact_version": document["artifact_version"],
"manifest_sha256": hashlib.sha256(raw).hexdigest(),
"observed_population_sha256": hashlib.sha256(
json.dumps(population, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
).hexdigest(),
"expected_count": len(expected), "observed_count": len(observed),
"missing": missing, "unexpected": unexpected, "changed": changed,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--snapshot-root", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
try:
result = evaluate(args.manifest, args.snapshot_root)
except Exception as error:
result = {
"schema_version": "aidefend.d004001-candidate-reverification.v1",
"control": "AID-D-004.001", "detector_status": "ERROR",
"reason": "manifest or candidate-snapshot verification failed",
"error_class": type(error).__name__,
}
payload = (json.dumps(result, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n").encode("utf-8")
descriptor = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400)
try:
with os.fdopen(descriptor, "wb", closefd=False) as stream:
stream.write(payload); stream.flush(); os.fsync(stream.fileno())
finally:
os.close(descriptor)
if args.out.read_bytes() != payload:
raise RuntimeError("detector result readback differs")
return {"CLEAR": 0, "FINDING": 1, "ERROR": 2}[result["detector_status"]]
if __name__ == "__main__":
raise SystemExit(main())
Verify producer evidence and retain the detector result
set -euo pipefail
umask 077
: "${D004001_MAX_FILES:?load from the verified detector profile}"
: "${D004001_MAX_FILE_BYTES:?load from the verified detector profile}"
: "${D004001_MAX_TOTAL_BYTES:?load from the verified detector profile}"
: "${D004001_MAX_MANIFEST_BYTES:?load from the verified detector profile}"
STAGE="$(mktemp -d)"
trap 'rm -rf -- "$STAGE"' EXIT HUP INT TERM
for INPUT in artifact-manifest.json artifact-manifest.sigstore.json; do
test -f "evidence/$INPUT" && test ! -L "evidence/$INPUT"
install -m 0400 -- "evidence/$INPUT" "$STAGE/$INPUT"
done
cosign verify-blob --key keys/artifact-integrity-authority.pub \
--bundle "$STAGE/artifact-manifest.sigstore.json" \
"$STAGE/artifact-manifest.json"
set +e
python detection/reverify_candidate_snapshot.py \
--manifest "$STAGE/artifact-manifest.json" \
--snapshot-root /mnt/read-only-candidate \
--out "$STAGE/detector-result.json"
STATUS=$?
set -e
cosign sign-blob --yes --key env://D004001_DETECTOR_RESULT_KEY \
--bundle "$STAGE/detector-result.sigstore.json" "$STAGE/detector-result.json"
install -m 0400 -- "$STAGE/detector-result.json" "$STAGE/detector-result.sigstore.json" evidence/
exit "$STATUS"The manifest, bundle, trust key, detector profile, and candidate snapshot identity are deployment-selected inputs. A separate read-only verifier re-verifies the AID-M-002.002 signature and recomputes the population. Missing, unexpected, or changed members produce a finding; trust, snapshot, parsing, or publication failures produce an error. This detector publishes evidence and never promotes the artifact. AID-H-003.002 owns any release decision.
Test clean, missing, extra, modified, symlink, special-file, cross-filesystem, empty, oversized, duplicate-key, duplicate-path, wrong-key, bad-bundle, writable-snapshot, mutation-during-read, result-collision, and result-readback fixtures. The leaf may PASS only for complete detector coverage with no finding; that is not a second artifact-admission verdict.
AID-D-004.001-G002
Evaluate an actual read-only storage snapshot
Run this detector under a dedicated read-only identity only after the storage or deployment orchestrator has mounted an immutable filesystem snapshot read-only and placed its attested snapshot ID in the signature-verified runtime profile. The release-authority manifest binds that snapshot ID, root, release, and complete regular-file population. A live writable release directory is not a stable snapshot and must return ERROR.
from __future__ import annotations
# File: /usr/local/bin/check_model_integrity.py
import hashlib
import hmac
import json
import math
import os
import posixpath
import stat
import subprocess
import tempfile
from pathlib import Path, PurePosixPath
from urllib.parse import urlsplit
import requests
def required(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError("verified runtime-profile field is absent: " + name)
return value
def positive_float(name: str) -> float:
value = float(required(name))
if not math.isfinite(value) or value <= 0:
raise RuntimeError("verified runtime-profile field is invalid: " + name)
return value
PROFILE_VERSION = required("AIDEFEND_RUNTIME_PROFILE_VERSION")
PROFILE_SHA256 = required("AIDEFEND_RUNTIME_PROFILE_SHA256").lower()
COMMAND_TIMEOUT = positive_float("D004001_COMMAND_TIMEOUT_SECONDS")
CONNECT_TIMEOUT = positive_float("D004001_ALERT_CONNECT_TIMEOUT_SECONDS")
READ_TIMEOUT = positive_float("D004001_ALERT_READ_TIMEOUT_SECONDS")
OBSERVED_SNAPSHOT_ID = required("D004001_OBSERVED_SNAPSHOT_ID")
MANIFEST_PATH = Path(required("D004001_MANIFEST_PATH"))
MANIFEST_BUNDLE = Path(required("D004001_MANIFEST_BUNDLE_PATH"))
MANIFEST_VERIFY_KEY = Path("/etc/aidefend/d004001/trust/deployment-manifest.pub")
EVIDENCE_PATH = Path(required("D004001_EVIDENCE_PATH"))
WEBHOOK_URL = required("D004001_WEBHOOK_URL")
HEX = set("0123456789abcdef")
if len(PROFILE_SHA256) != 64 or set(PROFILE_SHA256) - HEX or PROFILE_SHA256 == "0" * 64:
raise RuntimeError("runtime-profile digest is not a nonzero lowercase SHA-256")
def strict_json(raw: bytes) -> object:
def unique_object(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError("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 token: (_ for _ in ()).throw(
ValueError("non-finite JSON constant: " + token)
),
)
def write_private(path: Path, payload: bytes) -> None:
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.chmod(path, 0o400)
def load_signed_manifest() -> tuple[dict, str]:
if MANIFEST_VERIFY_KEY.is_symlink():
raise RuntimeError("manifest verification key must not be a symlink")
key_path = MANIFEST_VERIFY_KEY.resolve(strict=True)
if not key_path.is_file() or key_path.stat().st_mode & 0o022:
raise RuntimeError("manifest verification key must be non-writable")
manifest_raw = MANIFEST_PATH.read_bytes()
bundle_raw = MANIFEST_BUNDLE.read_bytes()
key_raw = key_path.read_bytes()
with tempfile.TemporaryDirectory(prefix="aidefend-d004001-manifest-") as directory:
root = Path(directory)
os.chmod(root, 0o700)
manifest_snapshot = root / "manifest.json"
bundle_snapshot = root / "manifest.bundle"
key_snapshot = root / "verify-key.pub"
write_private(manifest_snapshot, manifest_raw)
write_private(bundle_snapshot, bundle_raw)
write_private(key_snapshot, key_raw)
captured = [
(manifest_snapshot, manifest_raw),
(bundle_snapshot, bundle_raw),
(key_snapshot, key_raw),
]
try:
subprocess.run(
["cosign", "verify-blob", "--key", str(key_snapshot),
"--bundle", str(bundle_snapshot), str(manifest_snapshot)],
check=True, capture_output=True, text=True,
timeout=COMMAND_TIMEOUT,
)
if any(
not hmac.compare_digest(path.read_bytes(), expected)
for path, expected in captured
):
raise RuntimeError("private manifest snapshot changed")
finally:
for path, _ in captured:
os.chmod(path, 0o600)
manifest = strict_json(manifest_raw)
fields = {
"schema_version", "release_id", "snapshot_id", "model_root", "artifacts",
}
if (
not isinstance(manifest, dict) or set(manifest) != fields
or manifest["schema_version"] != "aidefend.model-deployment-manifest.v2"
or any(
not isinstance(manifest[field], str) or not manifest[field]
for field in ("release_id", "snapshot_id", "model_root")
)
):
raise ValueError("signed model manifest schema differs")
return manifest, hashlib.sha256(manifest_raw).hexdigest()
def safe_relative(value: object) -> str:
if not isinstance(value, str) or not value or "\\" in value:
raise ValueError("manifest path is not a nonempty POSIX relative path")
normalized = posixpath.normpath(value)
path = PurePosixPath(value)
if normalized != value or path.is_absolute() or any(
part in {"", ".", ".."} for part in path.parts
):
raise ValueError("manifest path escapes the signed model root")
return path.as_posix()
def enumerate_regular_files(root: Path) -> dict[str, Path]:
observed = {}
for current_root, directory_names, file_names in os.walk(root, followlinks=False):
current = Path(current_root)
for name in directory_names:
candidate = current / name
if candidate.is_symlink() or not candidate.is_dir():
raise ValueError("snapshot contains a linked or invalid directory")
for name in file_names:
candidate = current / name
relative = candidate.relative_to(root).as_posix()
if candidate.is_symlink() or not candidate.is_file():
raise ValueError("snapshot contains a linked or special member: " + relative)
if relative in observed:
raise ValueError("snapshot contains a duplicate relative path")
observed[relative] = candidate
return observed
def read_stable_regular(path: Path) -> bytes:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags)
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode):
raise ValueError("snapshot member is not a regular file")
chunks = []
while True:
chunk = os.read(descriptor, 1024 * 1024)
if not chunk:
break
chunks.append(chunk)
after = os.fstat(descriptor)
finally:
os.close(descriptor)
identity_before = (
before.st_dev, before.st_ino, before.st_size,
before.st_mtime_ns, before.st_ctime_ns,
)
identity_after = (
after.st_dev, after.st_ino, after.st_size,
after.st_mtime_ns, after.st_ctime_ns,
)
raw = b"".join(chunks)
if identity_before != identity_after or len(raw) != before.st_size:
raise RuntimeError("snapshot member changed while being captured")
return raw
def capture_tree(root: Path) -> tuple[tempfile.TemporaryDirectory, dict[str, str], str]:
first_population = enumerate_regular_files(root)
owner = tempfile.TemporaryDirectory(prefix="aidefend-d004001-tree-")
snapshot = Path(owner.name)
os.chmod(snapshot, 0o700)
observed = {}
for relative_text in sorted(first_population):
raw = read_stable_regular(first_population[relative_text])
relative = PurePosixPath(relative_text)
destination = snapshot.joinpath(*relative.parts)
destination.parent.mkdir(parents=True, exist_ok=True)
current = destination.parent
while current != snapshot:
os.chmod(current, 0o700)
current = current.parent
write_private(destination, raw)
if not hmac.compare_digest(destination.read_bytes(), raw):
raise RuntimeError("private artifact snapshot readback differs")
observed[relative_text] = hashlib.sha256(raw).hexdigest()
second_population = enumerate_regular_files(root)
if set(second_population) != set(first_population):
raise RuntimeError("snapshot population changed during capture")
canonical = (json.dumps(
[{"path": path, "sha256": observed[path]} for path in sorted(observed)],
sort_keys=True, separators=(",", ":"),
) + "\n").encode("utf-8")
return owner, observed, hashlib.sha256(canonical).hexdigest()
def evaluate() -> dict:
manifest, manifest_sha256 = load_signed_manifest()
if manifest["snapshot_id"] != OBSERVED_SNAPSHOT_ID:
raise ValueError("mounted snapshot identity differs from signed manifest")
source_root = Path(manifest["model_root"])
if source_root.is_symlink():
raise ValueError("model snapshot root must not be a symlink")
root = source_root.resolve(strict=True)
if not root.is_dir():
raise ValueError("model snapshot root is not a directory")
if not os.statvfs(root).f_flag & os.ST_RDONLY:
raise ValueError("model root must be a filesystem snapshot mounted read-only")
artifacts = manifest["artifacts"]
if not isinstance(artifacts, list) or not artifacts:
raise ValueError("signed artifact population is empty")
expected = {}
for item in artifacts:
if (
not isinstance(item, dict) or set(item) != {"path", "sha256"}
or not isinstance(item["sha256"], str)
):
raise ValueError("artifact entry schema differs")
relative = safe_relative(item["path"])
digest = item["sha256"].lower()
if relative in expected or len(digest) != 64 or set(digest) - HEX or digest == "0" * 64:
raise ValueError("artifact path or digest is invalid or duplicated")
expected[relative] = digest
owner, observed, captured_tree_sha256 = capture_tree(root)
missing = sorted(set(expected) - set(observed))
unexpected = sorted(set(observed) - set(expected))
changed = sorted(
path for path in set(expected) & set(observed)
if expected[path] != observed[path]
)
result = {
"schema_version": "aidefend.d004001-integrity-result.v2",
"control": "AID-D-004.001",
"status": "FAIL" if missing or unexpected or changed else "PASS",
"release_id": manifest["release_id"],
"snapshot_id": manifest["snapshot_id"],
"manifest_sha256": manifest_sha256,
"captured_tree_sha256": captured_tree_sha256,
"runtime_profile_version": PROFILE_VERSION,
"runtime_profile_sha256": PROFILE_SHA256,
"expected_count": len(expected),
"observed_count": len(observed),
"missing": missing,
"unexpected": unexpected,
"changed": changed,
}
owner.cleanup()
return result
def post_finding(result: dict) -> None:
parsed = urlsplit(WEBHOOK_URL)
if (
parsed.scheme != "https" or not parsed.hostname or parsed.username
or parsed.password or parsed.fragment
):
raise ValueError("alert endpoint must be one canonical HTTPS URL")
with requests.Session() as session:
with session.post(
WEBHOOK_URL, json=result,
cert=(required("D004001_ALERT_CLIENT_CERT"), required("D004001_ALERT_CLIENT_KEY")),
verify=required("D004001_ALERT_CA_BUNDLE"),
timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
allow_redirects=False, stream=True,
) as response:
if 300 <= response.status_code < 400:
raise RuntimeError("alert redirect is forbidden")
response.raise_for_status()
def publish_result(result: dict) -> None:
payload = (json.dumps(
result, sort_keys=True, separators=(",", ":"), allow_nan=False,
) + "\n").encode("utf-8")
parent = EVIDENCE_PATH.parent.resolve(strict=True)
parent_stat = parent.stat()
if parent_stat.st_uid != os.geteuid() or parent_stat.st_mode & 0o022:
raise RuntimeError("evidence directory ownership or mode is unsafe")
destination = parent / EVIDENCE_PATH.name
descriptor, temporary_name = tempfile.mkstemp(prefix=".d004001-result-", dir=parent)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, 0o400)
os.replace(temporary, destination)
if not hmac.compare_digest(destination.read_bytes(), payload):
raise RuntimeError("local integrity evidence readback differs")
finally:
if temporary.exists():
temporary.unlink()
try:
result = evaluate()
except Exception as error:
result = {
"schema_version": "aidefend.d004001-integrity-result.v2",
"control": "AID-D-004.001",
"status": "ERROR",
"runtime_profile_version": PROFILE_VERSION,
"runtime_profile_sha256": PROFILE_SHA256,
"error_class": type(error).__name__,
}
publish_result(result)
if result["status"] != "PASS":
post_finding(result)
raise SystemExit(1)Schedule and independently verify
Run from a hardened systemd timer, EDR/FIM job, or deployment agent. Retain the current manifest bundle, runtime-profile identity, storage-snapshot attestation, exact population result, captured-tree digest, and local evidence digest in storage the monitored node cannot alter. An independent read-only identity mounts the same snapshot ID, re-enumerates it, captures every member once, and compares expected, observed, missing, unexpected, and changed members.
Exercise clean, missing, modified, extra, unreadable, symlink, special-file, duplicate-key, bad-bundle, wrong snapshot ID, writable mount, mutation-during-read, timeout, redirect, TLS, evidence-publication, and alert-failure fixtures. Population or digest drift is FAIL; detector, trust, snapshot, or handoff failure is ERROR. Neither state establishes a clean node, and incident response owns containment or restoration.
Open-source tools
- MLflow Model Registry
- DVC
- Notation
- Cosign
- sha256sum
- AIDE
Commercial / hosted tools
- Databricks Models in Unity Catalog
- Amazon SageMaker AI Model Registry
- Google Vertex AI Model Registry
- JFrog Artifactory
Threat mappings
MITRE ATLAS
- AML.T0011 User Execution
- AML.T0020 Training Data Poisoning
- AML.T0011.000 User Execution: Unsafe AI Artifacts
- AML.T0018.003 Manipulate AI Model: Modify Prompt Construction Logic
- AML.T0018 Manipulate AI Model
- AML.T0018.000 Manipulate AI Model: Poison AI Model
- AML.T0018.001 Manipulate AI Model: Modify AI Model Architecture
- AML.T0018.002 Manipulate AI Model: Embed Malware
- AML.T0076 Corrupt AI Model
- AML.T0010 AI Supply Chain Compromise
- AML.T0010.001 AI Supply Chain Compromise: AI Software
- AML.T0010.002 AI Supply Chain Compromise: Data
- AML.T0010.003 AI Supply Chain Compromise: Model
- AML.T0010.004 AI Supply Chain Compromise: Container Registry
- AML.T0074 Masquerading
- AML.T0112.001 Machine Compromise: AI Artifacts
MAESTRO
- Data Tampering (L2)
- Compromised Container Images (L4)
- Supply Chain Attacks (Cross-Layer)
- Compromised Framework Components (L3)
OWASP LLM Top 10 2026
- LLM04:2026 Supply Chain
- LLM05:2026 Data and Model Poisoning
OWASP ML Top 10 2023
- ML06:2023 AI Supply Chain Attacks
- ML07:2023 Transfer Learning Attack (signature verification detects substituted pre-trained artifacts)
OWASP Top 10 for Agentic Applications 2026
- ASI04:2026 Agentic Supply Chain Vulnerabilities
NIST Adversarial Machine Learning 2025
- NISTAML.051 Model Poisoning (Supply Chain)
- NISTAML.026 Model Poisoning (Integrity) (digest verification detects unauthorized model-byte modification)
Cisco Integrated AI Security and Safety Framework
- AISubtech-5.2.1 Agent Profile Tampering (changed file-backed model or runtime-configuration artifacts)
- AISubtech-9.3.1 Malicious Package / Tool Injection (changed file-backed model or runtime-configuration artifacts)
- AISubtech-9.3.3 Dependency Replacement / Rug Pull (changed file-backed model or runtime-configuration artifacts)
- AITech-5.2 Configuration Persistence (changed file-backed model or runtime-configuration artifacts)
- AITech-9.3 Dependency / Plugin Compromise (changed file-backed model or runtime-configuration artifacts)
Google Secure AI Framework 2.0 - Risks
- MST: Model Source Tampering
- MDT: Model Deployment Tampering
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
- Agents - Tools MCP Server 13.21: Supply Chain Attacks