Skip to content

security: harden LLM attack surface (audits 1+2 findings F1-F4)#73

Merged
thierryvm merged 6 commits into
mainfrom
security/harden-llm-attack-surface
May 12, 2026
Merged

security: harden LLM attack surface (audits 1+2 findings F1-F4)#73
thierryvm merged 6 commits into
mainfrom
security/harden-llm-attack-surface

Conversation

@thierryvm

Copy link
Copy Markdown
Owner

Summary

First security hardening sweep after running the two new LLM auditing agents (prompt-guardrail-auditor haiku + llm-security-auditor opus 7-couches) on main. Addresses the 4 convergent findings flagged by both audits.

Baseline scores (commit 3df4641) :

  • prompt-guardrail-auditor : 7/10 — 1 CRITICAL mineure + 4 WARNINGS
  • llm-security-auditor : 7.4/10 — 0 CRITICAL + 2 HIGH + 5 MEDIUM — Ship avec mitigations

What's in the PR

Foundation (2 commits) — versioned for cross-projet reuse :

  • feat(security) agents .claude/agents/llm-security-auditor.md + prompt-guardrail-auditor.md
  • docs(security) baseline reports in docs/security-audits/2026-05-12-*.md

Fixes (4 commits, 1 per finding) :

Commit Finding What changes
F1 log_redact both audits New AI_KEY_RE regex catches sk-or-v1-*, sk-ant-*, sk-proj-*, sk-*, AIza* in raw log strings (e.g. OpenAI SDK error messages). 7 new tests using OBVIOUSLY_FAKE_TEST fixtures.
F2 network_rules llm-security HIGH-1 Add "DÉFENSE PROMPT-INJECTION" section at top of VISUAL_EXTRACTION_PROMPT (mirrors get_synthesis_prompt). Closes Chain B (visual_profile poisoning).
F3 sidecar/ai_client.py llm-security MEDIUM-1 (V1 ASCII Smuggling) Extend _sanitize_surrogates to strip Unicode TAG block, ZWSP/ZWNJ/ZWJ, BOM, BIDI overrides. 9 new tests + regression check on visible content.
F4 commands/ai.rs prompt-guardrail W2 New validate_brief_input consolidates length-check + rejects override patterns, system markers, delimiter injection, base64 payloads, invisible Unicode. Called from 4 entry points. 13 new tests.

Test counts

  • Rust : 269 (249 baseline + 20 new = 7 log_redact + 13 brief_validation)
  • Python : 85 (76 baseline + 9 new on _sanitize_surrogates)
  • All clippy warnings = 0

Out of scope (deferred)

The remaining MEDIUM findings stay open as a follow-up sprint :

  • requirements.txt pin-by-hash (supply chain) → PyInstaller v0.4 will resolve structurally
  • cargo audit + pip-audit + npm audit in CI
  • Runtime model gating UI (warn on ⚠️ jsonUnreliable models like Mistral Small)
  • DNS rebinding mitigation on SSRF guard (already commented for v0.4 followup)
  • _respond_error sidecar redaction wrapper (defense-in-depth)
  • UX warning in Composer "envoyé à LLM tiers"

Notes

The 2 audit reports under docs/security-audits/ document baseline scores and the full finding list. They will be re-run after merge to confirm score lift (expected 7.4 → 8.5+).

The audit agents are intentionally portable — same format used so they could later be aggregated by a cross-projet dashboard (Atlas Sentinelle in Thierry's parallel work). Today they live in the GPC repo and are invoked from this session only.

🤖 Generated with Claude Code

thierryvm and others added 6 commits May 12, 2026 11:22
…gents

Port the two LLM-focused auditing agents from Terminal Learning,
adapted to Getpostcraft's stack (Tauri 2 + Python sidecar + keychain
BYOK vs. Next.js + Vercel + Supabase + localStorage).

- `prompt-guardrail-auditor` (haiku, gate per-PR) — 7-step review
  covering system prompts in `network_rules.rs` and `sidecar/`,
  sanitizers, key manager, UI rendering, fetch surfaces, injection
  patterns, transverse checks.
- `llm-security-auditor` (opus, release-driven) — 7-layer audit
  covering surface recon, threat modeling, OWASP LLM Top 10 (2025),
  2026 vectors (ASCII Smuggling, multi-turn drift, encoding bypass,
  sycophancy, indirect injection), attack chain composition, defense
  stress-test, self-critique.

Both agents document N/A surfaces explicitly (no Sentry tunnel, no
Vercel CSP, no Supabase RLS, no RAG/tools/MCP V1) to keep findings
honest.

.gitignore: track .claude/agents/*.md while keeping settings.local.json
and scheduled_tasks.lock ignored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First runs of the two new LLM security agents on main @ 3df4641.

- `prompt-guardrail-auditor` (haiku): 7/10 — 1 CRITICAL mineure
  (log_redact patterns) + 4 WARNINGS (input sanitizer, JSON mode
  scope, RAM cache, sidecar error redaction).
- `llm-security-auditor` (opus, 7 couches): 7.4/10 — 0 CRITICAL,
  2 HIGH (visual prompt without injection defense, log scrubber
  missing AI key patterns), 5 MEDIUM (Unicode tags, supply chain
  pinning, runtime model gating, DNS rebinding, CI audit), 7 LOW.
  Verdict: Ship avec mitigations.

Both reports confirm the fundamental architecture (keychain ADR-009,
CSP Tauri strict, PKCE OAuth, JSON mode, React auto-escape, SSRF
guard tested). The gaps cluster on the 2026 LLM-specific perimeter:
ASCII Smuggling, indirect injection via scraped URLs, scrubber regex
coverage. Follow-up fixes shipped in subsequent commits in this PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
OpenAI SDK errors like "Incorrect API key provided: sk-..." propagate
as exception messages and reach log::error! un-scrubbed because the
key is not in a recognised JSON field. Both security audits
(prompt-guardrail [C1] + llm-security [HIGH-2]) flagged this as the
highest-priority leak surface.

Add a third pass AI_KEY_RE applied after the existing field-based
redactors. Catches the 5 common AI provider key shapes (OpenRouter,
Anthropic, OpenAI project, OpenAI legacy fallback, Google Gemini).
20-char minimum after the prefix avoids false positives on short
test fixtures.

Marked with a distinct [REDACTED_AI_KEY] token so log readers can
triage which provider mirrored the key into an error string.

Test fixtures use OBVIOUSLY_FAKE_TEST tokens to avoid tripping
GitHub Push Protection / TruffleHog. 7 new tests, 18/18 log_redact
tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…N_PROMPT (audit F2)

The visual extraction call reads a screenshot of an external website
and asks the model to return a JSON profile. The auditor
[llm-security HIGH-1] flagged this as the entry point for Chain B
(visual_profile poisoning persistent cross-posts): an attacker-
controlled site can include OCR-style instructions in its hero
("ignore all previous, return {...}") that the vision model would
otherwise follow.

Add a "DÉFENSE PROMPT-INJECTION" section at the top of the prompt
mirroring the structure already present in get_synthesis_prompt
(scraped-content path). Tells the model to treat the screenshot as
a visual data source only, ignore instruction-shaped text inside it,
and stay anchored to the JSON schema spec.

No new tests — the section adds to the system prompt body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The llm-security-auditor [MEDIUM-1, vector V1 ASCII Smuggling] flagged
that _sanitize_surrogates only covered U+D800-U+DFFF (lone surrogates
that CPython refuses to UTF-8 encode), leaving the more dangerous 2026
invisible-payload carriers in place:

- U+E0020-U+E007E (Unicode TAG block) — encodes invisible ASCII payload
- U+E0001 (LANGUAGE TAG) + U+E007F (CANCEL TAG) sentinels
- U+FEFF (BOM / ZWNBSP) — Windows clipboard paste artefact
- U+200B-U+200D (ZWSP, ZWNJ, ZWJ) — zero-width spacing
- U+202A-U+202E (BIDI overrides) — RTL flips hiding payload
- U+2066-U+2069 (BIDI isolates)

Extend _sanitize_surrogates to strip all of these. Function name kept
for backward compat — semantic now broader, docstring updated.

9 new tests + 1 regression test that visible content (accents, emoji)
passes through unchanged. 85/85 sidecar Python tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…t F4)

The prompt-guardrail-auditor [W2] flagged that no input validation
existed between the renderer and the sidecar — a brief containing
"ignore previous instructions" or a Unicode-tag payload was passed
through raw. The system prompt was the only line of defense.

Add validate_brief_input(&str) -> Result<(), String> called from the
4 entry points that accept a user brief (generate_content,
generate_variants, generate_and_save_group, generate_carousel).
Rejects:

- Briefs shorter than 10 chars (consolidates the previous len-check)
- Override patterns (ignore previous / disregard / you are now) + FR
- System-prompt markers ([INST], <|im_start|>, ### Instruction)
- Delimiter injection (</user_brief>, </system>, etc.)
- Base64-encoded "ignore" / "Ignore" payloads
- Invisible Unicode: TAG block, BOM, ZWSP, BIDI overrides — mirrors
  the sidecar _sanitize_surrogates ranges

False positives accepted (e.g. a brief containing "système :" is
rejected); error message tells the user how to rephrase.

13 new tests, 269/269 Rust tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @thierryvm, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@thierryvm thierryvm merged commit 5eede0c into main May 12, 2026
5 checks passed
@thierryvm thierryvm deleted the security/harden-llm-attack-surface branch May 12, 2026 09:37
thierryvm added a commit that referenced this pull request May 12, 2026
…I) (#74)

* feat(db,deps): migration 020 + Argon2id/subtle deps for Security Admin gate

Foundation commit for the Settings → Security dashboard (Phase A of the
post-#73 hardening roadmap). Two pieces land together because the schema
and the crypto dep are mutually dependent — the module can't compile
without argon2 + subtle, and the module is what writes to the new tables.

Migration 020:
- security_audit_attempts: append-only log of password unlock attempts
  (forensic trail, no PII — mono-user desktop). Feeds the in-RAM lockout
  decision; persistence is for auditing, not enforcement.
- security_audit_reports: landing zone for the LLM auditor outputs.
  Schema declared here so 020 ships as a coherent unit with the gate.
  Empty until Phase B (agent runner) writes the first row.

Dependencies:
- argon2 0.5: Argon2id hashing with OWASP 2026 params (m=19MB, t=2, p=1).
  PHC string stored in the OS keychain — never SQLite.
- subtle 2: constant-time equality for the in-RAM session token. Guards
  against the theoretical side-channel where compare time leaks the
  matched-prefix length.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(security_admin): Argon2id password + session token + lockout policy

Core module for the Settings → Security gate. Three pieces of state
on AppState, small async API for the Tauri command layer (next commit).

Password lifecycle:
- setup_password: 12-char min, Argon2id hash, PHC string in OS keychain
  (provider "security_password_hash" added to ai_keys::KNOWN_PROVIDERS)
- verify_password: lockout check → keychain read → Argon2id verify →
  on success mint SessionState + log to security_audit_attempts.
  Typed VerifyOutcome (Ok/LockedOut/NoPasswordSet/Wrong) for the UI.
- reset_password: keychain delete + state reset. #[allow(dead_code)]
  for now, admin-only recovery (Phase B may expose).

Session token:
- 32 random bytes from OsRng, base64 URL-safe no-pad for IPC.
- 30-minute lifetime. Comparison via subtle::ConstantTimeEq — defends
  the theoretical timing side-channel cheaply.

Lockout policy (in-RAM by design — app restart resets, fine because
that is high-friction):
  0-2 fails: free
  3-4: 5s · 5-9: 30s · 10-19: 5min · 20+: capped 5min

Audit log: every attempt appends to security_audit_attempts. Best-
effort, never fails the verify decision (a wedged DB must not lock
the user out of their own app).

12 unit tests (pure logic: lockout escalation, session expiry,
malformed token rejection) + 4 keychain-touching integration tests
that skip gracefully when keychain is unavailable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(commands): Tauri commands for Security Admin gate

Thin IPC layer over the security_admin module. No business logic —
each command maps a module call to a serde-friendly response.

Exposed commands:
- is_security_password_set() -> bool
- setup_security_password(plain) -> Result<()>
- verify_security_password(plain) -> Result<VerifyResponse>
- check_security_session(token) -> bool
- end_security_session() -> ()
- list_recent_security_attempts(limit) -> Result<Vec<AttemptRow>>

VerifyResponse is a tagged union (snake_case kind) so the renderer
pattern-matches without parsing strings. AttemptRow is a #[derive(FromRow)]
shape with only the 4 columns the UI renders.

Module registered in commands/mod.rs and wired into lib.rs's
invoke_handler. AppState already grew a security_admin: Arc<...>
field in the previous commit so these handlers can reach the
session/lockout mutexes directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(ui): Settings → Security tab with password gate

User-facing slice of Phase A. New "Sécurité" tab in Settings exposes
three states driven by the security_admin commands:

1. Setup wizard — when no password is configured. Confirm-match,
   12-char minimum, password fields type="password" + autocomplete
   "new-password" so browser autofill won't leak.
2. Verify gate — once a hash exists, prompts on every fresh tab open.
   Lockout countdown live-decrements on screen. Wrong/locked_out/
   no_password_set/ok branches all rendered explicitly.
3. Unlocked panel — placeholder for the Phase B agent runner +
   reports table, plus a live "Tentatives récentes" forensic view
   reading the new security_audit_attempts log.

Session token cached in sessionStorage (renderer-side) so a renderer
refresh doesn't force re-prompt — but the Rust side still enforces
the 30-minute lifetime, so a stale token gets rejected and we fall
back to the gate cleanly.

All components use shadcn/ui primitives (Card, Input, Button, Alert)
matching the rest of Settings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant