Log retention is bounded by rotation config, IaC retention settings, or platform-managed logging
Why it matters
Application logs quietly accumulate confidential data: IP addresses, emails, session identifiers, request payloads. SOC 2 C1.2 requires that confidential information is disposed of when no longer needed, and unbounded log retention is the most common quiet violation of it (bounded retention also evidences CC7.2, since a log pipeline someone actually configured is one someone actually monitors). Auditors ask "how long do you keep logs, and where is that enforced": the answer must point at a rotation config, an IaC retention setting, or a platform whose defaults bound it. AI-generated and vibe-coded apps typically fail this because tutorials show new winston.transports.File(...) and never the lifecycle: the transport ships, the retention bound never does, and the log directory grows until the disk fills or a subpoena arrives. GDPR's storage-limitation principle (Art. 5(1)(e)) applies the same pressure from the privacy side. The good news is that the dominant deploy targets (Vercel, Netlify, Heroku) manage log retention themselves, so most stdout-only apps pass with a note; the check exists to catch the self-managed file and ELK/Loki setups where nobody ever set a bound.
Severity rationale
Low because unbounded log retention is a slow-accruing confidentiality and disk exposure rather than an immediate breach vector, and platform-managed deployments bound it by default; the mechanism is retained confidential data with no disposal path.
Remediation
Put a retention bound wherever logs persist. For file transports, replace the plain file transport in your logger setup (e.g. src/lib/logger.ts) with a rotating one that deletes old files:
import DailyRotateFile from 'winston-daily-rotate-file';
logger.add(new DailyRotateFile({
filename: 'logs/app-%DATE%.log',
maxSize: '20m',
maxFiles: '30d', // rotation without maxFiles rotates but never disposes
}));
For platform log groups, set the bound in IaC (retention_in_days = 30 on an aws_cloudwatch_log_group, retention_days on a GCP logging bucket). For self-hosted Loki set retention_period under limits_config; for self-hosted Elasticsearch commit an ILM delete policy. If you deploy to Vercel, Netlify, or Heroku and log only to stdout, the platform bounds retention for you: nothing to add.
Detection
- ID:
log-retention-configured - Severity:
low - What to look for: First map how the project logs and where logs persist. Logging libraries in
package.json:winston,pino,bunyan,log4js,morgan, plus rotation add-onswinston-daily-rotate-file,pino-roll,rotating-file-stream; Python equivalents:logging.handlers.RotatingFileHandler/TimedRotatingFileHandler,loguruwithrotation=/retention=; Go:lumberjack(MaxAge,MaxBackups). Self-managed log persistence signals:new winston.transports.File(...),pino.destination('...')orpino/filetargeting a.logpath,fs.createWriteStreamappending tologs/,log4jsfile appenders, alogs/dir in.gitignorepaired with a file transport; self-hosted log stacks indocker-compose*.yml/k8s/(services namedelasticsearch,logstash,kibana,loki,promtail,graylog,fluentd/fluent-bitwith file outputs). Retention bounds to hunt for:maxFiles/maxSizeon rotate transports,logrotate.confor/etc/logrotate.d/*entries copied in aDockerfile, Lokiretention_periodunderlimits_config(or table_manager retention), an Elasticsearch ILM policy file, IaC settings in*.tf/ CDK / Pulumi (retention_in_daysonaws_cloudwatch_log_group, CDKlogRetention, GCP logging bucketretention_days, log sink TTL). Platform-managed leg: deployment config for a platform that bounds log retention itself (vercel.jsonor.vercel/,netlify.toml,Procfilefor Heroku,fly.toml,render.yaml) combined with logging that goes only to stdout/stderr or a hosted log service, with no self-managed log files. - Pass criteria: Any ONE of: (a) a file or rotating transport that is actually wired into the logger AND carries an explicit disposal bound (
maxFileson winston-daily-rotate-file or pino-roll,backupCounton RotatingFileHandler,retention=on loguru,MaxAge/MaxBackupson lumberjack, or a logrotate rule whose path matches the log output); (b) an IaC log retention setting (retention_in_days,logRetention,retention_days, sink TTL) present in committed infrastructure code; (c) platform-managed logging: a Vercel/Netlify/Heroku/Fly/Render deployment config exists and no self-managed log files are written anywhere, in which case PASS with a note naming the platform (platform defaults bound retention). - Fail criteria: Self-managed log infrastructure exists with no retention bound anywhere: a file transport writing to disk with no
maxsize/maxFilesand no logrotate rule or IaC retention covering it; a self-hosted ELK/Loki/Graylog stack in compose or k8s manifests with noretention_period, ILM policy, or curator config. Sneaky variants that still FAIL:winston-daily-rotate-fileorpino-rollpresent inpackage.jsonbut never instantiated while a plain unbounded file transport does the actual writing (installed is not wired); a DailyRotateFile wired WITHOUTmaxFiles(rotation without retention rotates forever and disposes of nothing); amaxFilesline that is commented out; alogrotate.confcommitted whose paths do not match where the app actually writes logs; a Loki config file present but theretention_periodkey absent or set to0. Do NOT fail a stdout-only app on a bounded platform, and do NOT fail for the retention window being "too long": any explicit bound passes; judging the number is out of scope. - Skip (N/A) when: No logging at all and no deployment target: no logging library in the manifest, no log file writes, no log stack in compose, and no platform deployment config (a bare library package). Quote:
"No logging library, log file writes, or deployment config — bare library with no log retention surface". - Before evaluating, quote: The logging setup evidence first (file path plus a 1-3 line excerpt of the transport/handler wiring, or the compose service names for a self-hosted stack). Then the retention evidence: the exact line carrying the bound (
maxFiles: '30d',retention_in_days = 30,retention_period: 720h, the logrotate rule) with its file path, OR the platform config path plus a statement that no self-managed log file writes were found after checking the enumerated transports. If failing, quote the unbounded transport line and state which bound locations were searched and empty. - Report even on pass:
"Retention bound: <rotate-transport maxFiles | iac retention_in_days | logrotate rule | platform-managed (<platform>)> at <path>". - Detail on fail:
"src/lib/logger.ts wires new winston.transports.File({ filename: 'logs/app.log' }) with no maxsize/maxFiles; winston-daily-rotate-file is in package.json but never instantiated, and no logrotate or IaC retention config exists — log retention is unbounded"or"docker-compose.yml runs loki + promtail but loki-config.yml has no retention_period under limits_config — self-hosted logs are kept indefinitely with no disposal path". - Remediation: Add a disposal bound where logs persist: switch the file transport in
src/lib/logger.tstowinston-daily-rotate-filewithmaxSize: '20m', maxFiles: '30d'(rotation withoutmaxFilesnever deletes), or setretention_in_days = 30on theaws_cloudwatch_log_groupin Terraform, orretention_periodunder Loki'slimits_config. Stdout-only on Vercel/Netlify/Heroku already passes. Deeper log-content and audit-trail coverage:saas-logging.
External references
- soc2:2017 · C1.2 — Disposal of confidential information
- iso-27001:2022 · A.8.15 — Logging
- nist:rev5 · AU-11 — Audit Record Retention
Taxons
History
- 2026-07-03·v1.0.0·Initial soc2-readiness v1 authoring — bounded log retention as the SOC 2 C1.2 disposal variant, with a platform-managed pass leg so stdout-only Vercel/Netlify/Heroku apps are not falsely failed.·by soc2-readiness-v1-build