Repository navigation
Identifier-hash salt keeps only the first 16 bytes of FORGELM_AUDIT_SECRET (XOR), discarding the rest of the secret's entropy (CR-A10-054) #369
Description
Wave 1 · theme D1-11: Other high-severity packages · highest severity High · 9 finding(s) · effort small
Summary
An output dir (audit log + .forgelm_audit_salt) is copied to an evidence bundle. The secret's first 16 characters are a predictable prefix, so the copy holder can recover subject identifiers from target_id/query_hash with a wordlist.
Primary finding
CR-A10-054 — Identifier-hash salt keeps only the first 16 bytes of FORGELM_AUDIT_SECRET (XOR), discarding the rest of the secret's entropy
severity Low · reachability documented-opt-in · intent intended-and-acceptable · confidence likely · independently confirmed · not reproduced (code trace)
What happens. _resolve_salt XORs the persistent 16-byte salt with env_secret[:16]. The target_id / query_hash is sha256(salt || value). Using plain SHA-256 with a fixed-length 128-bit secret prefix instead of HMAC is not exploitable here: nothing verifies a MAC, the prefix is fixed-length so concatenation is unambiguous, and length extension only produces digests of identifiers that contain padding bytes. The real weakness is the truncation. Deployers are told to use long KMS secrets, and those are often hex or base64 or carry a structured prefix (e.g. 'prod-forgelm-aud...'), so only the first 16 characters (64 bits for hex) contribute. Someone who obtains the salt file (it sits next to audit_log.jsonl and travels with any copy of the output dir) then faces a much smaller search space for the env half before running a wordlist over low-entropy identifiers (emails, phone numbers). docs/design/gdpr_erasure.md:288 also misdescribes the derivation ('first 16 bytes of the secret if set, else per-dir salt'), while the code XORs both.
Where.
forgelm/cli/subcommands/_purge.py:216-223forgelm/cli/subcommands/_purge.py:227-229forgelm/cli/subcommands/_reverse_pii.py:500docs/design/gdpr_erasure.md:288
Evidence.
- Static: env_prefix = env_secret[:_SALT_BYTES].ljust(_SALT_BYTES, b'\x00') with _SALT_BYTES = 16 (_purge.py:80, 218); salt = XOR(persistent, env_prefix) (:222).
Recommended fix. Derive the hashing key as HMAC-SHA256(key=full FORGELM_AUDIT_SECRET, msg=persistent_salt), or use a KDF over the whole secret, and hash identifiers with HMAC(key, value). Version the salt_source label so old digests stay distinguishable. Fix the design-doc description.
Other findings with the same root cause
These close with the same fix; update them in the same pull request.
-
CR-A10-061 (code, Low) — Persistent identifier salt is read through symlinks with no mode, owner or file-type check, so an output-dir writer can pin a known salt and make purge target_id / reverse-pii query_hash precomputable
severity Low · reachabilitydefault· intentintended-but-harmful· confidence likely · independently confirmed · reproduced end to end
Failure scenario. Output-dir writer runsrm <out>/.forgelm_audit_salt; ln -s /dev/zero <out>/.forgelm_audit_salt(or writes 16 known bytes with mode 0644). The operator runsforgelm purge --row-id subject-42 --corpus c.jsonl --output-dir <out>: exit 0, with target_id == sha256(b'\x00'16 + b'subject-42'). The attacker hashes a wordlist with the zero salt and recovers the subject identifier from the audit log.
Where.forgelm/cli/subcommands/_purge.py:165-193,forgelm/cli/subcommands/_purge.py:166,forgelm/cli/subcommands/_purge.py:172,forgelm/cli/subcommands/_purge.py:184
Recommended fix. Read the salt via os.open(O_RDONLY|O_NOFOLLOW|O_NONBLOCK) and fstat it. Require S_ISREG, st_uid == geteuid(), mode & 0o077 == 0 and st_nlink == 1, and refuse otherwise (exit 1 with an actionable message). Record a short keyed salt fingerprint (e.g. HMAC(secret, salt)[:8], or sha256(salt)[:8] when no secret is set) in data.erasure_ and data.access_request_query, so a salt swap is visible in the c… -
TECH-T11-004 (technology, Next release) — Regulation and standards: GDPR, ISO/IEC 27001, SOC 2, ISO/IEC 42001, EDPB, EU Cyber Resilience Act: Pseudonymisation: masked corpora and hashed subject identifiers in purge/reverse-pii audit events (…
severity Next release
Item. Pseudonymisation: masked corpora and hashed subject identifiers in purge/reverse-pii audit events (GDPR Art. 4(5), Recital 26; EDPB Guidelines 01/2025) · verdict:incorrect-claim· urgency: Next release
State in ForgeLM. Masking claims: docs/usermanuals/en/compliance/gdpr.md:38 (TR :38) says 'By the time data lands in JSONL, identifiable subjects are removed', and :85 (TR :85) says 'The masked JSONL doesn't contain their identifying info — already minimised.' The regex masker covers 8 structured types (forgelm/data_audit/_types.py:19) and does not mask names (docs/usermanuals/en/data/pii-ml.md:8 itself says names…
State of the ecosystem on 2026-10-01. GDPR Recital 26 (in force): data that 'have undergone pseudonymisation, which could be attributed to a natural person by the use of additional information should be considered to be information on an identifiable natural person'. Art. 4(5) defines pseudonymisation as requiring that the additional information be 'kept separately'. EDPB Guidelines 01/2025 on Pseudonymisation were adopted on 16 Janu…
Recommendation. Reword gdpr.md:38/:85 (EN+TR) to 'pseudonymised / partially minimised; names and free-text identifiers remain unless --pii-ml is used', and fix them together with gdpr.md:78. Document target_id and query_hash as pseudonymised personal data. Recommend FORGELM_AUDIT_SECRET, or a salt stored outside output_dir, whenever purge or reverse-pii is used, or require it. Consider a versioned move to HMAC-S…
Sources. -
DOC-BLEGAL-022 (docs, High) — docs/usermanuals/en/compliance/gdpr-erasure.md: misleading — Article 17(3)(b) preserves audit / accounting records as a legal-obligation defence
severity High
Where.docs/usermanuals/en/compliance/gdpr-erasure.md:52(Turkish mirror:docs/usermanuals/tr/compliance/gdpr-erasure.md:52) · type: misleading
The document says. Article 17(3)(b) preserves audit / accounting records as a legal-obligation defence
What the code does. Art. 17(3)(b) disapplies Art. 17(1)-(2) only 'to the extent that processing is necessary for compliance with a legal obligation which requires processing by Union or Member State law'. It does not cover audit or accounting records as a class. It applies only where a specific law requires that record, for as long as that law requires it. For high-risk AI providers, AI Act Art. 19(1) and Art. 18 ar…
Proposed correction. Delete the audit log: ForgeLM records the erasure as a new event. Retaining earlier entries relies on Art. 17(3)(b) only where a specific Union or Member State law (e.g. AI Act Art. 18/19 for high-risk providers) requires keeping them, and only for that period; document which obligation applies.
Turkish mirror. the Turkish page has the same error; fix both in the same PR -
DOC-BLEGAL-023 (docs, High) — docs/usermanuals/en/compliance/gdpr-erasure.md: misleading — [IS-T11-10] 'The chain row never carries the cleartext identifier — the salt protects against wordlist attacks against the audit log itsel…
severity High
Where.docs/usermanuals/en/compliance/gdpr-erasure.md:78(Turkish mirror:docs/usermanuals/tr/compliance/gdpr-erasure.md:78) · type: misleading
The document says. [IS-T11-10] 'The chain row never carries the cleartext identifier — the salt protects against wordlist attacks against the audit log itself.'
What the code does. The salt is stored at <output_dir>/.forgelm_audit_salt next to the log (forgelm/cli/subcommands/_purge.py:165). It is XOR-combined with a secret only when FORGELM_AUDIT_SECRET is set (_purge.py:216-223); otherwise salt_source is 'per_dir'. The hash is sha256(salt + value) (_purge.py:229), so anyone who can read output_dir can brute-force low-entropy identifiers. GDPR Art. 4(5) requires the additi…
Proposed correction. The chain row never carries the cleartext identifier. In the defaultper_dirmode the salt sits next to the log, so it protects only against precomputed tables, not against anyone who can read the output directory. SetFORGELM_AUDIT_SECRET(kept separately from the output directory) to make the hash keyed, and treat the audit log as personal d…
Turkish mirror. the Turkish page has the same error; fix both in the same PR -
DOC-D3B02-006 (docs, High) — docs/usermanuals/en/compliance/gdpr-erasure.md: false claim — The salt does not mix with FORGELM_AUDIT_SECRET. Rotating FORGELM_AUDIT_SECRET rotates only the chain HMAC key. Identifier hashes come on…
severity High
Where.docs/usermanuals/en/compliance/gdpr-erasure.md:86-92(Turkish mirror:docs/usermanuals/tr/compliance/gdpr-erasure.md:86-92) · type: false claim
The document says. The salt does not mix with FORGELM_AUDIT_SECRET. Rotating FORGELM_AUDIT_SECRET rotates only the chain HMAC key. Identifier hashes come only from the per-dir salt and stay stable across env-var rotations.
What the code does. When FORGELM_AUDIT_SECRET is set,_resolve_saltXORs its first 16 bytes into the per-dir salt (salt_source="env_var"). purge and reverse-pii plaintext mode both hash identifiers with that combined salt. Reproduced: purge under secret A gave target_id 2018d059…, reverse-pii under secret B gave query_hash f180f678…, and with no secret it gave 0e946cf7…. All three differ for the same subject.
Code references.forgelm/cli/subcommands/_purge.py:196-224,forgelm/cli/subcommands/_reverse_pii.py:290-310
Proposed correction. "The identifier-hash salt is the per-output-dir salt, XOR-combined with the first 16 bytes ofFORGELM_AUDIT_SECRETwhen that variable is set. Rotating or unsettingFORGELM_AUDIT_SECRETtherefore changes every identifier hash. Run purge and reverse-pii with the same secret state, and record the secret's lifecycle with the output dir, if you nee…
Turkish mirror. the Turkish page has the same error; fix both in the same PR -
DOC-D3B02-023 (docs, Medium) — docs/usermanuals/en/compliance/gdpr-erasure.md: wrong detail — Both subcommands follow the "0/1/2/3/4" contract. Exit 1 covers "bad flags, unreadable corpus, malformed regex, conflicting retention va…
severity Medium
Where.docs/usermanuals/en/compliance/gdpr-erasure.md:96-104(Turkish mirror:docs/usermanuals/tr/compliance/gdpr-erasure.md:96-104) · type: wrong detail
The document says. Both subcommands follow the "0/1/2/3/4" contract. Exit 1 covers "bad flags, unreadable corpus, malformed regex, conflicting retention values, unwritable--audit-dir".
What the code does. Argparse rejects bad flags with exit 2. An unreadable corpus exits 2 in both purge and reverse-pii. An unwritable or uncreatable explicit--audit-direxits 2. All three were reproduced. No conflicting-retention validation exists. The public contract is 0-6.
Code references.forgelm/cli/subcommands/_purge.py:752-775,forgelm/cli/subcommands/_reverse_pii.py:523-576,forgelm/cli/_exit_codes.py:10-49
Proposed correction. Exit 1: "missing corpus file,--row-idwithout--corpus, no matching row / ambiguous match, malformed custom regex, glob matching no files,--check-policyconfig that fails to load." Exit 2: "invalid flags (argparse), unreadable or non-UTF-8 corpus, unwritable audit dir, ReDoS timeout, rename/delete failure." Replace "0/1/2/3/4" with "0–6 (o…
Turkish mirror. the Turkish page has the same error; fix both in the same PR -
DOC-D2B02-055 (docs, Low) — docs/guides/gdpr_erasure.md: stale — public exit-code contract
0/1/2/3/4
severity Low · already tracked: KNOWN-OPEN C31-EXIT-CODE-PROSE — docs/roadmap/phase-16-trust-surface-hardening.md:782-785
Where.docs/guides/gdpr_erasure.md:71(TR :71) · type: stale
The document says. public exit-code contract0/1/2/3/4
What the code does. The public set is 0-6 (EXIT_WIZARD_CANCELLED=5, EXIT_INTEGRITY_FAILURE=6).
Code references.forgelm/cli/_exit_codes.py:10-41
Proposed correction. Refer to the full table in error-handling.md.
Turkish mirror. the Turkish page has the same error; fix both in the same PR -
DOC-D2B02-056 (docs, Low) — docs/guides/gdpr_erasure.md: stale — This keeps the public exit-code contract
0/1/2/3/4consistent across every ForgeLM subcommand.
severity Low · already tracked: KNOWN-OPEN C31-EXIT-CODE-PROSE — docs/roadmap/phase-16-trust-surface-hardening.md:782-785
Where.docs/guides/gdpr_erasure.md:71(Turkish mirror:docs/guides/gdpr_erasure-tr.md:71) · type: stale
The document says. This keeps the public exit-code contract0/1/2/3/4consistent across every ForgeLM subcommand.
What the code does. The public contract is 0-6 (5 wizard-cancelled, 6 integrity failure).
Code references.forgelm/cli/_exit_codes.py:42-52
Proposed correction. "…keeps the public exit-code contract (0–6, seeforgelm/cli/_exit_codes.py) consistent…"
Turkish mirror. the Turkish page has the same error; fix both in the same PR
Acceptance criteria
- A regression test reproduces the failure scenario and asserts the corrected behaviour.
- Documentation listed above is corrected in English and in its Turkish mirror in the same PR.
- Every finding in this issue is resolved or explicitly closed with a reason.
Found by the October 2026 ForgeLM code and documentation review against commit cab2947. Code links point at that commit; line numbers may have moved since. Finding IDs (CR-…, DOC-…, TECH-…) are stable references for this backlog.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTodo
Activity