Skip to content

Security & code-health audit: catalog hardening (2 high, 6 medium) + first test suite - #13

Merged
osayed0001 merged 16 commits into
mainfrom
audit/security-2026-07-31
Aug 1, 2026
Merged

Security & code-health audit: catalog hardening (2 high, 6 medium) + first test suite#13
osayed0001 merged 16 commits into
mainfrom
audit/security-2026-07-31

Conversation

@hasan-ismail

Copy link
Copy Markdown
Member

Security and code-health audit of the catalog, baseline main @ 4f4a7f0.

You must merge this yourself — autonomous push was disabled

catalog.json on main is the production artifact: the platform fetches the raw file directly, with no build or deploy gate, so a merge is live to every OpenMasjidOS App Store immediately. This branch also touches scripts/**, which triggers build-catalog.yml on push to main — a job that regenerates catalog.json and auto-commits and pushes it unattended using CATALOG_PUSH_TOKEN, a PAT chosen to bypass branch protection. So a push both ships the artifact and fires a job that re-publishes it. That is exactly the pre-flight condition that overrides an instruction to push.

catalog.json rebuilds byte-identical — verified after every commit. The platform contract is untouched.


Scope: catalog only

Changes are limited to registry.yaml-adjacent tooling: scripts/, .github/, .gitignore, package.json, CLAUDE.md, README.md, docs/. examples/ is byte-identical to pre-audit and no external app repo was written to (the build only fetches each app's manifest.yaml and docker-compose.yml, read-only).

Prayer-time and Hijri correctness are excluded. They belong to each app's own repo — for the clocks masjids actually run, that is the shipped display app (OpenMasjidDisplay), which calculates its own times in its own repository. An earlier draft raised three findings against examples/…/prayer.js; they are withdrawn, and CLAUDE.md is corrected so the mistake isn't repeated. Nothing is implied about OpenMasjidDisplay — its source was never read.

Findings

19 findings: 0 critical, 2 high, 6 medium, 8 low, 3 info. 10 fixed + 1 partial here; 7 deferred (6 because they live in app source, 1 because it needs another repo); 2 need no action.

ID Severity Finding Status
APPS-001 High path: traversal defeats the commit: SHA pin — a "pinned" entry fetches a different repository Fixed
APPS-002 High Compose validator has no external/renamed network check (it has one for volumes) Fixed
APPS-004 Medium Discovery-label ban documented as "Rejected at build AND at install" — never checked Fixed
APPS-005 Medium npm install (not npm ci) in the job that auto-publishes production Fixed
APPS-006 Medium 7 Actions on mutable tags, incl. build-push-action with packages: write Fixed
APPS-007 Medium Quadratic ReDoS + unbounded/untimed fetch can stall the unattended build Fixed
APPS-011/-012 Low cgroup_parent unchecked; scalar cap_add/security_opt/group_add skipped checks Fixed
APPS-014 Low Untrusted manifest fields copied into catalog.json with no type/length validation Fixed
APPS-015 Low .gitignore gaps (.env.*, keys, certs, .npmrc) Fixed (partial)
APPS-020 Info No tests, lint or type check anywhere; the documented safety gate was untested Fixed
APPS-008/-009/-013/-018/-023 Med–Low Live in examples/ — reported, not fixed Deferred
APPS-019 Low parking-attendant image not digest-pinned — needs a change in that repo Deferred
APPS-021/-022 Info Secrets clean (475 commits, 18 patterns, 0 hits); CLA workflow assessed and accepted No action

The pattern worth noting: three controls this repo documents as enforced were not implemented — the label ban (docs/BUILDING_AN_APP.md:573), the commit: pin (registry.yaml), and platform lockstep (CLAUDE.md §10). The docs were ahead of the code, so a reviewer trusting them would skip the check.

The two High findings

APPS-001repo/ref/path were interpolated into a raw.githubusercontent.com URL unvalidated; only leading/trailing slashes were stripped, so .. survived:

path: ../../../../attacker/evil/main
  →  https://raw.githubusercontent.com/attacker/evil/main/manifest.yaml

Meanwhile registry.yaml, the review diff and the build log all still named the pinned repo and SHA, and no ⚠ fired. The attacker's own ref is mutable, so content can change after review and the nightly job republishes it.

APPS-002networks: {omos_internal: {external: true}} is valid Docker Compose (confirmed against Docker Compose v5.3.1) and passed with zero errors and zero warnings, letting a listed app join a pre-existing Docker network. The validator already forbids exactly this for volumes; networks were simply never inspected.

Verification

$ npm ci        → added 1 package, found 0 vulnerabilities
$ npm audit     → found 0 vulnerabilities
$ npm run lint  → ✓ 18 file(s) checked, catalog.json matches the platform contract
$ npm test      → tests 139  pass 139  fail 0
$ npm run build → 5 apps; git status clean (catalog.json byte-identical)
Before After
Tests none 139, all passing
Lint / contract check none npm run lint
CI gate before publishing npm install → build npm ci → lint → test → build
Actions on mutable tags 7 0 (+ Dependabot to keep pins fresh)
Dependencies 1 1 — no new dependency

Every new rejection was verified against all five live app composes and manifests before each commit, so nothing currently listed is affected.

Behaviour changes to watch (Tier 2)

  1. The build now fails on inputs it previously accepted — malformed/..-containing registry fields, external/renamed networks, reserved labels, cgroup_parent, bad manifest types. Intended; messages name the exact problem.
  2. CI gates publication on lint + tests — a red test now blocks app releases until fixed.
  3. npm installnpm ci — fails hard if lockfile and package.json drift, rather than silently resolving.

Two mistakes I made, corrected

  • APPS-017 was wrong. I claimed midnight-crossing Isha mis-selected the next prayer, implemented a fix, then simulated a full day: zero difference — the existing code was already correct. Reverted rather than shipped.
  • A font change caused a regression, caught pre-commit. Adding a Naskh face globally made English render in it. That work is no longer on this branch anyway (out of scope), but it's why I verify computed output rather than assuming a CSS edit is inert.

Docs corrected

CLAUDE.md contradicted itself — §1 called examples/ "complete, working reference apps" while §15 said "catalog-only. Don't add real app source here." That contradiction is what pulled this audit into app code. §1/§4/§11/§14/§15 and the README now state examples/ is illustrative scaffolding, and that app correctness (prayer times, Hijri, Zakat, RTL) belongs to each app's own repo.

Read next

  • docs/audit/ACTION_REQUIRED.md — nothing to rotate, no history rewrite needed; 4 compose checks for the platform to mirror, 3 platform questions, 1 digest pin needed in parking-attendant.
  • docs/audit/REMEDIATION.md — per-commit verification and per-finding rollback commands.
  • docs/audit/findings.json — machine-readable, same schema across the repo audits.

Rollback: git revert -m 1 <merge-sha>, or reset to the pushed tag pre-audit-2026-07-31 (4f4a7f0).

🤖 Generated with Claude Code

hasan-ismail and others added 16 commits July 31, 2026 21:42
23 findings (0 critical, 3 high, 7 medium, 9 low, 4 info) against
main @ 4f4a7f0. Compose findings adjudicated against real Docker
Compose v5.3.1; prayer-time findings verified numerically.

Autonomous push to main was disabled by pre-flight: catalog.json on
main IS the production artifact the platform fetches, and pushing
scripts/** additionally triggers an unattended rebuild that
re-publishes it with a branch-protection-bypassing PAT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…PPS-020]

CLAUDE.md §10 marks scripts/validate-compose.mjs DO-NOT-REGRESS and
requires it to stay in lockstep with the platform validator, but the
repo had no test runner, linter or type check at all — the invariant
was maintained by hand and this audit had no suite to keep green.

Adds, with zero new dependencies (node:test + node --check):
  - 60 validator cases covering every directive class it rejects,
    plus the alias-bomb and unparseable-YAML fallback paths
  - 45 prayer-engine cases: ordering, sunrise/sunset symmetry about
    dhuhr, Hanafi > Standard Asr, fixed-interval Makkah Isha,
    angle-based Tehran/Jafari Maghrib, tz linearity, and golden pins
    for Cairo/Toronto/London-Dec so later changes to high-latitude
    handling can be proved not to move times that were already right
  - scripts/lint.mjs: parses every .mjs/.js, enforces the SPDX header
    CLAUDE.md §10 mandates, and asserts catalog.json still matches the
    platform contract in CLAUDE.md §2

The equinox test pins the 0.833 deg refraction allowance: apparent day
length at the equator is 12h + 2*0.833/15, not 12h.

105 tests, 105 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…[APPS-001]

rawBase() interpolated the registry entry's repo/ref/path into a
raw.githubusercontent.com URL with no validation; the only cleaning was
stripping leading/trailing slashes from `path`, so a .. segment
survived and URL normalisation moved the fetch to a different repo:

  repo:   OpenMasjid-Solutions/OpenMasjidDisplay
  commit: a32816bf5e3e3576b4a0bcfb400713b12383e98f
  path:   ../../../../attacker/evil/main
  -> https://raw.githubusercontent.com/attacker/evil/main/manifest.yaml

Every control looked intact while this happened: registry.yaml still
named the pinned repo, `immutable` stayed true so no warning fired, and
the build log printed the innocent repo and SHA. The attacker's own ref
(main) is mutable, so content could change after review and the
unattended nightly rebuild would republish it -- and CATALOG_PUSH_TOKEN
bypasses branch protection, so no second human gate stood in the way.

Validation now runs before any URL is built (scripts/registry-validate.mjs,
split out so it is testable -- importing build-catalog.mjs runs the build):
  - repo must be exactly <owner>/<repo> from [A-Za-z0-9._-]
  - ref must be a plain git ref and contain no ..
  - path must be a relative subpath with no .. segment
  - all three reject whitespace, control chars and URL punctuation
  - rawBase() re-validates and asserts the assembled URL is still on
    raw.githubusercontent.com under /<repo>/<ref>/, so the traversal
    cannot be reintroduced by a caller that forgets to validate

Verified: 119 tests pass (13 new, incl. one that executes the pre-fix
URL to document the escape); lint passes; catalog.json rebuilds
byte-identical, so the platform contract is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…002]

The validator had a careful checkExternalVolumes rule stopping an app
attaching to storage it does not own, and no counterpart for networks.
This is valid Docker Compose (confirmed against Docker Compose v5.3.1)
and passed with zero errors AND zero warnings:

  services:
    a:
      networks: [omos_internal]
  networks:
    omos_internal:
      external: true

`external: true` uses the network name verbatim and `name:` overrides the
project-scoped name, so either places the container on a pre-existing
network -- including the platform's own, where the core API and other
apps' containers listen. classifyVolumeSource() only ever sees volume
sources, so nothing looked at this. The asymmetry with the volume rule is
what makes it an oversight rather than a decision.

Now rejected, mirroring the volume rule exactly:
  - any top-level network with `external:` (short or { name } form)
  - any top-level network with an explicit `name:` override
  - a distinct message when the target matches ^omos[-_], because that
    one reaches the platform
  - `driver: host` / `driver: none`
  - a service joining a network the file never declares (compose would
    resolve it against pre-existing networks)

Ordinary project-scoped networks, aliases, and composes with no
networks: block at all are unaffected -- all covered by tests.

Per Tier 3 the matching change to the platform's
apps/compose-validate.ts is NOT made here; it is recorded in
docs/audit/ACTION_REQUIRED.md under Cross-repo. What is reachable on an
omos_* network could not be verified from this repo and is flagged as
such in the audit.

Verified: 129 tests pass (10 new); all five live app composes still
pass; catalog.json rebuilds byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…[APPS-004][APPS-011][APPS-012]

Three checks in the same function; one revert undoes all three.

APPS-004 (Medium) - CLAUDE.md section 4C forbids discovery labels and
docs/BUILDING_AN_APP.md:573 states they are "Rejected at build AND at
install". Nothing here ever read labels, so the documented guarantee was
not real and this passed clean:

  labels:
    com.docker.compose.project: omos-donations
    com.openmasjid.trusted: "true"

CLAUDE.md section 2.5 says platform discovery is by
com.docker.compose.project=omos-<id>, so an app claiming another app's
project label can confuse platform inventory, lifecycle and uninstall -
it may be listed, stopped or removed as part of that app. Now rejected
(case-insensitively) on services, service build, networks, volumes,
secrets and configs, in both the map and "k=v" list forms. An app's own
labels, including org.opencontainers.*, are unaffected.

The precise platform-side consequence of a duplicate project label could
not be verified from this repo and is recorded in ACTION_REQUIRED.md.

APPS-011 (Low) - cgroup_parent was unchecked although `cgroup: host` was
rejected, and both are the same class. It places the container outside the
cgroup slice the platform assigns, escaping the memory and CPU limits that
keep a Raspberry Pi responsive. Now an error. sysctls is now a warning
rather than an error: Docker only permits namespaced sysctls without
--privileged, so the reach is the container's own namespace - worth
surfacing, not worth failing a legitimate app over.

APPS-012 (Low, NOT exploitable) - cap_add, security_opt and group_add were
gated on Array.isArray(), so a scalar skipped the check entirely, while the
neighbouring devices and volumes_from checks handled both shapes. I first
read this as a bypass; Docker Compose v5.3.1 settled it:

  cap_add: SYS_ADMIN        -> services.a.cap_add must be a array
  security_opt: seccomp=... -> services.a.security_opt must be a array
  group_add: docker         -> services.a.group_add must be a array

So such a compose fails at install anyway and no privilege is gained. It
is still fixed, because CLAUDE.md section 10 makes lockstep with the
platform's independent validator an invariant, that validator may
normalise shapes differently, and a safety check should not depend on YAML
shape. Downgraded to Low in the report accordingly.

Verified: 158 tests pass (13 new); all five live app composes still pass -
they use the list form with cap_drop and no-new-privileges, and set no
labels, cgroup_parent or sysctls; catalog.json rebuilds byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nightly rebuild is unattended and auto-publishes catalog.json, so a
listed app repo that can stall it can also stall every masjid's view of
the catalog.

Two problems, both reachable from untrusted app-repo content.

1. Quadratic regex. The merge-key scan was /(^|\n)\s*<<\s*:/. Because \s
   also matches \n, the (^|\n) alternation and the \s* quantifier
   overlapped: a match could begin at every newline and \s* then ran to
   end-of-input before failing. Measured on the old pattern against
   attacker-controlled compose text:

       20,000 chars      98.8 ms
       80,000 chars    1611.1 ms
      160,000 chars    6431.7 ms
      320,000 chars   25712.5 ms      (~4x per doubling)

   Replaced with the anchored /^[ \t]*<<[ \t]*:/m, which is linear because
   a merge key can only be preceded by spaces or tabs on its own line:

       320,000 chars       0.235 ms   (about 100,000x faster)

   Proven equivalent on every spelling that matters - indented, tab
   indented, at column 0, and "<< :" with a space before the colon - and
   on a negative case. Three of those are now tests.

2. Unbounded, untimed fetch. fetchText had no timeout and no size limit;
   res.text() buffered whatever the remote sent, so a slow server could
   hang the job to the 6-hour Actions ceiling and a huge body could
   exhaust its memory. Now:
     - AbortSignal.timeout(20s) on both fetchText and resolveRefToSha
     - a 2 MiB ceiling enforced while streaming the body, so it stops at
       the limit instead of discovering the size after buffering
     - content-length rejected up front when it already exceeds the cap
     - a separate 64 KiB cap on the compose itself, since that text is
       embedded verbatim into catalog.json for every install

   With the 2 MiB fetch cap in place the worst case that can now reach
   the validator costs ~1.2s, and that residual is the YAML parser
   walking the document, which is linear in size:

       1 MiB  mergeKeyRegex 0.96ms  yamlParse  594ms
       2 MiB  mergeKeyRegex 2.44ms  yamlParse 1222ms
       4 MiB  mergeKeyRegex 4.13ms  yamlParse 2450ms

Real limits are far above real content: the largest live compose is well
under 10 KB and the largest manifest a couple of KB.

Verified: 161 tests pass (4 new). The linearity test asserts the shape -
4x input must not cost 16x - rather than an absolute wall-clock number, so
it will not flake on shared CI. Full build against all five live repos
still succeeds and catalog.json rebuilds byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…APPS-005][APPS-006][APPS-020]

All three harden the same job - the one that auto-publishes production.

Context for the severity: catalog.json on main IS the artifact the
platform fetches, and this workflow regenerates and pushes it unattended
on a cron, using CATALOG_PUSH_TOKEN, a PAT chosen specifically to bypass
branch protection. It is the highest-leverage position in the repository.

APPS-005 (Medium) - it ran `npm install`, which unlike `npm ci` may
resolve a newer version inside the declared "^2.5.0" range and rewrite the
lockfile. Demonstrated: a plain `npm install` on a clean tree modified
package-lock.json. So an unreviewed dependency release could execute in
this job and then publish with it. Now `npm ci`, which installs exactly
what the lockfile pins - verified it succeeds and leaves the lockfile
untouched. Added `cache: npm` to setup-node.

APPS-006 (Medium) - seven actions were on mutable major tags. Moving a tag
is the exact supply-chain attack the rest of this repo warns about at
length for registry refs and image tags. The catalog workflow's two
mattered because of the PAT; the five in the copied image-build template
mattered more, because that job holds packages: write and pushes the image
masjids pull - docker/build-push-action and docker/login-action among
them. cla.yml already pinned by SHA, so this applies an existing in-repo
convention rather than inventing one. Pinned, with the version kept in a
trailing comment:

  actions/checkout             -> 11d5960a326750d5838078e36cf38b85af677262 # v4
  actions/setup-node           -> 49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
  docker/setup-qemu-action     -> c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
  docker/setup-buildx-action   -> 8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
  docker/login-action          -> c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
  docker/build-push-action     -> 10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6

Pinned to the tip of the SAME major each file already used, so no
behaviour changes. Newer majors exist (checkout v7, setup-node v7,
docker/* v4/v7) but a major bump is a Tier 2 change and is listed in
ACTION_REQUIRED.md instead of made here.

APPS-020 (Info) - the publish step is now gated on `npm run lint` and
`npm test`. scripts/validate-compose.mjs is described in CLAUDE.md
section 10 as the catalog's safety gate with a DO-NOT-REGRESS invariant;
proving it works before regenerating the production artifact is the point
of having the suite at all. lint also asserts catalog.json still matches
the platform contract in CLAUDE.md section 2, so a shape regression fails
the build instead of shipping.

Verified: all four workflow files parse and their step lists are correct;
`npm ci` exits 0 and does not mutate the lockfile; every action in the repo
is now SHA-pinned (grep for a bare @vn finds nothing); 161 tests and lint
pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dary [APPS-014]

name, tagline, description, settings, ports, icon and screenshots were
copied from an untrusted remote manifest straight into catalog.json with no
type check, no length limit and no structural validation.

Concrete consequences:
  - `name` was only tested for truthiness, then apps.sort() called
    a.name.localeCompare(b.name). A numeric or object name threw a
    TypeError and broke the build (asserted in a test).
  - wrong types silently violated the platform contract in CLAUDE.md 2.3,
    which fixes settings as an Array and ports as [{container: number}].
  - settings[].key was not checked as an environment-variable name, and
    settings[].default was not checked for newlines - although CLAUDE.md 7
    requires single-line values precisely because the platform writes them
    to .env as KEY=VALUE. A newline there is an .env line injection.
  - no length limit at all on a field embedded in the file every masjid
    fetches.

Now validated: strings must be strings and are capped (name 120, tagline
200, description 16 KiB - the largest live description is ~1.7 KB, so the
caps are far above anything real); settings must be a list of objects with
an ^[A-Za-z_][A-Za-z0-9_]*$ key, no duplicates, a known type, options
present for select, and a single-line scalar default; ports[].container
must be an integer in 1..65535; icon/screenshots must be relative subpaths
with no ".." and no scheme. description stays multi-line, since it is
markdown.

Scope note, per Tier 3: `description` is rendered as markdown on the
platform's app detail page, so whether hostile markdown is merely ugly or
is XSS in the dashboard depends on the platform's renderer, which I cannot
see from this repo. Likewise whether the platform quotes .env values.
Implemented the half that belongs here - reject it at the catalog boundary -
and recorded both platform-side questions in ACTION_REQUIRED.md rather than
guessing.

Verified: 200 tests pass (63 new, covering every accepted and rejected
shape). All five live manifests still validate and catalog.json rebuilds
byte-identical, so nothing currently listed is affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prevention, not a live leak: the audit content-scanned all 475 commits for
18 credential patterns and found nothing, and there is no .env,
certificate, backup or dump anywhere in history. This keeps it that way.

.gitignore covered `.env` but not the variants tools actually create
(.env.local, .env.production, .env.development), and covered no key or
certificate material at all - no *.pem, *.key, *.p12, *.crt, id_rsa - nor
.npmrc, which is where an npm auth token lands. All added, with
!.env.example kept so a documented template can still be committed.

Scope note: the matching .dockerignore for the two reference apps under
examples/ was deliberately left out of this branch. This audit is scoped to
the catalog itself (registry, build tooling, workflows, docs) and does not
change app source, including the example apps. That part of APPS-015 is
recorded as deferred in the audit report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uses

Confines this audit branch to the catalog itself - registry, build tooling,
workflows, ignore rules and docs - and makes no change to app source,
including the two reference apps under examples/. That matches CLAUDE.md
sections 1 and 15: this repo is catalog-only.

  - adds a scope banner naming every finding now deferred because its fix
    belongs in app source: APPS-003, -008, -009, -010, -013, -016, -018,
    -023, and the .dockerignore half of -015
  - rewrites those Status cells from Fixed to Deferred (app scope), so the
    report never claims a fix that is not on the branch
  - marks APPS-017 as WITHDRAWN / not a defect: it was implemented, then
    reverted when a full-day simulation showed zero observable difference
  - restates the severity table as fixed / deferred-app-scope /
    deferred-other / no-action-needed
  - drops the examples test glob from the npm test script, since the
    prayer-engine suite is app source and is not on this branch

APPS-003 (high-latitude prayer times returning Fajr 01:02 and Isha 01:02
for London in June) stays the most important UNFIXED finding. Being out of
scope here does not make it safe; the verified numbers and the prescribed
fix are recorded in full.

Nothing was ever pushed: origin/main is still 4f4a7f0, this branch does not
exist on the remote, and no tag was pushed. No app repository was written
to - the build only ever fetches them read-only.

139 catalog tests pass; lint passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Down-rates APPS-003 from High to Medium and fixes a framing error that
made the report misleading about whose code the bug is in.

What the first draft got wrong: it called APPS-003 "the finding to read if
you care about harm" and "the most important unfixed finding", which reads
as though the prayer engine masjids actually run is broken. It is not.

The facts, checked against the live catalog:

  - the defect is in examples/prayer-times-display/src/js/prayer.js, a
    REFERENCE TEMPLATE in this repo
  - prayer-times-display is commented out in registry.yaml:55-56, so it is
    in no catalog and no masjid can install it
  - prayer times for real installs belong to the shipped `display` app,
    OpenMasjidDisplay v0.65.0, whose own manifest description says it
    renders "prayer clocks calculated on the device" with Adhan and Iqamah
    times. That engine is in ITS OWN repository behind a digest-pinned
    image, has no catalog settings (configured in its own control panel),
    and is a much larger codebase than this static nginx template.

And the part that needed saying explicitly: I never read
OpenMasjidDisplay's source. The catalog build fetches only each app's
manifest.yaml and docker-compose.yml; the engine is inside the image.
Claiming the same bug there on this evidence would be a fabricated finding.
It is now recorded in ACTION_REQUIRED.md as a cross-repo CHECK TO RUN, with
the exact test - MWL times for London (51.5074, -0.1278) on 21 June; if
Fajr equals Isha, or Fajr lands near 01:00, it has the same clamp.

Severity: Medium, not High. Nothing here ships, and this is not the engine
masjids use, so neither the Critical criterion ("wrong prayer times shipped
to users") nor High applies. It is latent - harmful once an author copies
the template, which CLAUDE.md 1 and 4A and docs/BUILDING_AN_APP.md all tell
them to do.

The verified numbers are unchanged (London 21 June still returns Fajr 01:02
and Isha 01:02, which is impossible). Only the reach was overstated.

Severity totals become: High 2, Medium 8. APPS-001 stays High and remains
the most important finding in this repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md contradicted itself, and that contradiction is what pulled this
audit into rewriting app code in a catalog-only repo:

  section 1  "examples/ - complete, working reference apps"
  section 15 "This repo is catalog-only. Don't add real app source here."

Section 11 (quality bar: design language, RTL, "prayer times get the same
scrutiny as security bugs") and section 14 (definition of done for an app)
also read as though they applied to this tree. Combined with "complete,
working", the natural reading is that examples/ is a maintained product
held to that bar - so finding it broken looks like a bug to fix here. It is
not: nothing under examples/ is catalogued, built, tested or run by anyone.

Changes:
  - section 1: examples/ is "illustrative scaffolding only" - skeletons
    showing the SHAPE an app repo needs. Explicitly not in the catalog, not
    built, not tested, not released, not run by any masjid. Once copied it
    is the author's code in the author's repo.
  - section 1 "does NOT contain": adds that this repo does not own app
    behaviour or correctness - prayer times, Hijri, Qibla, Zakat maths, UI,
    RTL - and names OpenMasjidDisplay as the owner of the prayer clocks
    masjids actually run, noting that changing examples/ cannot fix or
    break it.
  - section 15: two new rules. Do not modify app source in this repo,
    INCLUDING examples/, which is out of scope for reviews, audits,
    refactors, test coverage and bug fixes - say so and stop rather than
    fixing it as a side quest. And: app correctness is never this repo's
    bug; report it as a cross-repo item, and never assert a bug in an app
    repo you have not read, since only manifest.yaml and
    docker-compose.yml are visible from here.
  - sections 4, 11, 14: scoped to the app's own repository, so the quality
    bar and definition of done are not mistaken for a to-do list here.
  - section 12 tree comment and README section: same correction.

Had these rules been in place, the app-source part of this audit would not
have been started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes APPS-006. Pinning every action to a commit SHA stops a moved tag
reaching the workflow that publishes catalog.json, but it also freezes the
version - so the fix introduces a new failure mode: a pin nobody updates
becomes a stale pin with known bugs. Dependabot resolves that by opening a
reviewable PR that moves the SHA and its trailing version comment together.

Watches this repo's tooling only: github-actions plus the single npm runtime
dependency. It does NOT watch examples/, which is illustrative scaffolding
rather than software this repo maintains (CLAUDE.md section 1), and whose
workflows never run from here.

A major bump to yaml is excluded from automation: it is the library the
build parses untrusted app-repo YAML with, so that is a review decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the audit deliverables and finishes scoping the report to the
catalog.

REMEDIATION.md - what shipped, per commit: what changed, why the fix works,
how it was verified, with the real npm ci / audit / lint / test / build
output pasted in rather than summarised. Leads with the three
behaviour-changing items (the build now rejects inputs it used to accept,
CI gates publication on lint+tests, npm install became npm ci). Includes a
rollback command per finding, the two ordering constraints between them
(APPS-014 before APPS-001, the CI gate before the test infra), and how to
revert the whole run to pre-audit-2026-07-31. Also records the two mistakes
I made and corrected, so the rest can be judged in that light.

ACTION_REQUIRED.md - only-you items. No credential needs rotating (475
commits, 18 patterns, zero hits) and no history rewrite is needed or
recommended; the priority list exists only in case one is ever suspected.
Cross-repo requests are stated as requests, not done deals: four compose
checks for the platform to mirror so CLAUDE.md section 10 lockstep holds,
three platform questions that would settle a severity here, and a digest
pin needed in the parking-attendant repo which cannot be done from
registry.yaml. Plus assumptions to disagree with, and what to do if main
breaks after merge.

findings.json - 19 findings plus 4 withdrawn, same schema as the other
repo audits so they merge. Cross-checked: the finding set matches the
report table exactly, the totals block matches the per-finding counts, and
every fixed finding names its commit while no deferred one does.

Report scoping finished in this commit: the three prayer-time/Hijri
findings and the disproved midnight-Isha one are removed from the findings
table and detail sections, the severity counts corrected to 19, and the
threat model and coverage sections rewritten so examples/ is stated as
deliberately not assessed rather than silently absent. The executive
summary now leads on the pattern that actually matters here - three
controls documented as enforced that were not implemented.

Final verification on this branch, from a clean node_modules:
  npm ci    -> added 1 package, found 0 vulnerabilities
  npm audit -> found 0 vulnerabilities
  npm run lint -> 18 files checked, catalog.json matches the platform contract
  npm test  -> tests 139, pass 139, fail 0
  npm run build -> 5 apps, catalog.json byte-identical to pre-audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes APPS-020. The gate added earlier lives in build-catalog.yml,
which only triggers on push to main (plus cron and dispatch). On main the
regenerated catalog.json is published immediately - the platform fetches
that raw file with no deploy gate - so a problem caught there is caught
after it is already live to every masjid. The check was in the wrong place.

This workflow moves it in front of the merge: npm ci, npm run lint,
npm test on every pull request targeting main. lint also asserts
catalog.json still matches the platform contract (CLAUDE.md section 2), so
a shape regression is caught at review time.

Details worth noting:
  - permissions: contents: read only. It reads code and reports a status;
    it never needs a token that can write or publish.
  - concurrency group with cancel-in-progress, so a force-push supersedes
    the previous run instead of queueing.
  - actions pinned to the same SHAs as build-catalog.yml.
  - npm audit runs advisory (continue-on-error): a fresh advisory in a
    transitive package should be visible in the log but must not block an
    unrelated catalog PR.
  - npm run build is deliberately NOT run here. It fetches every listed app
    repo (network, rate limits) and rewrites catalog.json, which must only
    ever be regenerated by build-catalog.yml on main.

Verified: workflow parses, triggers and permissions are as intended, and
lint plus 139 tests pass locally on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new PR workflow failed on its first run and caught a real bug in my own
test script:

  > node --test "scripts/__tests__/*.test.mjs"
  Could not find '/home/runner/work/.../scripts/__tests__/*.test.mjs'
  ##[error]Process completed with exit code 1

Node only expands a quoted glob passed to --test from v22 onward. I wrote
and verified that script on Node 24 locally; CI runs Node 20, where the
pattern is taken literally as a filename. So `npm test` was silently broken
on the runtime that actually matters, and the audit's own test evidence had
only ever been produced on the wrong Node version.

Checked every candidate against BOTH runtimes rather than assuming:

                                        Node 20      Node 24
  node --test                           139 pass     139 pass
  node --test scripts/__tests__         139 pass     0 pass, fail
  node --test "scripts/.../*.test.mjs"  not found    139 pass

Only the bare `node --test` works on both, so that is what package.json now
uses. It recursively finds files matching the *.test.mjs convention from the
repo root and skips node_modules, which covers scripts/__tests__ today and
anything added later without needing the script updated again.

Verified: 139 tests pass under Node 20 in a container and under Node 24
locally; lint passes.

This is exactly the failure the PR workflow was added to catch, and it
caught it before the branch could be merged - the previous arrangement, with
checks only on push to main, would have gone red after the catalog was
already republished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@osayed0001
osayed0001 merged commit 66f24bf into main Aug 1, 2026
2 checks passed
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.

2 participants