Security Remediation Planning
Categories:
This page is for engineers who are about to file work. It takes the findings that survived ground-truth verification and turns them into a small number of epics with issues underneath. Every item says what is wrong in plain terms, why it matters, and what “done” looks like so the acceptance criteria write themselves.
Read this first
Three things will save you time.
Findings are not tickets. A list of 184 findings is not a backlog of 184 issues. Most findings repeat the same underlying habit in different files. Group by cause, fix the cause once, then close the findings it covers. The epics below are grouped that way.
Verified beats reported. Some findings in the older documents did not survive checking. One named a Lambda that has never existed. Another quoted code that is not in the file. Before you file an issue from any security document, open the file it cites and confirm the thing is really there. If it is not, that mismatch is itself worth a ticket.
Small and closable beats big and aspirational. An issue that says “improve S3 security” never closes. An issue that says “set all four public-access-block flags to true on phenom-dev-media-storage and prove an anonymous GET returns 403” closes in an afternoon and can be verified by anyone.
The epics
Five epics. The first two are the ones with live exposure behind them.
graph LR
E1("EPIC 1<br/>Lock down media storage")
E2("EPIC 2<br/>Decide and enforce<br/>metadata handling")
E3("EPIC 3<br/>Fix the documentation<br/>that was wrong")
E4("EPIC 4<br/>Close the chat MCP<br/>weakness class")
E5("EPIC 5<br/>Make verification<br/>routine")
E1 --> E2
E3 --> E1
E5 --> E1
E5 --> E2
style E1 fill:#ff6b6b,color:#fff
style E2 fill:#ffd43b,color:#333
style E3 fill:#339af0,color:#fff
style E4 fill:#51cf66,color:#fff
style E5 fill:#845ef7,color:#fff
EPIC 1: Lock down media storage
Priority: highest. This is the only finding on this page with confirmed live exposure.
What is wrong
One S3 bucket that holds real uploaded media, phenom-dev-media-storage, is readable by anybody on the internet. Its public-access-block settings are all switched off, and it carries a bucket policy that allows s3:GetObject to everyone with no conditions attached. An anonymous request with no credentials returns HTTP 200 and the actual image.
A second bucket, phenom-drop-staging-uploads, has the same open policy. It happens to be empty right now, so nothing is exposed today, but the moment something is written to it that changes.
The production equivalent, phenom-prod-media-storage, is configured correctly: public access fully blocked and reads allowed only to CloudFront. So the right pattern already exists in the estate. Two buckets simply do not follow it.
Why it matters
Object keys are guessable and they leak. They appear in browser history, in referrer headers, in support tickets, in screenshots and in logs. “Nobody knows the URL” is not access control. Anything a user uploaded to development is retrievable by anyone who ever sees or guesses a key.
Issues to file
1.1 Turn public access back on for phenom-dev-media-storage
Set all four public-access-block flags to true and remove the PublicReadGetObject statement from the bucket policy. Keep the DenyObjectDeletion statement, which is doing useful work.
Done when: an anonymous curl against a known object key returns 403, and an authenticated request from the application still returns 200. Both results pasted into the issue.
Watch out for: something may currently be reading from that bucket anonymously. Check before you flip it, or you will break a dev surface and not know why. If a consumer exists, front the bucket with CloudFront and an origin access control, copying the production pattern, rather than leaving the bucket open.
1.2 Do the same for phenom-drop-staging-uploads
Same change, easier, because the bucket is empty and nothing can break.
Done when: all four flags true, public-read statement gone, anonymous GET returns 403.
1.3 Codify the posture in Terraform so it cannot drift back
The current settings were not what the code implies, which means somebody changed them by hand or the code never covered them. Put the public-access-block and the bucket policy for every media bucket into the Terraform module, so the state of these buckets is reviewable in a pull request.
Done when: terraform plan is clean against the corrected live state, and the buckets’ public-access configuration appears in version control.
1.4 Add a guard that fails the build if a media bucket goes public
A small check in CI that lists the media buckets and asserts all four public-access-block flags are true.
Done when: the check exists, passes on the fixed buckets, and demonstrably fails when pointed at a deliberately-open test bucket.
EPIC 2: Decide and enforce metadata handling
What is wrong
The March audit recorded that a metadata-stripper Lambda removed EXIF, GPS, IPTC and XMP data from uploaded photos before they could be served publicly. That Lambda does not exist. It is not in the repository and it never has been, in any branch.
In practice no GPS data was found in a sample of stored objects, because uploads are re-encoded to WebP on the way in and the transcode throws EXIF away as a side effect. So the outcome is currently fine. The control is not.
Why it matters
A control nobody wrote down is a control nobody can protect. The next person who adds “store the original alongside the transcode” for a perfectly good reason will reintroduce location data into publicly served files and no test will notice. Combined with Epic 1, that is a geotagged photo of a real person’s location, fetchable without credentials.
There is also an honesty problem. A security document currently claims a control exists. If that document is ever shown to a partner or a regulator, it is wrong in writing.
Issues to file
2.1 Decide the intent: strip deliberately, or rely on the transcode deliberately
This is a decision issue, not a code issue, and it should be resolved before the other two. Either is defensible. What is not defensible is the current position of relying on an accident.
Done when: a short decision record exists saying which approach was chosen and why, linked from the audit page.
2.2 Write a test that proves it, whichever way 2.1 goes
Upload a known geotagged image through the real pipeline. Fetch the stored object. Assert it carries no GPS block.
Done when: the test exists, currently passes, and fails if you point it at an unprocessed original.
2.3 Correct or rebuild the C7 control
If 2.1 chose stripping, build the stage. If 2.1 chose the transcode, document the transcode as the control and re-scope C7 to match.
Done when: the audit page’s C7 entry describes something that actually exists.
EPIC 3: Fix the documentation that was wrong
What is wrong
Several statements in the security documents did not match the code. The pipeline diagrams showed a two-bucket design that was deliberately replaced by a single-bucket design months ago. A code sample for upload size enforcement quoted an approach the codebase does not use. A Terraform default was quoted as an empty list when it is a two-entry allowlist.
Why it matters
Engineers read these pages to decide what to work on. Wrong pages cause wrong work: someone maintains a stripper that does not exist, or files a bug against a bucket that was retired. It also erodes trust in the accurate parts, which are the majority.
Worth noting that one of these corrections went the good way. The real upload size control is stronger than the document claimed: S3 enforces a size range server-side under a presigned POST policy, instead of trusting a client-supplied length. The document undersold the code.
Issues to file
3.1 Audit page corrected against ground truth
Already done on 2026-08-18. File it as closed-on-arrival so the trail exists, and link the corrected page.
3.2 Sweep the remaining security pages for the same class of error
The tracker page and the platform assessment pages have not had the same treatment. Check every cited file path and line number resolves.
Done when: every code citation across the security section has been opened and confirmed, with a list of any that did not resolve.
3.3 Add a citation convention
Cite a file and a symbol, not a file and a line number. Line numbers rot within days. presigned-url-generator/index.js, createPresignedPost block stays true far longer than index.js:89.
Done when: the convention is written down and applied in the corrected pages.
EPIC 4: Close the chat MCP weakness class
What was wrong
The chat MCP tool server accepted every request with no authentication at all, while holding the Hasura admin secret and exposing nine chat-moderation tools, three of which were destructive. Anyone able to reach it could drive them.
Status: done, 2026-08-18
Worth reading as a template because it shows the shape of a good remediation.
The service turned out to be completely unused. Its production target group served zero requests on every one of the thirty days before removal, and no client code anywhere called it. So instead of adding authentication to a service nobody used, the service was removed and the module was made to refuse to start without a token, so re-enabling it cannot reintroduce the hole.
Both halves matter. Removing the deployment closed the live exposure. Making the module fail closed stopped it coming back.
Issues that remain
4.1 Check the rest of the estate for the same shape
The pattern to hunt: a service that holds a powerful credential, is reachable, and is not authenticated. Enumerate services that hold admin secrets, then for each one ask whether anything actually calls it.
Done when: a list exists of every service holding an admin-level credential, with its request count over the last 30 days beside it.
Why this ordering: the traffic number tells you whether to authenticate or delete, and deleting is always the better fix.
EPIC 5: Make verification routine
What is wrong
The findings above were only discovered because somebody re-read the documents against the code and then probed the live account. Nothing in the normal workflow would have caught them. The bucket had been publicly readable for an unknown length of time and no alarm existed.
Why it matters
Every epic above is a one-time fix. This one stops the next batch accumulating.
Issues to file
5.1 Nightly public-access check across all buckets
List every bucket in the account, assert public-access-block flags, alert on any bucket with a policy granting *.
Done when: the check runs on a schedule and has alerted at least once against a deliberately-open test bucket.
5.2 Reachability probe, not just configuration probe
Configuration checks catch stated intent. An unauthenticated GET catches reality. Both matter, because the two disagreed here.
Done when: the probe runs against a canary object in each media bucket and asserts the expected status code.
5.3 Treat “the cited file does not exist” as a build failure for docs
A link checker for code citations: parse file paths out of the security pages, assert each resolves in the repository.
Done when: it runs in CI on the docs site and fails on a deliberately broken citation.
Filing conventions
Every issue created for this work sets all five of these at creation time, not as a follow-up.
| Parameter | What to set |
|---|---|
| Labels | security plus the component (infrastructure, docs, chat, and so on) plus a severity (critical, high, medium) |
| Type | Bug for something broken, Task for something to build, Feature for new capability. A security weakness in deployed infrastructure is a Bug. |
| Project | The owning board, with the Status field set. Do not leave it in no-board limbo. |
| Milestone | The matching milestone. If none fits, say so in the issue rather than forcing it onto an unrelated one. |
| Relationship | Link the issue to its epic as a sub-issue, and cross-reference the finding ID (C7, W-1) in the body. |
A note on the last two. At the time of writing, phenom-infra has no milestone or project board that fits security remediation work, and none of its open issues carry a type. Someone should create an owning board and a milestone for this programme before the issues are filed, otherwise all five parameters cannot honestly be set.
Writing good acceptance criteria
The pattern that works for security issues:
Given an unauthenticated client When it requests
https://phenom-dev-media-storage.s3.amazonaws.com/<known-key>Then the response status is 403
Concrete, checkable by anyone, and impossible to argue about in review. Compare it with “bucket is secured”, which cannot be verified and cannot be closed.
Two rules worth holding to:
Never accept a proxy signal as proof. A green pipeline means the pipeline is green. A 200 from a health endpoint means the health endpoint answered. Neither tells you the thing you changed actually behaves differently. Probe the real behaviour and paste the output into the issue.
Prove the test can fail. A check that has never gone red is a check nobody has tested. Point it at something deliberately broken once and confirm it complains.
Related Documentation
- /docs/security/drop-security-audit/: the corrected audit, with the evidence behind each correction
- /docs/security/drop-remediation-tracker/: current control state for all findings
- /docs/security/platform-assessment-2026-08/: the platform-wide assessment and its own filing guide
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.