Critical and High Findings
Categories:
All 28 top-severity findings, written so you can paste one into an issue and start work. Each entry gives the location, what is actually wrong, and what to change.
Every one of these is currently Open. None has a fix in flight that the assessment could see.
| Ref | Severity | Component | Confidence | Root cause |
|---|---|---|---|---|
| MGR-C01 | Critical | phenom-drop | Inferred | RC-3 |
| MGR-C02 | Critical | phenom-infra | Verified | RC-3 |
| MGR-H01 | High | Buzzard-Android | Inferred | RC-9 |
| MGR-H02 | High | codetalker-dev | Inferred | RC-3 |
| MGR-H03 | High | dev-environment-installer | Inferred | RC-9 |
| MGR-H04 | High | ghostmode-thephenom-app | Inferred | RC-3 |
| MGR-H05 | High | ghostmode-thephenom-app | Inferred | RC-1 |
| MGR-H06 | High | phenom-backend | Inferred | RC-9 |
| MGR-H07 | High | phenom-backend | Verified | RC-3 |
| MGR-H08 | High | phenom-backend | Inferred | RC-9 |
| MGR-H09 | High | phenom-backend | Inferred | RC-4 |
| MGR-H10 | High | phenom-backend | Inferred | RC-3 |
| MGR-H11 | High | phenom-backend | Verified | RC-3 |
| MGR-H12 | High | phenom-drop | Inferred | RC-3 |
| MGR-H13 | High | phenom-drop | Verified | RC-3 |
| MGR-H14 | High | phenom-drop | Inferred | RC-6 |
| MGR-H15 | High | phenom-drop | Inferred | RC-7 |
| MGR-H16 | High | phenom-drop | Inferred | RC-5 |
| MGR-H17 | High | phenom-infra | Verified | RC-4 |
| MGR-H18 | High | phenom-infra | Inferred | RC-3 |
| MGR-H19 | High | phenom-infra | Inferred | RC-6 |
| MGR-H20 | High | phenom-infra | Verified | RC-2 |
| MGR-H21 | High | phenom-infra | Verified | RC-4 |
| MGR-H22 | High | phenom-infra | Inferred | RC-7 |
| MGR-H23 | High | phenom-push-sender | Inferred | RC-2 |
| MGR-H24 | High | PhenomApp | Verified | RC-9 |
| MGR-H25 | High | sablier-weblogon | Inferred | RC-6 |
| MGR-H26 | High | sablier-weblogon | Inferred | RC-1 |
Root cause attributions in this table are the assessment’s mapping where it stated one, and this page’s best reading where it did not. Treat them as a filing aid, not as data.
Critical
MGR-C01
Three unauthenticated endpoints write or relay a caller-asserted provenance verdict, and no server-side gate exists
phenom-drop/backend/server.py:1540 · phenom-drop · Inferred · WSTG-BUSL-03, WSTG-BUSL-01, OWASP A06:2025
What is wrong. The upload handler checks that fileHash is a non-empty string and nothing else. No code path in the file consults a verification status before presigning. The verification verdict that the product exists to produce is a value the client supplies, not a value the server computes. Three endpoints will write or relay it.
This is a design defect in a provenance product. The gap inside the portal’s own server is unconditional at this commit and does not depend on deployment state.
What bounds the blast radius. If the external registry the portal relays to re-derives status from its own record of that hash, the poisoned verdict is corrected upstream and the damage stops at Phenom’s own five stores. If it trusts the relayed value, it does not. That is a question about the registry, not about this code.
What to change.
- Make the validate handler write its result: when its update reports zero affected rows, insert a row for that hash carrying the server-derived status, and stamp two new columns recording when and by what it was verified.
- Gate the upload handler on that row. Replace the non-empty check at
:1540with a format check on the hash plus a lookup requiring a recent, server-written status in an accepted set. - Delete the hardcoded insert at
:1567-1573, because the row now already exists. - Add a uniqueness constraint on the hash column, so the insert behaves as its name implies and the newest-row selection cannot be steered by appending.
- Remove the body-supplied status from the badge handler and read it from the stored row instead, returning 404 when there is none.
- Remove the hash endpoint, which the application does not call, or strip its trust-bearing fields and require authentication.
- Populate the upstream relay from the stored row rather than the request body.
- Ungate the pre-mint re-verification, so an asserted status cannot switch off the check that would contradict it.
The durable change. One rule, enforced in one place: a verification verdict is a value the server computes and the client reads, never a value the client supplies. Treating this as a patch queue is how it stays open.
MGR-C02
An MCP tool server with nine chat-moderation tools has no inbound authentication and queries the database as superuser
phenom-infra/modules/chat-mcp-server/app/src/index.ts:64 · phenom-infra · Verified · WSTG-ATHN-04, OWASP A07:2025, API5:2023
What is wrong. The tool server’s entire middleware stack is one JSON body parser. Its tool route is registered directly on the application with no guard, no router mount and no bearer check. Load balancer rules forward that path on four production hostnames with a plain forward action rather than an authenticating one, and the edge Worker in front of one of them matches the path against none of its eight classifier arms, so it reaches a permissive default. The nine tools execute against production chat data using the database engine’s administrative secret, which resolves to its built-in superuser, so no row permission is consulted for anything they touch.
Is there an edge rule that is not in the repository? The zone’s rule sets are managed in infrastructure code, which is what makes this answerable rather than unknown. There is no custom firewall rule set in the tree at all. The only rate-limit rule matches Matrix login and register paths on one hostname. The only two Access applications cover a different hostname and a staging endpoint. Under that reading the tool route is an unauthenticated administrative interface.
What to change. Both halves are needed, because either alone leaves a path open.
- Authenticate the application. Mount the tool routes on a router behind a bearer check, leaving the two health paths anonymous for the target-group probe. Mirror the shape that already works in the sibling console in this estate, which refuses an unset expected value before comparing and compares in constant time. Source the token from the existing secret store and fail closed at startup when it is empty, alongside the check that already exists for the database secret.
- Make the edge decision explicit. Add an arm for the tool path before the default in the Worker’s classifier, and replace the permissive default with a deny, so a path the classifier does not recognise is refused rather than inherited.
The durable change. Stop using the administrative secret for tool execution. The tool server should authenticate as a scoped database role holding exactly the access its nine tools need, so a future authentication failure is contained by row permissions instead of bypassing them. The module already contains the machinery to obtain a scoped token.
Same-day mitigation, and it is a mitigation rather than a fix. Add an edge firewall rule blocking the tool path.
Remediation status, 2026-08-18: resolved. PR #261 (commit 2a6a125, closing issue #260) takes the stronger of the two options. Rather than authenticating the service it deletes the deployment, on the evidence that the production target group served zero requests on each of the 30 days to 2026-08-18 and that no client in any repository calls the path. It removes module.chat_mcp_server, module.chat_mcp_server_staging and aws_lb_listener_rule.origin_mcp, and it adds src/require-auth.ts, a constant-time bearer check mounted after the two health paths that refuses to start when the token is unset or shorter than 32 characters, so re-enabling the module cannot reintroduce this finding.
It was applied and probed, not merely merged. Targeted applies ran against both environments ahead of the merge, destroying nine resources in production and eight in development, and the post-merge plan in each environment reports No changes. Your infrastructure matches the configuration. The operator confirmed the result by inspection rather than by exit code: no target group and no ECS service matching the tool server remains in the account, none of the ten rules on the production HTTPS listener mentions the path, the log group is gone, and the path now answers 302 at the edge with no backend behind it. Secret integrity was checked separately, with all five keys in the production application secret unchanged in length.
Two things this does not settle, both now conditional rather than live. The durable change is still unimplemented: index.ts continues to read the administrative secret and pass it to the backend constructor, so a future re-enablement would restore a superuser-scoped tool server unless a scoped database role lands first. Half 2 above is also outstanding: the edge Worker’s classifier still has no arm for the tool path, and its final arm still returns a pass-through for unrecognised paths, above a comment deferring the tightening. That permissive default is a live weakness in its own right; it is simply no longer a route to this service.
High: credentials and secrets
MGR-H01
Three Google service-account private keys and the release signing keystore are committed, one into the live Firebase project
Buzzard-Android/keys/ · Buzzard-Android · Inferred · WSTG-CRYP-04, WSTG-CONF-02, OWASP A04:2025
What is wrong. A service-account key file is a complete credential with no second factor. Whatever roles the production account holds in the live project are held by anyone with the file, and the mobile client’s own configuration names that project as its push-messaging credential source. The keystore is the release signing identity: possession plus the password permits signing an artifact that the store and already-installed devices accept as an update to the legitimate application.
Exposure window: six years and four months for the production key, three years for the third.
What to change, and the order matters.
- Revoke the three service-account keys in the cloud console by key identifier.
- Audit each account’s role bindings and decide whether the account itself should survive. Deleting a key does not narrow an over-broad role.
- Rotate the signing identity. If store signing is enabled, request an upload-key reset with a newly generated keystore. If the application is self-signed, the identity cannot be changed for an existing listing, so rotate the two passwords and the CI variables holding them.
- Then untrack the files, add the two patterns to the ignore rules, and rewrite history.
Purging is not a substitute for revocation. Any clone taken in the last six years retains the blobs.
The durable change. Remove the file-based credential from existence. Replace it with workload identity federation from the CI provider to the distribution account, so there is no key file to commit.
MGR-H06
A Firebase Admin SDK private key for the live project is in repository history reachable from HEAD’s own ancestry
blob 71e5ac66, reachable from HEAD’s ancestry · phenom-backend · Inferred · WSTG-CONF-04, WSTG-INFO-05, OWASP A04:2025
What is wrong. The blob is reachable from a commit that is an ancestor of the pinned HEAD, so every clone of the default branch contains the key. That was re-derived during verification rather than inherited.
The severity turns on revocation, which source cannot settle. If the key was revoked at or after removal, residual risk is the historical window and the correct rating is Low. If it was removed from git and never revoked, it is live.
What to change.
- Delete the key in cloud identity management, identifying it by the key identifier in the blob.
- Audit that account’s role bindings on the project and delete the account itself if the ingestion job no longer uses it.
- Pull the audit logs for that account from the first-commit date to the present and look for authentication from outside known CI and workstation ranges.
- Close the tracking issue explicitly, recording the identity action and its date rather than only the git removal.
The durable change. The ingestion job should obtain credentials at runtime from workload identity federation or the secret store, not from a file on disk beside the source. Add an ingest gate that fails any added blob containing a service-account type marker or a private-key header. The repository has no such gate, which is why this file class landed in two repositories.
Do not rewrite history as the primary response. Scrubbing the blob requires force-pushing every branch, invalidates every existing clone and fork, and does nothing about a key already copied. Rotation is the control; history rewriting is cleanup.
MGR-H24
Two EC P-256 private keys, one bundled with a live Cloudflare Access token and its CSR, are committed in mobile-client history
PhenomApp/.claude/settings.local.json, tracked at HEAD · PhenomApp · Verified · RC-9
What is wrong. The file is tracked at HEAD, not merely present in history, and it carries a live Cloudflare Access service-token pair plus two private keys and a certificate signing request. PhenomApp/.gitignore:141-143 carries *.key and signing.key under a comment naming a previous incident, and git check-ignore -v exits 1 on this path: no rule covers it.
What to change. The first two are minutes of work.
- Untrack the settings file and add its path to the ignore rules. It is a per-developer file that should never have been tracked.
- Rotate the Access service token now. It is live in a tracked file at HEAD, and the same file carries the exact command that uses it.
- Treat both private keys as public: revoke the leaf issued to the enrolment identity, and revoke every certificate ever issued against the committed certificate request.
The durable change. Make the pre-commit and build check an allowlist over file content rather than a blocklist over filenames. The existing ignore rule covers two filename patterns and would never have covered this path.
Severity is pending one lookup. See Q-1. One read-only Cloudflare Zero Trust lookup decides whether this is High or Critical, and whether an incident-response obligation exists over fourteen services’ access logs.
MGR-H03
The macOS installer embeds a GitHub token in the clone URL, persisting it in cleartext in a world-readable git config
dev-environment-installer/install-mac.command:121 · dev-environment-installer · Inferred · WSTG-CONF-09, OWASP A06:2025
What is wrong. The installer’s only path for fetching the repository embeds the operator’s personal access token in the user-information component of an HTTPS clone URL. Git writes that URL verbatim into the clone’s configuration file, which it creates world-readable, and nothing in the installer or the environment’s setup script ever rewrites the remote. The token is read out of the OS keychain immediately before this, so the design moves a credential from protected storage into a plain file.
On a first run with no existing clone, the credential is written. If a clone already exists the installer pulls instead, but if that directory came from a previous run the credential is already there and every subsequent pull authenticates from it.
What to change. Replace the clone block with a clean-URL clone driven by a request-time credential helper, so the secret never enters the URL, the configuration file or the process argument list. Write the token to a temporary file created with a restrictive mask, pass a helper with the per-invocation configuration flag so no global configuration is left behind, and remove the temporary file on exit. The developer image already uses that construct.
The durable change. This is a written rule with no enforcement point. Phenom’s own audit script already detects credentialed remotes and reflog entries: run it in CI and on provisioning, and fail rather than report. Then remediate the hosts already provisioned: run the audit across each home directory, rewrite every remote it reports, and rotate every token that has been through the old path.
MGR-H08
The provisioning runbook instructs the operator to store a full-scope GitHub token in cleartext on a host it also makes root-equivalent
phenom-backend/README.md:35 · phenom-backend · Inferred · WSTG-CONF-09, OWASP A06:2025
What is wrong. The backend repository’s readme is a 71-line provisioning runbook. It instructs the reader to enable git’s store credential helper, generate a classic personal access token with the top repository scope, and clone over HTTPS, which writes an organisation-wide read and write credential to a plain file with no expiry. Eighteen lines later it adds that same account to the container group, which is root-equivalent on the host.
What to change. Rewrite the runbook’s clone section, removing both the storing-helper instruction and the classic-token instruction. Prefer no token at all: attach an instance role and use the cloud provider’s source connection, or clone over SSH with a per-developer key registered on that developer’s own account, which is what the developer image already implements and which gives attribution and per-person revocation. If a token is unavoidable, specify a fine-grained token scoped to this repository alone, read-only, with an explicit expiry, and keep it off disk with the caching helper rather than the storing one. Remove the individual’s name and the instance identifier.
The durable change. Reconcile the estate’s three written positions on credential storage into one policy, and rotate every classic token issued under the current text. Separately, replace the container-group grant with a dedicated deploy account, or keep it and state in the runbook that membership makes every member root-equivalent on the instance.
High: access control and data exposure
MGR-H07
Every authenticated account reads all rows and columns of the submission table, including email, name and coordinates
hasura/metadata/databases/default/tables/public_drops.yaml:12 · phenom-backend · Verified · WSTG-APIT-03, WSTG-APIT-02, OWASP A01:2025, API3:2023
What is wrong. The submission table grants role user a read permission with every column selected and no row predicate. Role user is what every account in the production identity pool receives by default, and that pool accepts self-service registration. Any account reads the whole submission dataset.
What to change. Replace the grant with an ownership-scoped one carrying an explicit column list: the columns the submitter’s own view needs, filtered on the caller’s identity. If an operator view over all submissions is required, and the operator API implies one is, express it as a separate role rather than by widening user, and keep email and reporter name out of that role’s column list unless contacting the submitter is an actual workflow. Coarsen coordinates for any role that does not need the exact point.
The durable change. 41 of 63 read permission blocks in this metadata are unscoped, and they will regenerate as tables are added. A build assertion over the metadata tree that fails any read permission with an empty filter on a non-public table is worth more than the individual edits and should land first.
Ordering constraint, and it is load-bearing
Removing this over-grant and the one at MGR-H11 will break five queries in the shipped mobile client, which depends on the over-fetch. The mobile change ships first.MGR-H11
Twelve tables grant role user an unfiltered read, exposing every account’s email and the coordinates and media of unpublished sightings
hasura/metadata/databases/default/tables/public_users.yaml:66 · phenom-backend · Verified · WSTG-APIT-03, WSTG-APIT-02, OWASP A01:2025, API3:2023
What is wrong. Role user reads with no row predicate on twelve tables: the user table, four sighting-detail tables, two reference tables, the like table, the submission table, the transcription table, the chat-room table and the link-preview table.
Why the parent’s row filter does not save you. Because the data API exposes every tracked table as its own top-level field, a caller does not go through the parent sighting to reach its children. The published-or-own predicate on the parent is not a control over the child tables at all.
Who “role user” means. The production pool sets self-service sign-up on, with a comment recording that the setting was reconciled against the live pool on a stated date and is intentional, and federated sign-in is enabled on every client. Role user therefore means anyone on the internet who can receive an email.
What to change, per table.
- On the user table: remove email, first name, last name and the dashboard-access boolean from the directory column list, and add a second permission for self-access carrying contact detail filtered on the caller’s identity. The engine merges multiple read permissions for one role by union of the applicable rows, so the directory view stays intact while contact detail narrows to self.
- On the four sighting-detail tables: replace the empty filter with the predicate the parent already carries, expressed as a relationship condition, so a child inherits its parent’s visibility.
The durable change. Write down row ownership before editing the 47, then enforce it in the build. The metadata assertion prevents the 48th; the 47 edits do not.
Remediation status, 2026-08-22: fixed and verified on staging, deliberately held for production. The email limb of this finding is closed in code on phenom-backend develop (commit 2552b8f) and probed against a live staging Hasura. It is not in production yet: PR #627 is open and held until PhenomApp 2.0.0 clears App Store and Play Store review, because the fix and the shipped client have to land in that order.
The prescription above is not implementable as written, and that matters. This finding says to add “a second permission for self-access” on the user table, on the reading that the engine unions multiple read permissions for one role. Hasura permits exactly one select permission per role per table, and its union of permissions is a union of rows, not of columns, so a second block cannot narrow a column. What does work is an inherited role: hasura/metadata/inherited_roles.yaml defines user as the union of two new roles, user_directory (the directory column list, no contact detail) and user_self (contact detail, filter pinned to X-Hasura-User-Id). Inherited roles produce genuine cell-level nullability, which is the behaviour this finding actually wants. One trap comes with it: a direct per-entity permission for role: user silently overrides the inheritance for that entity, so adding one back re-opens the finding without any error.
Two simpler-looking options were tested and refuted. Dropping email from the column list, and renaming it via custom_column_names, both break the shipped client outright. Hasura hard-rejects a query that names a column the role cannot read (field "email" not found in type: users) rather than returning null, so every affected operation fails instead of degrading. custom_column_names is worse still: it is global to every role including admin, and it rewrites the generated input types (users_set_input, users_insert_input, users_bool_exp, users_order_by) as well as the output type.
The shipped-client count in this finding is low. The alert below and W-4 both say five queries. The mobile client selects email in nine operations, counted at source, including nested phenom { user { email } } and phenom_comments { author: user { email } } selections that a query-level grep misses. The inherited role keeps all nine parsing, which is why the count did not become a blocker.
Evidence. Eight assertions, 8 pass, 0 fail, run against staging Hasura on 2026-08-22: own row keeps a real email; another account’s row stays visible but its email is null; both nested selections still parse; role moderator still reads another account’s email (unchanged, and still owed an explicit keep-or-drop decision); role public still cannot read email at all; update_users still resolves. The probe is checked in at phenom-backend/hasura/scripts/probe-mgr-h11-email-mask.sh so the next validation does not start from scratch.
What is still open on this finding. Only the email limb is done. The four sighting-detail tables still carry filter: {} and still expose the coordinates and media of unpublished sightings, and the durable change named above, the CI assertion that fails an unscoped select permission, is not implemented. Treat this finding as partially remediated.
Eleven findings reference this one
Removing the over-grant breaks five queries in the shipped mobile client, so the mobile change ships first. It will also raise the severity of at least one Low finding (MGR-L33), whose own text declares that its rating depends on this over-grant remaining in place. Plan for the re-rating.MGR-H10
A sighting’s author can set its moderation status to any value, republishing a scanner-held or moderator-rejected sighting
hasura/metadata/databases/default/tables/public_phenom.yaml:114 · phenom-backend · Inferred · WSTG-INPV-20, WSTG-APIT-03, OWASP A01:2025, API3:2023
What is wrong. The sighting table grants role user an update on the status column, scoped by a pre-update filter that restricts which rows are selectable but with an empty post-update check, so the new value is unconstrained. No value constraint exists at any other layer. The author of a sighting held pending by the scanner, or set inactive by a moderator, can return it to published.
What to change. Remove the status column from the role’s update column list entirely. An author who needs a draft or publish control should get a narrowly typed action rather than a direct column write.
If a direct write has to stay, constrain both ends, because the two clauses answer different questions: make a held row not selectable for update by adding a status exclusion to the filter, and constrain what it may become by adding an allowed-value set to the post-update check.
The durable change. Give the moderator role the update permission its name implies, so the workflow is expressible without granting the superuser role. More generally, an empty post-update check on any column carrying workflow state is the pattern to gate in the build. It is the same class as the empty read filters in MGR-H07, on the write side.
Detection, as a mitigation while the permission change ships. The audit row that records a reversion already exists. Alert on a transition from held or inactive back to published performed by the row’s own author.
Do not fix MGR-H20 without this
Staff hold the superuser role because the moderator role cannot do the work. Fixing the Cognito group mapping in MGR-H20 without giving the moderator role its update permission moves the problem rather than removing it.MGR-H20
The pre-token Lambda mints the database engine’s reserved superuser role for the dashboard-access group
environments/production/lambda-functions/hasura-cognito-trigger/index.js:121 · phenom-infra · Verified · RC-2
What is wrong. The production pre-token function places the engine’s reserved superuser role in the allowed-roles claim for anyone in either of two groups. One of those two is the operator dashboard’s gate group, documented in the same infrastructure file as a pure label with no role attachment and no precedence. A sibling comment in the same file asserts that only members of the other group get the superuser role, which is the opposite of what the file it sits beside does.
What to change. Change the group test so the dashboard gate no longer confers a database role: grant the superuser and support roles only for the dedicated administrative group, and grant the moderator role for the moderation group or the administrative group, which is the shape the next block in the same file already uses. The dashboard group then grants dashboard reach and nothing else. Correct the sibling comment at the same time, or the next operator regenerates the mistake.
The durable change. Decide what role the dashboard actually needs and create it. See the warning on MGR-H10 about the pressure that produced this mapping.
MGR-H23
The push relay resolves caller-supplied identifiers as superuser and returns the result, including raw device push tokens
phenom-push-sender/src/index.ts:136 · phenom-push-sender · Inferred · RC-2
What is wrong. Both authenticated routes take an object identifier from the request body, resolve it against the data engine using the administrative secret, and return the resolved data to the caller. There is no caller identity in the request to compare the identifier against. The returned data includes the raw device push tokens that the row policy exists to withhold, under a comment saying users see only their own device tokens.
What to change, in order of value.
- Stop returning device tokens. Change the per-device result field to a non-reversible reference such as the row identifier or a digest prefix, and have the deletion path take that reference. At minimum, drop the per-device results from both response bodies and return only counts. This is the one fix that holds under either reachability branch.
- Exclude reserved keys when copying the caller’s data object onto the payload.
- Validate its shape at runtime rather than only in the type annotation.
The durable change. Give the Worker a scoped database role instead of the administrative secret: read on the token table for the three columns it needs, delete on the same table, read of two columns elsewhere, and nothing more. Then give each caller an identity, replacing the single shared header with per-caller credentials.
MGR-H12
The confirm-upload endpoint is unauthenticated, presigns an arbitrary caller-supplied storage key, and appends to a production index object
phenom-drop/backend/server.py:1667 · phenom-drop · Inferred · WSTG-ATHZ-02, WSTG-BUSL-06, OWASP A01:2025
What is wrong. The confirm-upload handler requires no passcode and no session, in contrast to the upload handler in the same file. Its only gate is that a verification row exists for the submitted hash, and the unauthenticated hash endpoint in MGR-C01 creates one. With that gate satisfied it presigns and returns a read URL for any object key the caller names, and appends caller-supplied data to an index object in production storage under the task role. A second unauthenticated presign sink on the same primitive was found during verification.
Impact. On the read side, a 15-minute presigned read URL for an object in the bucket holding the public’s uploaded media, including submissions whose embedded coordinates the same application records. The URL is handed straight back to the caller in the response. On the write side, an unauthenticated and unbounded append to the index object in that bucket, performed with the service’s default credentials rather than the scoped media identity, poisoning an index consumed downstream and growing a production object without limit.
What to change.
- Bind confirm-upload to the transaction that issued the presign. In the upload handler, after the passcode check, generate a single-use nonce, persist it on the verification row alongside a used flag, and return it in the response. In confirm-upload, require the nonce, compare it in constant time, reject when missing or already used, and mark it used.
- Validate the key server-side rather than trusting it. Require the submitted key to equal the key the server recorded for that hash at upload time, and reject anything else rather than presigning an unrecognised key.
The durable change. Constrain the presigner itself, so a bug in a handler cannot become an arbitrary read: reject a key that does not match the expected prefix and shape before signing, inside the presigning module rather than in each caller. And narrow the identity behind the presigning key to exactly the prefix its comment claims, declared in infrastructure code so the claim is checkable.
Same-day mitigation, and it is a mitigation because it does not close the authorization gap. Attach the existing rate limiter to both handlers. It bounds the index-growth half while the nonce ships.
MGR-H13
The literal string REJECTED is a valid one-time passcode for any email address, bypassing the submission flow’s only authentication
phenom-drop/backend/server.py:1391 · phenom-drop · Verified · WSTG-ATHN-04, WSTG-BUSL-06, OWASP A07:2025
What is wrong. The rejection handler reuses the credential table as an audit log. It inserts the constant string REJECTED into the passcode column for a caller-supplied email address, on an unauthenticated endpoint, and neither of the two credential lookups excludes that value. The constant is therefore a working passcode for an address the caller does not control.
The verification half is unconditional. The rejection endpoint is routed, requires nothing but a non-empty email address, and the row insert is committed before the outbound mail send, so the bypass does not depend on mail delivery, on the mail provider’s key being configured, or on the badge mint succeeding.
What to change. Stop reusing the credential table for rejection logging. Add a rejection log table with its own columns and write the rejection there, and point the rate-limit count at it so the two flows keep independent budgets, or keep them shared deliberately and say so. This is a small change and it is Tier 1 item 4.
The durable change: two defences in depth, so a future value written into the credential store cannot become a credential.
- Add a
kindcolumn to the passcode table, write the passcode kind only from the passcode handler, and add that condition to both lookups. - Independently, reject a submitted passcode that does not match the issued format before either query runs.
MGR-H09
The documentation proxy builds its upstream URL by concatenation, sending a Cloudflare Access service token to an attacker-chosen host
admin_sandbox/nest-api/src/routes/docs.ts:30 · phenom-backend · Inferred · WSTG-INPV-19, WSTG-ATHN-04, OWASP A01:2025
What is wrong. The API Worker’s documentation proxy derives its upstream URL by concatenating the caller-controlled path suffix onto a fixed origin string, with no host validation. The dispatch that reaches it is a bare prefix match with no delimiter requirement, and it sits 56 lines above the authentication call. A request whose path continues the origin string with an attacker-owned domain causes the Worker to send the service token outward.
What to change.
- Stop concatenating. Resolve the sub-path against the upstream as a URL and then pin the host: require a delimiter so a path continuing the prefix cannot match, resolve relative to the upstream with a trailing separator, and reject anything whose resulting host or scheme is not the expected one. Both guards are needed: the delimiter check kills the domain-suffix and user-information forms at the dispatch boundary, and the host pin is the fail-safe if the prefix ever changes.
- Restrict the forwarded method to a read allowlist, returning 405 otherwise, since the proxy forwards no request body.
The durable change. Move the route behind the authentication call. A route that attaches a service credential should not be reachable before the gate, and the ordering here is the reason the credential leaves at all. More generally this is one of 39 sites where a machine credential is attached on a user request path against the service’s own written invariant.
Same-day mitigation. Rotate the Access service token, because a static credential that has been reachable this way should be treated as disclosed, and disable developer-subdomain publication on the production Worker so no off-zone hostname answers.
High: infrastructure and CI
MGR-H17
The CI deploy role reads every secret in the account and replaces the code of every function, contradicting its own scoping comment
phenom-infra/modules/ci-cd/main.tf:103 · phenom-infra · Verified · RC-4
What is wrong. The CI role’s inline policy grants secret-value reads and function-code updates on an unrestricted resource. Two comments in the same file state that the role is deliberately scoped so it cannot write production secrets. That is true of the writes and false of the reads, and the security-scanner suppression comment is written about the scoping the reads escape.
Two of the three escalations hold unconditionally from the policy text. The third is partly refuted: the role can rewrite the inline policy of an existing account-scoped identity, but it has neither the access-key-creation nor the console-login-creation action, verified by exhaustive search.
What to change.
- Split the secret read out of the unrestricted statement into its own statement listing the development secret name patterns explicitly, so the read scoping matches the write scoping the module already claims. If a production plan needs specific production secrets, list those identifiers rather than reopening the wildcard.
- Remove the two function-update actions from the unrestricted statement and re-add them in a statement whose resource list names only the functions this pipeline deploys.
- Correct or delete the two comments, so the file stops asserting a containment it does not have.
Land this in the same pull request as MGR-H21.
MGR-H21
The CI trust policy accepts any ref in the repository while the role holds write on production infrastructure state
phenom-infra/modules/ci-cd/main.tf:21 · phenom-infra · Verified · RC-4
What is wrong. The role’s trust condition matches the repository followed by a wildcard, so any ref, branch, pull request or environment in that repository satisfies it. The role that trust admits holds read and write on the production infrastructure state bucket. The environment protection on the production apply job constrains which job runs; it constrains nothing at the identity layer.
Phenom’s own setup runbook for this pipeline instructs the operator to apply the module, note the role output, and set the secret with a command whose form, with no environment flag, creates a repository secret. That is documentary support for the repository-scoped reading.
What to change. Split the role in two and pin each subject condition with an exact match rather than a pattern: one role trusted only for the production environment subject, one for development. Add the environment name as a module variable so the condition is explicit rather than implied.
The durable change. Land this in the same change as MGR-H17’s resource narrowing. The two belong together because the security-scanner suppression comment on one is written about the scoping the other escapes, and fixing either alone leaves the file asserting a containment it does not have. Together they close two High findings and break the chain from a developer host to the whole cloud account.
MGR-H22
Dispatch inputs are interpolated into a shell block in the production infrastructure apply job, after it assumes the production role
phenom-infra/.github/workflows/prod-infra-ci.yml:284 · phenom-infra · Inferred · RC-7
What is wrong. A workflow-dispatch input is interpolated directly into a run: script body, in the job that has already assumed the production role.
What to change. Pass the inputs through the step environment and dereference them as quoted shell variables, which is the documented mitigation for this class. Build the argument list as an array so word splitting is deliberate, and validate the target against an expected shape before using it.
The durable change. Make the input a constrained choice rather than a free string wherever the set of legitimate values is known, and add a build check that fails any workflow interpolating a user-controlled expression directly into a script body. This is the only place in the assessment where the check can be written once and cover 22 workflows.
MGR-H18
The development load balancer forwards every unmatched hostname to the database engine, and one public DNS name lands there
phenom-infra/modules/alb/main.tf:108 · phenom-infra · Inferred · RC-3
What is wrong. The listener’s default action is a bare forward to the data-engine target group. Every host that is supposed to reach that target group already has an explicit rule, so the default action carries no legitimate traffic. One public DNS name with no matching rule therefore reaches the database engine.
What to change. Replace the default action with a fixed response returning 404. Apply the same change to the production listener, whose default action is also a bare forward.
The durable change. This is deny-by-default at the network layer. A default that forwards is a decision nobody made; a default that refuses turns every new hostname into an explicit choice.
Same-day mitigation. Remove the public DNS record for the unrouted hostname, or point it away from this balancer. That closes the specific reachable path without waiting for the listener change.
MGR-H19
The production action handler sends the database superuser secret and every caller’s ID token over cleartext HTTP
environments/production/lambda-functions/hasura-action-phenom-handler/index.js:85 · phenom-infra · Inferred · RC-6
What is wrong. The handler talks to the balancer over plain HTTP, so the administrative secret and every caller’s identity token cross the wire in the clear on every write.
What to change. Use TLS. The balancer’s provider-assigned name is not covered by the wildcard certificate, so changing the port alone will fail certificate validation. Change the environment variable to the covered hostname at the same time, and make the scheme explicit in the helper rather than implied by a port number.
The durable change. Remove the cleartext listener’s ability to carry anything but a redirect, and remove the world-open cleartext rule from the balancer’s security group. A cleartext path that exists is a cleartext path something will use.
Then rotate. An administrative secret transmitted in the clear on every write since this function was deployed should be treated as disclosed. That rotation is one of the dozen currently blocked on there being no rotation procedure at all.
MGR-H15
The production image is pushed with static long-lived cloud keys to a mutable tag that the runtime auto-deploys with no gate
phenom-drop/.github/workflows/docker-build.yml:26 · phenom-drop · Inferred · OWASP A02:2025, A03:2025, A08:2025
What is wrong. The build workflow authenticates to the container registry with a static access key pair and pushes the production tag. The registry repository is configured mutable, the production service consumes exactly that tag, and automatic deployment is on by default and not overridden. The job carries no permissions block and no environment gate.
What to change. Replace the static keys with federated short-lived credentials: add a permissions block granting id-token: write and contents: read, replace the environment block with the credential-configuration action assuming a publish role, and delete the two repository secrets. The trust condition on that role should pin the repository and the branch with an exact match rather than a pattern.
The durable change. Pin the deployed artifact by digest rather than by tag and make the registry repository immutable, so what is running can be tied to a commit. Then put the production publish behind an environment with required reviewers, which is the control four of six production-facing deploy paths currently lack.
MGR-H16
Production public-submission media is served from and written to a storage bucket the infrastructure code does not declare
phenom-drop/backend/server.py:1687 · phenom-drop · Inferred · WSTG-CONF-11, OWASP A02:2025, A01:2025
What is wrong. The production service mints media URLs against, presigns reads from, and read-modify-writes an index object into a bucket whose name is hardcoded on an unconditional code path and defaulted in the presigning module. That bucket is declared as a resource nowhere in the infrastructure repository. The environment variable that would override the default is set in neither the variables nor the secrets of the production service, and the bucket’s name identifies it as a development-tier resource.
What to change. Declare a production bucket in the production environment with a public-access block, versioning, a lifecycle policy and access logging, and give the production service its own. Set the override variable on the production service explicitly, so the default in the code is never what production uses. Remove the development identity’s grant on whatever bucket production ends up using.
The durable change. Five buckets are declared against nine referenced. Generate one register from the accounts, diff it against the infrastructure code in the build, and fail on drift. Until that exists, a resource can be load-bearing for production and invisible to every control you believe you have.
Same-day mitigation. Read the live bucket’s public-access block and policy in the console and, if the block is not fully on, turn it on. Read-only lookup plus one setting, and it does not need the declaration work to land first.
High: application and client
MGR-H02
Every editor-extension setting is workspace-settable, so a cloned repository redirects the Matrix session and login token to an attacker host
codetalker-dev/package.json · codetalker-dev · Inferred · WSTG-ATHZ-01, WSTG-INPV-19, OWASP A03:2025
What is wrong. The extension contributes twelve configuration properties and declares a scope on none of them. A property with no declared scope is settable from a workspace’s own settings file, so a two-line file committed to any repository a developer clones, or receives as a pull request, redirects the extension’s chat homeserver, its identity-token file path, and its speech input and output endpoints. The extension activates on editor startup and connects with no user interaction.
What is disclosed. Two live credentials reach the attacker’s host. The chat access token grants the developer’s chat identity on the homeserver. The identity token is the same one the rest of the estate authenticates with, and one group membership on that identity is MGR-H20. The configurable token-file path additionally turns the extension into an arbitrary file read: a workspace names a path, the extension reads it and posts the contents outward. The speech endpoints disclose microphone audio and chat text to the same destination.
The one precondition, and it is weak. The extension declares no untrusted-workspace capability, so the editor disables it in restricted mode and the workspace has to be trusted first. Developers routinely trust their own clones, and the developer container image bakes this extension into the shared coding environment, where a workspace is trusted once and stays trusted.
What to change. Add a machine scope and a restricted flag to the seven security-relevant properties, so a workspace or folder settings file cannot override them, and add the untrusted-workspace capability declaration explicitly rather than relying on the implicit default.
The durable change. Validate the resolved value before use, so a machine-level misconfiguration cannot become the same primitive: check the homeserver against an origin allowlist and reject anything else at startup, and reject a configured token path that resolves outside the expected roots. Scope alone moves the attacker from a workspace file to a user settings file; validation removes the primitive.
MGR-H04
An ALB OIDC identity is accepted with no issuer or audience binding, and the source-of-channel check is opt-in and set nowhere
ghostmode/alb_auth.py:113 · ghostmode-thephenom-app · Inferred · WSTG-ATHN-10, WSTG-SESS-10, OWASP A07:2025
What is wrong. The console’s verifier accepts any token that validates against the cloud provider’s regional public-key endpoint. It passes no issuer and no audience to the decode, and the one control that would bind the token to a specific load balancer is skipped whenever its environment variable is empty. That variable appears in no artifact in the snapshot. A search across both the console’s repository and the infrastructure repository finds it at exactly two locations, both inside the verifier itself: a documentation string and the lookup. It is absent from the example environment file, from the deployment manifest, and from every file in the infrastructure repository, which contains no reference to this service at all.
The middleware accepts this channel as an equal alternative to a strictly stronger one that checks issuer, audience, token use and group membership.
What to change. Make the source check mandatory and fail closed: read the expected signer, log an error and refuse the identity when it is empty, and reject a mismatch. Then bind the token: pass the issuer the balancer’s action is configured with and the client identifier it authenticates against, and require the expiry, issuer and audience claims. Read both values once at import and refuse to verify anything if either is empty.
The durable change. Provision the values rather than remembering them: add all three variables to the example environment file with empty values and a comment, and to whichever deployment manifest becomes authoritative. A control that is opt-in and defaults to off is a convention, not a mechanism.
MGR-H05
Attacker-chosen request paths reach a JavaScript string inside an inline onclick attribute through an HTML text-node encoder
ghostmode/dashboard.py:608 · ghostmode-thephenom-app · Inferred · WSTG-INPV-14, WSTG-CLNT-01, OWASP A05:2025
What is wrong. The operator dashboard builds its surveillance rows by string concatenation and places the attacker-chosen HTTP request path, recovered from the edge firewall event log, inside a JavaScript string literal nested in an inline event-handler attribute. The value is escaped with an HTML text-node encoder. The HTML parser decodes character references in an attribute value before the handler is compiled as script, so the encoder’s apostrophe replacement becomes an apostrophe again and closes the string. The encoder is correct for the context it was written for and wrong for the context it is used in.
The condition is whether the edge preserves an unencoded apostrophe in the logged path. The apostrophe is a sub-delimiter and is legal unencoded in a path segment.
What to change. Stop generating inline event handlers around untrusted data. Emit the values as data attributes, which the existing encoder is the correct one for, and attach one container-level click listener that reads them from the element’s dataset. Do the same for the IP drill-down link and the drill-down event table. That removes the nested script-in-markup context rather than adding a second encoder to it.
If an inline handler has to survive an interim release, the value must be JavaScript-string-escaped before the HTML escape, but ship the delegated-listener form, because the interim form is one refactor away from being wrong again.
The durable change. Once the inline handlers are gone, drop the inline-script exemption from the content policy. That is what makes this class non-exploitable rather than merely fixed. Separately, either wire the estate’s unused server-side encoder into the collector for the path and host fields, or delete it, so it stops implying a protection that is connected to nothing.
MGR-H14
The public drop API is a single-threaded server, so one unauthenticated request holds every endpoint for its longest outbound call
phenom-drop/backend/server.py:2457 · phenom-drop · Inferred · WSTG-BUSL-05, OWASP A06:2025, API4:2023. High, availability only.
What is wrong. The server is constructed from the standard library’s single-request-at-a-time base class, with no threading or forking mixin anywhere in the repository and no external application server. Several unauthenticated handlers make blocking outbound calls inside the request, with timeouts of 15, 30 and 180 seconds. Any one caller can serialise the entire API behind themselves.
The trigger does not even require malice. The validate endpoint reaches an upstream client whose timeout is 30 seconds; that depends only on the third-party service being slow or unreachable, which an attacker can also induce by submitting the maximum payload. Its per-minute limit is keyed on the caller’s own forwarding header, so it is trivially rotated away.
Impact. Complete denial of service for every endpoint the API serves, including the upload flow, the passcode flow, the confirm flow and the health check. At the 30-second timeout that is one request every 30 seconds for a permanent outage; at 180 seconds it is one request every three minutes. No state is destroyed, so the impact is availability only, but it is total availability loss on the public submission portal.
What to change. Replace the server construction with the threading variant and enable daemon threads. That is a two-line change and it removes the amplification. Note: the in-memory rate-limit store and the geolocation cache become shared mutable state under threading. Guard both with a lock, or move the limiter into the database.
The durable change. Put the application behind a real gateway server with a bounded worker pool, and take the long outbound call off the request path entirely: cut the 180-second timeout to 15 seconds and move video verification into a job the client polls. The confirm handler already returns that result opportunistically, so the client already tolerates its absence.
Also close the reachable trigger. Add a rate limit to the server-verify handler and reject a URL whose host is not in an allowlist. That also closes an outbound-request relay filed separately as a Low.
MGR-H25
Auto-provisioned developer boxes publish an IDE on host ports 8450-8599 with no password variable set
sablier-weblogon/persona/boxes.ts:347 · sablier-weblogon · Inferred · RC-6
What is wrong. Each provisioned box binds its IDE to a host port, and the base image’s authentication depends on an environment variable this repository never sets. The posture is a property of an upstream image’s default rather than of this code.
What to change. Stop publishing the box on the host: delete the port-binding entry and keep the exposed port. The router already reaches each box by container name on the internal port. Replace the two consumers of the publication with a container-network readiness probe, which is what the existing health probe already tries to do and will start working, since today it probes the loopback host port from inside the provisioner container and is therefore always false.
The durable change. Do not rely on the base image’s default. Append an explicit per-box credential to the environment array, generated per box and stored with the box record, so the authentication posture is a property of this repository’s code.
MGR-H26
Auto-provisioned developer boxes share a network with services that accept identity headers without verifying them
sablier-weblogon/persona/box-router.ts:101 · sablier-weblogon · Inferred · RC-1
What is wrong. The boxes, the router, the verifier, the provisioner, the voice service and the scaler all sit on one Docker network. The router and the provisioner trust an inbound identity header that the upstream gate sets. From inside a box, that header is forgeable.
What to change. Separate the networks. Declare a second network for boxes, attach only the router to both, and leave the verifier, the provisioner, the voice service and the scaler on the access network alone. That removes the reachability that makes the header forgeable from inside a box, and it is a compose change rather than a code change.
The durable change. Stop treating an inbound header as an assertion. The router and the provisioner should verify the identity token themselves rather than trusting a header the upstream gate set, so the boundary holds wherever the request entered from. Nine separate verifiers exist in this estate and these two services have none.
Same-day mitigation. Strip the identity headers on ingress at the reverse proxy.
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.