AID-M-002.003: External Dataset Content-Risk Characterization
Pillars: data, model
Lifecycle: scoping, building
Scope boundary
Owns content-risk characterization of an external dataset's actual records, distributions, labels, anomalies, sensitive content, and poisoning indicators before downstream admission decisions.
AID-H-003.003: AID-M-002.003 characterizes what risks are present in the dataset contents; AID-H-003.003 decides whether the external dataset and its supplier provenance satisfy the admission policy. A low-risk content scan does not approve an untrusted supplier, and trusted provenance does not prove that dataset contents are safe.
Implementation guidance
- AID-M-002.003-G001: Define a versioned content-risk policy for the dataset class and bind it to the complete immutable input manifest.
- AID-M-002.003-G002: Stream every CSV and JSONL manifest member through the content-risk profiler and block promotion on findings or incomplete coverage.
- AID-M-002.003-G003: Replay the complete content-risk profile from read-only inputs before promotion.
AID-M-002.003-G001
1. Freeze the dataset-class content policy
Consume the complete manifest already authenticated by AID-H-003.003, then version the supported formats, required fields and types, content fields, duplicate keys, distribution baselines, PII entities, detector versions, and finding limits used for this dataset class. Manifest signatures, publisher identity, rights, and lineage stay with AID-H-003.003; a trusted source is not evidence that its content is safe.
{"schema_version":"aidefend.content-risk-policy.v1","policy_id":"support-corpus-v3","presidio":{"package_version":"2.2.355","language":"en","entities":["EMAIL_ADDRESS","PHONE_NUMBER","US_SSN","CREDIT_CARD"],"min_score":0.70},"trufflehog":{"version_output":"trufflehog 3.90.8"},"formats":{".csv":{"kind":"csv","required_fields":["text","label"],"field_types":{"text":"string","label":"string"},"content_fields":["text"],"duplicate_key_fields":["text"],"distributions":{"label":{"expected":{"safe":0.8,"unsafe":0.2},"max_total_variation":0.10}}},".jsonl":{"kind":"jsonl","required_fields":["text","label"],"field_types":{"text":"string","label":"string"},"content_fields":["text"],"duplicate_key_fields":["text"],"distributions":{"label":{"expected":{"safe":0.8,"unsafe":0.2},"max_total_variation":0.10}}}},"limits":{"pii_findings":0,"verified_secrets":0,"schema_violations":0,"malformed_records":0,"duplicate_records":0,"distribution_breaches":0}}The values and tool versions above are a reviewed deployment example, not universal thresholds. Generate and pin them through the version-locked build and organizational policy for the actual dataset class.
AID-M-002.003-G002
2. Profile every manifest member
Save the following as tools/profile_dataset.py in the locked scanner image. It normalizes the manifest, inventories every file, rejects missing, extra, changed, linked, escaped, unreadable, or unsupported inputs, streams CSV and JSONL records, checks schema and malformed records, detects cross-file duplicates, measures policy-defined distributions, runs Presidio and TruffleHog --only-verified, and reports tool/version failures as incomplete coverage.
from __future__ import annotations
import argparse,csv,hashlib,json,math,os,sqlite3,subprocess,tempfile
from collections import Counter,defaultdict
from importlib.metadata import PackageNotFoundError,version
from pathlib import Path
try:
from presidio_analyzer import AnalyzerEngine
except ImportError:
AnalyzerEngine=None
try:
COMMAND_TIMEOUT_SECONDS=float(os.environ["M002003_SCANNER_COMMAND_TIMEOUT_SECONDS"])
except (KeyError,ValueError) as error:
raise RuntimeError("signed runtime-profile scanner timeout is unavailable") from error
if not math.isfinite(COMMAND_TIMEOUT_SECONDS) or COMMAND_TIMEOUT_SECONDS<=0:
raise RuntimeError("signed runtime-profile scanner timeout is invalid")
T={"string":str,"integer":int,"number":(int,float),"boolean":bool}
def canon(v): return json.dumps(v,sort_keys=True,separators=(",",":"),ensure_ascii=False).encode("utf-8")
def sha(p):
h=hashlib.sha256()
with p.open("rb") as f:
for b in iter(lambda:f.read(1048576),b""): h.update(b)
return h.hexdigest()
def inventory(root):
files=[]; unsafe=[]
for p in sorted(root.rglob("*")):
rel=p.relative_to(root).as_posix()
if p.is_symlink(): unsafe.append(f"symlink:{rel}")
elif p.is_file(): files.append({"path":rel,"size_bytes":p.stat().st_size,"sha256":sha(p)})
return files,unsafe
def rows(path,kind):
if kind=="csv":
try:
with path.open("r",encoding="utf-8",newline="") as f:
r=csv.reader(f,strict=True)
try: head=next(r)
except StopIteration: yield 1,None,"empty_file",False; return
if not head or len(head)!=len(set(head)) or any(not x for x in head): yield 1,None,"invalid_header",True; return
try:
for n,values in enumerate(r,2):
yield (n,None,"column_count",False) if len(values)!=len(head) else (n,dict(zip(head,values)),None,False)
except csv.Error as e: yield r.line_num,None,f"csv_error:{e}",True
except (OSError,UnicodeError) as e: yield 0,None,f"read_error:{e}",True
elif kind=="jsonl":
try:
with path.open("r",encoding="utf-8") as f:
for n,raw in enumerate(f,1):
try: value=json.loads(raw)
except json.JSONDecodeError as e: yield n,None,f"json_error:{e.msg}",False; continue
yield (n,value,None,False) if isinstance(value,dict) else (n,None,"record_not_object",False)
except (OSError,UnicodeError) as e: yield 0,None,f"read_error:{e}",True
def type_ok(value,name):
typ=T.get(name)
if typ is None: raise ValueError(f"unsupported policy type:{name}")
return not(name in {"integer","number"} and isinstance(value,bool)) and isinstance(value,typ)
def th_version():
p=subprocess.run(["trufflehog","--version"],capture_output=True,text=True,check=False,timeout=COMMAND_TIMEOUT_SECONDS)
if p.returncode: raise RuntimeError("TruffleHog version query failed")
return (p.stdout or p.stderr).strip()
def secrets(path):
p=subprocess.run(["trufflehog","filesystem","--json","--no-update","--only-verified",str(path)],capture_output=True,text=True,check=False,timeout=COMMAND_TIMEOUT_SECONDS)
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip() or "TruffleHog failed")
out=Counter()
for raw in p.stdout.splitlines():
if raw.strip():
item=json.loads(raw)
if item.get("Verified") is True: out[str(item.get("DetectorName","unknown"))]+=1
return out
def normalized_manifest(manifest,errors):
raw=manifest.get("files")
if not isinstance(raw,list) or not raw: errors.append("manifest_has_no_files"); return []
out=[]; paths=set()
for i,item in enumerate(raw):
try: row={"path":str(item["path"]),"size_bytes":int(item["size_bytes"]),"sha256":str(item["sha256"]).lower()}
except (KeyError,TypeError,ValueError): errors.append(f"invalid_manifest_entry:{i}"); continue
if row["path"] in paths: errors.append(f"duplicate_manifest_path:{row['path']}")
paths.add(row["path"]); out.append(row)
return sorted(out,key=lambda x:x["path"])
def build(root,manifest_path,policy_path):
root=root.resolve(); manifest=json.loads(manifest_path.read_text(encoding="utf-8")); policy=json.loads(policy_path.read_text(encoding="utf-8"))
errors=[]; coverage=[]; expected=normalized_manifest(manifest,errors); actual,unsafe=inventory(root); errors+=unsafe
if expected!=actual: errors.append("manifest_inventory_or_digest_mismatch")
for item in expected:
rel=item["path"]
try:
p=(root/rel).resolve(strict=True)
if not rel or not p.is_relative_to(root): errors.append(f"unsafe_manifest_path:{rel}")
except OSError: errors.append(f"unreadable_manifest_path:{rel}")
try: pv=version("presidio-analyzer")
except PackageNotFoundError: pv="NOT_INSTALLED"
analyzer=None
if AnalyzerEngine is None or pv!=policy["presidio"]["package_version"]: errors.append("presidio_version_mismatch")
else:
try: analyzer=AnalyzerEngine()
except Exception as e: errors.append(f"presidio_initialization_error:{type(e).__name__}")
th_ok=False
try: th_ok=th_version()==policy["trufflehog"]["version_output"]
except (OSError,RuntimeError,subprocess.SubprocessError): pass
if not th_ok: errors.append("trufflehog_version_mismatch")
totals=Counter(); pii=Counter(); secret_types=Counter(); dists=defaultdict(Counter); dist_rules={}
with tempfile.TemporaryDirectory(prefix="aidefend-content-risk-") as td:
db=sqlite3.connect(str(Path(td)/"duplicates.sqlite3")); db.execute("CREATE TABLE seen(digest TEXT PRIMARY KEY)")
manifest_bad=any(x.startswith(("manifest_","invalid_manifest","duplicate_manifest","symlink","unsafe_manifest","unreadable_manifest")) for x in errors)
if not manifest_bad:
for item in expected:
rel=item["path"]; path=root/rel; spec=policy.get("formats",{}).get(path.suffix.lower())
if spec is None: coverage.append({"path":rel,"state":"UNSUPPORTED","reason":path.suffix.lower() or "no_suffix"}); errors.append(f"unsupported_media:{rel}"); continue
if spec.get("kind") not in {"csv","jsonl"}: coverage.append({"path":rel,"state":"UNSUPPORTED","reason":"invalid_format_adapter"}); errors.append(f"unsupported_adapter:{rel}"); continue
counts=Counter(); malformed=Counter(); fatal=[]
for field,rule in spec.get("distributions",{}).items():
old=dist_rules.setdefault(field,rule)
if old!=rule: fatal.append(f"conflicting_distribution_policy:{field}")
for _n,record,issue,is_fatal in rows(path,spec["kind"]):
if issue:
counts["malformed_records"]+=1; malformed[issue]+=1
if is_fatal: fatal.append(issue)
continue
counts["records"]+=1
bad=any(record.get(f) in (None,"") for f in spec.get("required_fields",[]))
for f,want in spec.get("field_types",{}).items():
if f in record and not type_ok(record[f],want): bad=True
counts["schema_violations"]+=int(bad)
key=[record.get(f) for f in spec.get("duplicate_key_fields",[])]
if key:
d=hashlib.sha256(canon(key)).hexdigest()
if db.execute("INSERT OR IGNORE INTO seen VALUES(?)",(d,)).rowcount==0: counts["duplicate_records"]+=1
for f in spec.get("distributions",{}): dists[f][str(record.get(f,"<MISSING>"))]+=1
if analyzer is not None:
for f in spec.get("content_fields",[]):
value=record.get(f)
if isinstance(value,str):
for hit in analyzer.analyze(text=value,language=policy["presidio"]["language"],entities=policy["presidio"]["entities"],score_threshold=float(policy["presidio"]["min_score"])):
pii[hit.entity_type]+=1; counts["pii_findings"]+=1
if analyzer is None: fatal.append("pii_scan_unavailable")
if th_ok:
try: found=secrets(path); secret_types.update(found); counts["verified_secrets"]+=sum(found.values())
except (OSError,RuntimeError,subprocess.SubprocessError,json.JSONDecodeError) as e: fatal.append(f"secret_scan_error:{type(e).__name__}")
else: fatal.append("secret_scan_unavailable")
totals.update(counts); coverage.append({"path":rel,"state":"INCOMPLETE" if fatal else "MEASURED","counts":dict(sorted(counts.items())),"malformed_by_reason":dict(sorted(malformed.items())),"fatal_errors":sorted(set(fatal))})
errors += [f"scan_incomplete:{rel}:{reason}" for reason in fatal]
db.close()
dist_report={}; dist_breaches=0
for field,counts in sorted(dists.items()):
rule=dist_rules[field]; total=sum(counts.values()); observed={k:v/total for k,v in sorted(counts.items())} if total else {}; expected_dist={k:float(v) for k,v in rule.get("expected",{}).items()}
if abs(sum(expected_dist.values())-1)>1e-9: errors.append(f"invalid_expected_distribution:{field}")
keys=set(observed)|set(expected_dist); tvd=.5*sum(abs(observed.get(k,0)-expected_dist.get(k,0)) for k in keys); breached=total==0 or tvd>float(rule["max_total_variation"]); dist_breaches+=int(breached)
dist_report[field]={"counts":dict(sorted(counts.items())),"observed":observed,"total_variation":round(tvd,8),"breached":breached}
totals["pii_findings"]=sum(pii.values()); totals["verified_secrets"]=sum(secret_types.values()); totals["distribution_breaches"]=dist_breaches
violations=[{"metric":m,"observed":totals.get(m,0),"maximum":int(mx)} for m,mx in sorted(policy["limits"].items()) if totals.get(m,0)>int(mx)]
complete=bool(expected) and not errors and len(coverage)==len(expected) and all(x["state"]=="MEASURED" for x in coverage)
return {"schema_version":"aidefend.content-risk-report.v1","dataset_id":manifest.get("dataset_id"),"manifest_sha256":sha(manifest_path),"policy_sha256":sha(policy_path),"complete":complete,"promotion_allowed":complete and not violations,"blocking_reasons":sorted(set(errors)),"violations":violations,"totals":dict(sorted(totals.items())),"pii_by_entity":dict(sorted(pii.items())),"verified_secrets_by_detector":dict(sorted(secret_types.items())),"distributions":dist_report,"coverage":coverage}
def main():
p=argparse.ArgumentParser(); p.add_argument("--root",type=Path,required=True); p.add_argument("--manifest",type=Path,required=True); p.add_argument("--policy",type=Path,required=True); mode=p.add_mutually_exclusive_group(required=True); mode.add_argument("--out",type=Path); mode.add_argument("--verify",type=Path); a=p.parse_args()
before=inventory(a.root.resolve()); actual=build(a.root,a.manifest,a.policy); after=inventory(a.root.resolve())
if before!=after: raise SystemExit("source dataset changed during read-only scan")
if a.verify:
expected=json.loads(a.verify.read_text(encoding="utf-8"))
if canon(actual)!=canon(expected): raise SystemExit("replay mismatch")
print(hashlib.sha256(canon(actual)).hexdigest())
else: a.out.write_text(json.dumps(actual,indent=2,sort_keys=True)+"\n",encoding="utf-8")
raise SystemExit(0 if actual["promotion_allowed"] else 2)
if __name__=="__main__": main()Pin the actual Presidio package, TruffleHog binary, and language model in the container lock. The report retains only counts, entity types, detector names, coverage states, and policy/digest bindings; never store raw PII, secret values, or matching excerpts.
python tools/profile_dataset.py --root quarantine/dataset --manifest evidence/dataset-manifest.json --policy policies/content-risk.json --out evidence/content-risk-report.jsonPromotion is allowed only when the complete manifest population is measured and no policy limit is exceeded. Unsupported media, tool/version mismatch, scanner failure, unsafe path, population drift, or unreadable input remains incomplete and blocks promotion.
AID-M-002.003-G003
3. Replay the profile at the promotion gate
Mount the dataset root, manifest, policy, and recorded report read-only in an independent promotion runner. First generate the signed candidate report, then rerun the same locked profiler with --verify; the verifier re-inventories the inputs and requires canonical byte-equivalence with the recorded deterministic report.
python tools/profile_dataset.py --root quarantine/dataset --manifest evidence/dataset-manifest.json --policy policies/content-risk.json --out evidence/content-risk-report.json
python tools/profile_dataset.py --root quarantine/dataset --manifest evidence/dataset-manifest.json --policy policies/content-risk.json --verify evidence/content-risk-report.jsonExercise negative fixtures for an extra file, missing member, digest change, symlink, malformed record, unsupported suffix, Presidio or TruffleHog version drift, verified secret, PII finding, duplicate, distribution breach, and replay mismatch. Any nonzero scanner result, incomplete coverage, changed read-only inventory, or replay mismatch prevents PASS.
Open-source tools
- Microsoft Presidio
- TruffleHog
- Gitleaks
- ydata-profiling
- Great Expectations
Commercial / hosted tools
- Google Cloud Sensitive Data Protection
- Amazon Macie
- Microsoft Purview
Threat mappings
MITRE ATLAS
- AML.T0010 AI Supply Chain Compromise
- AML.T0115 Publish Poisoned AI Artifacts
- AML.T0099 AI Agent Tool Data Poisoning
- AML.T0010.002 AI Supply Chain Compromise: Data
- AML.T0115.000 Publish Poisoned AI Artifacts: Datasets
- AML.T0020 Training Data Poisoning
- AML.T0059 Erode Dataset Integrity
MAESTRO
- Data Poisoning (L2)
- Supply Chain Attacks (Cross-Layer)
- Data Poisoning (Training Phase) (L1)
OWASP LLM Top 10 2026
- LLM02:2026 Sensitive Information Disclosure
- LLM05:2026 Data and Model Poisoning
OWASP ML Top 10 2023
- ML02:2023 Data Poisoning Attack
- 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.013 Data Poisoning
- NISTAML.024 Targeted Poisoning
- NISTAML.037 Training Data Attacks
- NISTAML.023 Backdoor Poisoning
Cisco Integrated AI Security and Safety Framework
- AISubtech-10.1.3 Sensitive Data Reconstruction (Blocks detected sensitive training literals before later memorization-based extraction)
- AISubtech-7.3.1 Corrupted Third-Party Data
- AISubtech-8.2.1 Training Data Exposure (Blocks dataset promotion when measured PII, card data or verified secrets exceed policy)
- AISubtech-8.2.2 LLM Data Leakage (Blocks dataset promotion when measured PII, card data or verified secrets exceed policy)
- AISubtech-8.3.3 Personally Identifiable Information Exposure (Blocks dataset promotion when measured PII, card data or verified secrets exceed policy)
- AISubtech-8.3.4 Payment Card Industry Data Exposure (Blocks dataset promotion when measured PII, card data or verified secrets exceed policy)
- AITech-6.1 Training Data Poisoning (Detects policy-defined malformed records and label-distribution poisoning indicators)
- AITech-7.3 Data Source Abuse and Manipulation
- AITech-8.2 Data Exfiltration / Exposure (Blocks dataset promotion when measured PII, card data or verified secrets exceed policy)
- AITech-8.3 Information Disclosure (Blocks dataset promotion when measured PII, card data or verified secrets exceed policy)
Google Secure AI Framework 2.0 - Risks
- DP: Data Poisoning
- EDH: Excessive Data Handling (PII/secrets scanning prevents ingesting data beyond policy)
- SDD: Sensitive Data Disclosure (PII scanning prevents sensitive data from entering training sets)
Databricks AI Security Framework 3.0
- Raw Data 1.2: Missing data classification
- Raw Data 1.3: Poor data quality
- Raw Data 1.7: Lack of data trustworthiness
- Raw Data 1.11: Compromised 3rd-party datasets
- Data Prep 2.3: Raw data criteria
- Datasets 3.1: Data poisoning