356 internal files (19MB) were on the production server filesystem at /var/www/tractatus/docs/ for ~128 days. Includes credential rotation procedures, VPS access references, Stripe financial details, and security audit reports. Files were NOT HTTP-accessible (Express serves only public/) but were world-readable on disk. Root cause: .rsyncignore used a denylist of specific file patterns rather than excluding the directory entirely. The denylist was incomplete and failed silently as new files were added. Fix: exclude docs/ and docs/** entirely. No production code reads from this directory. Verified by rsync dry-run and app health check. See: docs/SECURITY_INCIDENT_REPORT_2026-02-11.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8.6 KiB
Security Incident Report: Internal Documentation on Production Server
Incident ID: SEC-2026-02-11-001 Severity: HIGH Date Discovered: 2026-02-11 Date Resolved: 2026-02-11 Reported By: User (during routine session) Status: RESOLVED
Summary
356 internal documentation files (19MB) were present on the production server filesystem at /var/www/tractatus/docs/ for approximately 4 months. Files included credential rotation procedures, VPS access references, Stripe financial configuration details, security audit reports, previous incident reports, session handoffs, and deployment architecture documentation.
Exposure Assessment
HTTP accessibility: NO. The files were NOT served by the web application. Express serves only public/ as static files (app.use(express.static('public'))). Nginx proxies all requests to Express with no direct static file serving from the docs/ directory. All tested HTTP requests to docs/*.md returned 404.
Note: public/docs/ (a separate directory containing SVGs and PDFs referenced by the site) IS intentionally web-served and was not part of this incident.
Filesystem accessibility: YES. Files were world-readable (permissions 664, owner ubuntu:ubuntu) on the production server for the full exposure window.
Active probes detected in nginx logs:
158.220.97.237probed/docs/.envon 2026-01-30 (4 attempts, all returned 400/404)164.68.124.190probed/docs/.envon 2026-02-11 (returned 404)91.134.240.3probed/docs/CREDENTIAL_ROTATION_PROCEDURES.mdon 2026-02-11 (returned 404)
All probes received 404 responses. No evidence of successful data exfiltration via HTTP.
Exposure Window
- First file deployed: 2025-10-06 (session-handoff-2025-10-07-part2.md)
- Last file deployed: 2026-02-11 (research-timeline.md)
- Total window: ~128 days (2025-10-06 to 2026-02-11)
- Files removed: 2026-02-11T21:15:00Z (approximate)
Sensitive Files Present on Production
Critical (credentials, access, financial)
| File | Content |
|---|---|
| CREDENTIAL_ROTATION_PROCEDURES.md | Credential rotation procedures and schedules |
| CREDENTIAL_VAULT_SPECIFICATION.md | Vault architecture and credential storage design |
| VPS_ACCESS_REFERENCE.md | VPS access instructions and connection details |
| VPS_RECOVERY_REFERENCE.md | Server recovery procedures |
| STRIPE_LIVE_MODE_DEPLOYMENT.md | Live Stripe payment configuration |
| STRIPE_SANDBOX_SETUP_COMPLETE.md | Stripe sandbox configuration |
| STRIPE_ACCOUNT_NAME_FIX.md | Stripe account details |
| STRIPE_BANK_NAME_MATCHING.md | Bank account matching details |
| STRIPE_PAYOUT_DIAGNOSTIC.md | Payout diagnostic information |
| STRIPE_FIX_FOR_JOHN_STROH.md | Personal financial account details |
| FIND_STRIPE_BANK_HOLDER_NAME.md | Bank holder name lookup |
| KOHA_STRIPE_SETUP.md | Koha payment integration details |
| stripe-analysis/ (6 files) | Stripe security audits and account analysis |
High (security architecture, incidents)
| File | Content |
|---|---|
| SECURITY-AUDIT-2025-10-09.md | Full security audit findings |
| SECURITY_AUDIT_REPORT.md | Security audit report |
| SECURITY_AUDIT_TEMPLATE_VPS.md | VPS audit template (reveals assessment criteria) |
| SECURITY_INCIDENT_REPORT_2025-12-09.md | Previous security incident details |
| INCIDENT_RECOVERY_2026-01-19.md | Incident recovery procedures |
| KOHA-SECURITY-AUDIT-2025-10-09.md | Koha security audit |
| DOCUMENT_SECURITY_GOVERNANCE.md | Security governance architecture |
| plans/security-implementation-roadmap.md | Security roadmap (reveals gaps) |
| plans/security-implementation-tracker.md | Security implementation status |
| framework-incidents/ (3 files) | Framework violation and bypass incidents |
Medium (internal architecture, session data)
| Category | Count | Examples |
|---|---|---|
| Session handoffs | ~20 files | Internal session state, priorities, work plans |
| Deployment documentation | ~8 files | Deployment guides, checklists, rsync patterns |
| Phase 2 plans | ~10 files | Infrastructure plans, cost estimates, roadmaps |
| Framework documentation | ~15 files | Framework architecture, improvements, rules |
| Internal reports | ~10 files | Business intelligence, stakeholder recruitment |
Low (operational, non-sensitive)
| Category | Count |
|---|---|
| Glossary, FAQ, general docs | ~20 files |
| Architecture diagrams (mermaid) | 2 files |
| Research data (published) | ~10 files |
| Markdown source (published docs) | ~15 files |
Total: 356 files across 35 directories
Root Cause
The deployment script (scripts/deploy.sh) uses rsync with a .rsyncignore file to exclude sensitive content during full deployments. The .rsyncignore used a denylist approach for the docs/ directory — attempting to enumerate every sensitive file pattern:
docs/session-handoff-*.md
docs/SESSION_MANAGEMENT_*.md
docs/draft-emails-*.md
docs/precis-*.md
docs/SECURITY_AUDIT_REPORT.md
docs/FRAMEWORK_FAILURE_*.md
docs/PHASE-2-*.md
docs/IMPLEMENTATION_PROGRESS_*.md
docs/DOCUMENT_SECURITY_GOVERNANCE.md
This failed because:
- Denylist incompleteness: The list covered ~12 patterns but the directory contained 356 files across dozens of naming conventions. Files like
CREDENTIAL_ROTATION_PROCEDURES.md,VPS_ACCESS_REFERENCE.md,STRIPE_*.md, and all subdirectories were never added to the exclusion list. - No production requirement: The
docs/directory is not used by any production code. Express servespublic/only. There was no reason for anydocs/file to be on the server. - Recurrence pattern: The user reports this is the third or fourth time sensitive files have been found on production in the past year. Each previous fix added specific patterns to the denylist rather than addressing the structural problem.
Remediation
Immediate (2026-02-11)
- Removed all files:
rm -rf /var/www/tractatus/docs/on production server. Verified directory no longer exists. - Application verified: Express app confirmed running (HTTP 200, systemd active) — no dependency on
docs/. - Rsync exclusion fixed: Changed
.rsyncignorefrom denylist to full directory exclusion:
# BEFORE (denylist - failed repeatedly):
docs/session-handoff-*.md
docs/SESSION_MANAGEMENT_*.md
docs/draft-emails-*.md
# ... 9 more specific patterns
# AFTER (allowlist - structural fix):
docs/
docs/**
- Dry-run verified:
rsync -avznwith updated.rsyncignoreconfirms zerodocs/files would be synced.
Structural (prevent recurrence)
The .rsyncignore now excludes docs/ entirely. Since no production code reads from this directory, this is safe. The public/docs/ directory (SVGs, PDFs) is unaffected — it lives inside public/ which is synced and served normally.
Why This Keeps Happening
This is the structural pattern across recurring incidents:
-
Denylist governance fails. Every time a new sensitive file is created, it must be manually added to the exclusion list. This requires the person creating the file to remember the exclusion list exists, know the correct pattern syntax, and test it. In practice, none of these happen consistently — especially when the file creator is an AI assistant responding to immediate task needs.
-
The deploy script checks the wrong thing. The
check-confidential-docs.jspre-commit hook scans for confidential markers but only catches files with explicit confidential headers. It does not catch files that are sensitive by nature (credential procedures, security audits) but lack such headers. -
Full deploys sync everything not excluded. The
rsync --deleteapproach means the production server is a mirror of local minus exclusions. This is an allowlist problem being solved with a denylist tool.
Mitigating Factors
- Files were not HTTP-accessible (Express serves only
public/) - Nginx does not serve static files from the docs/ path
- All detected probe attempts received 404 responses
- No evidence of server compromise during the exposure window
- The most sensitive files (
.env, SSH keys) were correctly excluded by other.rsyncignorerules
Recommendations
- DONE: Exclude
docs/entirely from rsync - Consider: Moving to an allowlist deployment model (only sync
public/,src/,package.json, etc.) rather than syncing everything-minus-exclusions - Consider: Adding a post-deploy verification step that checks for unexpected directories on production
- Consider: Regular automated audit comparing production filesystem against expected deployment manifest
Resolved: 2026-02-11T21:15:00Z Verified by: Dry-run rsync, production HTTP tests, application health check