Skip to content

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

Open

Description

@cemililik

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.

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 · reachability default · intent intended-but-harmful · confidence likely · independently confirmed · reproduced end to end
    Failure scenario. Output-dir writer runs rm <out>/.forgelm_audit_salt; ln -s /dev/zero <out>/.forgelm_audit_salt (or writes 16 known bytes with mode 0644). The operator runs forgelm 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 default per_dir mode the salt sits next to the log, so it protects only against precomputed tables, not against anyone who can read the output directory. Set FORGELM_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_salt XORs 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 of FORGELM_AUDIT_SECRET when that variable is set. Rotating or unsetting FORGELM_AUDIT_SECRET therefore 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-dir exits 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-id without --corpus, no matching row / ambiguous match, malformed custom regex, glob matching no files, --check-policy config 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 contract 0/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/4 consistent 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 contract 0/1/2/3/4 consistent 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, see forgelm/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.

Activity

  1. added
    bugSomething isn't working
    documentationImprovements or additions to documentation
    backlog: knownAlready tracked in docs/roadmap
    dependenciesDependency or ecosystem currency
    securitySecurity-relevant
    severity: highWrong behaviour or a false claim users rely on
    source: review-2026-10Found by the October 2026 code and documentation review
    urgency: next-releaseTechnology currency: fix in the next release
    wave: 1Roadmap wave 1: high severity, small effort
    on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: gdpr-approvalCode area: gdpr-approvalbacklog: knownAlready tracked in docs/roadmapbugSomething isn't workingdependenciesDependency or ecosystem currencydocumentationImprovements or additions to documentationsecuritySecurity-relevantseverity: highWrong behaviour or a false claim users rely onsource: review-2026-10Found by the October 2026 code and documentation reviewurgency: next-releaseTechnology currency: fix in the next releasewave: 1Roadmap wave 1: high severity, small effort

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions