# Compliance gap report — OWASP Top 10 (2025) vs bugzilla
Generated 2026-09-14 by CodeNavi compliance-check v0.1.5 · 10 rules
Codebase revision: git 4299886 (2026-02-18 upstream commit; unchanged since the 2026-09-10 partial run)

**Summary:** 0 pass · 7 partial · 3 fail · 0 not applicable · 0 cannot check from code

Scope notes: the target is the Bugzilla 5.x Perl/CGI application as checked out for testing (~290 Perl files, Template Toolkit templates, vendored YUI JavaScript). It has its own accounts, passwords, login cookies, CSRF tokens, attachments, an XML-RPC/JSON-RPC/REST API and an admin audit log, so every rule applies. CodeNavi's call graph does not cover Perl, so "is this control on every execution path?" was answered by enumerating call sites with `grep_search`, not by walking callers. Three rows are carried from the 2026-09-10 run at the same revision and are marked *(carried)*; A07 carries only its throttling sub-check and adds the seven sub-checks that run did not search.

| Rule | Question | Verdict | Evidence | Note |
|------|----------|---------|----------|------|
| TOP10-A01 | Broken Access Control | partial *(carried 2026-09-10)* | `Bugzilla/Bug.pm:372-403,415-433` (`check` / `check_is_visible`), `Bugzilla/Token.pm:219-229` (`check_hash_token`), 75 `check_token_data`/`check_hash_token` call sites across 30 files | Worst applicable sub-check: id-scoped records. The gate is explicit and refuses via `ThrowUserError`, but `Bugzilla::Bug->check` throws `bug_id_does_not_exist` before the visibility gate, so "does not exist" and "not authorized" are distinguishable and the existence of a restricted bug id is disclosed; ids are sequential and in every URL, so this is an accepted design, recorded as a known one. CSRF is strong: every token names its own action (`'delete_user'`, `'edit_group'`, …), so a token minted for one action cannot authorise another; the JSON-RPC server additionally refuses non-`application/json` POSTs and refuses data-changing methods over GET (`Bugzilla/WebService/Server/JSONRPC.pm:393-414`). Partial rather than pass because enforcement is per call site, not a framework-level gate: a new `.cgi` that forgets the call inherits no protection, and a static search cannot prove every state-changing path calls one. Re-verified at this revision: the cited lines are unchanged. |
| TOP10-A02 | Security Misconfiguration | fail | `Bugzilla.pm:17-23` and `Bugzilla/Config.pm:339-340` (`CGI::Carp fatalsToBrowser`), `Bugzilla/Auth/Persist/Cookie.pm:65-91` and `Bugzilla/CGI.pm:400-408` (cookie flags), `Bugzilla/WebService/Server/XMLRPC.pm:148-155` and `importxml.pl:1305-1311` (`NoExpand => 1`), `Bugzilla/Update.pm:41-42` (Twig without NoExpand), `Bugzilla/Install.pm:310-362` (admin prompted, not seeded), `Dockerfile:57,63-64` (no `.dockerignore`; `docker*`/`Dockerfile*`/`localconfig` removed after COPY, `.git` not), `docker-compose.yml:24-34` (fixture credentials) | Worst sub-check: debug output to the browser. In any plain-CGI deployment (`SERVER_SOFTWARE` set, `MOD_PERL` unset) `CGI::Carp` is imported with `fatalsToBrowser` at compile time, so compile errors and uncaught `die` messages, with file paths and line numbers, are rendered to the client; the comment says this is deliberate. mod_perl deployments skip that block. Cookies: `HttpOnly` always; `Secure` only when the `ssl_redirect` param is on; no `SameSite` attribute found by these searches on any cookie. XML: both untrusted-input parsers (XML-RPC, email/XML import) disable entity expansion; the update-notification file is parsed without it but is fetched by Bugzilla itself (see A08). Default admin: none seeded, the installer prompts for email and password. Secrets: DB credentials live in `localconfig` (generated, excluded from the image); the only literals found are the docker fixture (`password01!`, `bugzilla`) in `docker-compose.yml`, a development stack. VCS metadata: the image `COPY`s the whole checkout with no `.dockerignore`, and no `.git` deny rule was found in the generated `.htaccess` set (`Bugzilla/Install/Filesystem.pm:317-326`), so a `.git` directory present at build time lands in the Apache docroot. |
| TOP10-A03 | Software Supply Chain Failures | fail | `Bugzilla/Install/Requirements.pm:84-163` (`REQUIRED_MODULES`, minimum versions only), `Dockerfile:54` (`cpan install` of four modules, unpinned), `js/yui/` (vendored, no manifest) | No lockfile: Perl dependencies are declared as floating minimums (`version => '3.51'` means `>= 3.51`) and resolved at install time from whatever the OS package manager or CPAN serves; the image build pulls four modules from CPAN with no version at all. No dependency-vulnerability scanning or update automation found by these searches: no `cpanfile.snapshot`, Dependabot/Renovate config, CI workflow, or SBOM step anywhere in the tree. Vendored YUI 2 JavaScript has no manifest to scan. Whether the versions an installation actually resolves are currently vulnerable needs a live CVE/OSV lookup: cannot check from code. |
| TOP10-A04 | Cryptographic Failures | fail | `Bugzilla/Util.pm:683-730` (`bz_crypt`), `Bugzilla/Constants.pm:646` (`PASSWORD_DIGEST_ALGORITHM => 'SHA-256'`), `Bugzilla/Util.pm:738-742` (`generate_random_password`), `Bugzilla/RNG.pm:68-81,105-123,138-159` (ISAAC seeded from `/dev/urandom`), `Bugzilla/Token.pm:199-216` (HMAC-SHA256 with `site_wide_secret`), `Bugzilla/Template.pm:517-599`, `Bugzilla/WebService/Server.pm:18,71-80`, `Bugzilla/DB/Schema.pm:2044` (MD5 on non-security paths) | Worst sub-check: password hashing. Passwords are stored as `salt , base64(SHA-256(password . salt)) {SHA-256}`: salted, but a single unkeyed digest pass with no work factor, and legacy entries with no algorithm tag fall back to `crypt()` (`Util.pm:714-718`). That is not an approved KDF (bcrypt/scrypt/Argon2/PBKDF2) and is fast to brute-force offline if the table leaks. The rest passes: tokens and CSRF hashes use HMAC-SHA256 keyed with a per-install secret; MD5 appears only for cache filenames, ETags and DB identifier shortening, not on a signing or token path; random values come from ISAAC (a CSPRNG) seeded with 8+ bytes from `/dev/urandom` or `RtlGenRandom`, with a warning on short seeds; no symmetric encryption found, so cipher-mode and key-size sub-checks drop out. Evidence note: the `read_file` of `Bugzilla/Object.pm:629` returned a `[REDACTED:secret_generic_assignment]` marker and was not used as evidence; it is inside the audit-log sanitiser, not on the hashing path. |
| TOP10-A05 | Injection | partial *(carried 2026-09-10)* | `Bugzilla/Search.pm:745,781-786` (heredoc `WHERE $where`, `selectall_arrayref($sql)`), `Bugzilla/Search.pm:2027,2138,2554` (`$dbh->quote`), `Bugzilla/User.pm:2435` (placeholders elsewhere) | Worst applicable sub-check: parameterised SQL. The search builder assembles the statement by string interpolation and executes it with no bind values; safety rests on every user value passing through `$dbh->quote()` (15 call sites in that file) and numeric input through `detaint_natural`. That is driver escaping, not parameterisation, and a search cannot prove no value reaches the string by another route. Elsewhere the codebase uses `?` placeholders consistently (every statement read in this run: `Auth/Persist/Cookie.pm`, `Auth/Login/Cookie.pm`, `Token.pm`, `Install.pm`). HTML output: Template Toolkit with per-value `FILTER html` (seen throughout `template/`), and the response layer sets `X-Content-Type-Options`, `X-Frame-Options` and refuses non-text content types to untrusted referers (`Bugzilla/CGI.pm:315-374`). Perl taint mode is on, so unvalidated input dies before reaching a shell or file operation. |
| TOP10-A06 | Insecure Design | partial | `Bugzilla/Attachment.pm:484-552` (`_check_content_type`), `Bugzilla/Attachment.pm:992-1026` (`get_content_type`), `Bugzilla/CGI.pm:315-374` (`_prevent_unsafe_response`) | Upload proxy: the declared content type, whether chosen from a list, typed manually or taken from the browser's `Content-Type` header, is validated server-side against a top-level-type allowlist regex (`LEGAL_CONTENT_TYPES`) and cleaned; content sniffing (`File::MimeInfo::Magic`) runs only when the header said `application/octet-stream` and the sniffer feature is installed. No filename-extension check found. The upload sub-check is therefore partial: a validated but client-chosen type, sniffed only in one case; Bugzilla mitigates the consequence by serving attachments from a separate `attachment_base` origin and the safe-response gate above. Role-gated UI vs backend: backend checks exist for every gated action read in this run (see A01); the same per-call-site caveat applies. The category's primary scope (threat modelling, secure SDLC, business-logic abuse cases) is a process control: see "Not checkable from code". |
| TOP10-A07 | Authentication Failures | partial *(throttling carried 2026-09-10; seven sub-checks new)* | `Bugzilla/User.pm:2416-2448`, `Bugzilla/Auth.pm:180-183,196-240`, `Bugzilla/Constants.pm:448-452` (throttling); `Bugzilla/User.pm:2586-2617` and `Bugzilla/Constants.pm:272` (`USER_PASSWORD_MIN_LENGTH => 6`); `Bugzilla/Token.pm:264-290` and `Bugzilla/Util.pm:738-742` (session id); `Bugzilla/Auth/Persist/Cookie.pm:45-51` (new cookie per login); `Bugzilla/Auth/Login/Cookie.pm:87-107` and `Bugzilla/Auth/Persist/Cookie.pm:55-56`, `Bugzilla/Constants.pm:442` (idle expiry); `Bugzilla/Auth/Persist/Cookie.pm:94-107` (logout) | Worst applicable sub-check: password policy. Server-side minimum length is 6, below the 8-character bar; complexity is a configurable param. No breached-password check found by these searches. Throttling passes: failures are recorded per user and per source IP, locked after 5 in 30 minutes, the maintainer is emailed on lockout, and the error message does not distinguish unknown user from wrong password. Session ids: `GenerateUniqueToken` draws them from the ISAAC CSPRNG and checks uniqueness against the table. Fixation: a fresh login cookie row is inserted on every successful login. Idle timeout: `MAX_LOGINCOOKIE_AGE` is 30 days, but the lookup on the request path (`Auth/Login/Cookie.pm:87-94`) does not check `lastused`; expiry is enforced only by the DELETE that runs when some user logs in, so an idle cookie stays valid until then. Logout deletes the server-side row. MFA: no second-factor code found by these searches (no TOTP, WebAuthn or `two_factor` symbol), so that sub-check drops out rather than failing. |
| TOP10-A08 | Software or Data Integrity Failures | partial | `Bugzilla/Constants.pm:210` (`REMOTE_FILE`), `Bugzilla/Update.pm:159-200` (`_synchronize_data`), `Bugzilla/Update.pm:41-57` (parse and surface `url`), `Bugzilla/DB/Schema.pm:3016-3027` (`thaw` / `Safe->reval` on the DB-stored schema), `Bugzilla/WebService/Server/JSONRPC.pm:365-367,417-430` (dispatch table and `PUBLIC_METHODS` allowlist before `eval "package …"`) | No native deserialisation of untrusted input found: the only `thaw`/`reval` is on the schema blob Bugzilla itself wrote to its own database; API bodies are JSON or XML with entity expansion off. Named gap: update notifications are fetched without transport or signature integrity; nothing is auto-applied. The `eval "package $new_class"` in the RPC servers interpolates a class name that comes from a fixed dispatch table and a `PUBLIC_METHODS` allowlist, not from the request. |
| TOP10-A09 | Security Logging and Alerting Failures | partial | `Bugzilla/User.pm:2416-2448` (`login_failure` table), `Bugzilla/Auth.pm:207-235` (lockout email), `Bugzilla/Error.pm:49-84` (`errorlog` only if the file exists and is writable), `Bugzilla/Bug.pm:420-431` (access denied raises with no other log call), `Bugzilla/Object.pm` `audit_log` (admin changes) | Failed logins are recorded structurally (a DB row per failure with user, IP and time) and lockouts are emailed, so the authentication half is covered. Access-control denials (`bug_access_denied`, `product_edit_denied`, …) go through `ThrowUserError`, which writes to `data/errorlog` only when the operator has created that file; by default no log entry results. Log injection: the `errorlog` writer dumps each parameter through `Data::Dumper` with `Useqq`, masks anything named `password`, and truncates at 512 characters, so user values are escaped by construction; the login name and IP appended on lines 59-60 are validated inputs. The admin `audit_log` table is structured. Retention, SIEM forwarding and alerting rules: cannot check from code. |
| TOP10-A10 | Mishandling of Exceptional Conditions | partial | `Bugzilla/Auth.pm:161-183` (auth errors raise, no swallow), `Bugzilla/Bug.pm:372-433` (`check` / `check_is_visible` raise), `Bugzilla/WebService/Bug.pm:405-421` (permissive mode: a failed check becomes a fault, the bug is excluded), `Bugzilla/Error.pm:92-113` (webpage mode prints a template; die mode dies); `Bugzilla.pm:17-23` (`fatalsToBrowser`), `Bugzilla/WebService/Server/REST.pm:384-387` (`err_msg => $@`) | Security checks fail closed: no `eval` wraps a login or permission check in the auth layer (56 `eval {}` sites in `Bugzilla/` enumerated; none around `login`, `can_see_bug`, `check_is_visible` or `check_token_data`), and the one deliberate wrapper, the API's `permissive` bug fetch, turns an error into a per-id fault instead of returning the bug. Remaining gap: raw exception detail reaches clients. Under plain CGI, `CGI::Carp fatalsToBrowser` renders uncaught `die` text with file paths to the browser (same site as A02), and the REST layer echoes the JSON decoder's error string to the caller. Expected errors (`ThrowUserError`/`ThrowCodeError`) go through templates and show only the error tag and its variables. |

## Not checkable from code
- TOP10-A03: whether the versions an installation resolves for `REQUIRED_MODULES` carry open CVEs needs a live OSV/NVD lookup against the deployed module list, not the source tree.
- TOP10-A06: threat modelling, secure-SDLC practice and business-logic abuse-case coverage live in the project's process records (design discussions, review history, security advisories), not in a code signature.
- TOP10-A09: log retention, SIEM forwarding and alerting rules are deployment configuration outside the repository.

## Scope caveat specific to this codebase
Bugzilla is ~290 Perl files. CodeNavi's call graph does not cover Perl (documented: `docs/supported-languages.md`), so call-site enumeration via `grep_search` stands in for caller walks; it is an enumeration, not a proof of reachability. Three rows are carried from the 2026-09-10 run at the same revision (marked above); every other verdict was searched and read in this run.

---
This is a gap analysis produced to prepare for an audit. It is not a
certification, and absence of evidence is not proof of absence.
<!-- report-sha256: 35ed020fd01aff9ed6a86b12c8b00eccdb4ff2571e547c3ca32c918781fbfe13 -->
