The September 2026 monthly full-toolchain round: an authenticated OWASP ZAP active scan, a 10,133-template Nuclei sweep, and the static half — Bandit, pip-audit and Semgrep. Every finding below is triaged to one of four dispositions: fixed, false positive, accepted trade-off (documented), or local-only artifact (cannot occur in production). Nothing is omitted, including the one High-risk alert ZAP raised. For the controls themselves, start at the Security overview.
ZAP actively attacks a running application — injection payloads, header and cookie checks, redirect and disclosure probes — against every URL and parameter it can reach. This round was run behind login, as a Company Admin, so the scan exercised the tenant-scoped surfaces a signed-in customer actually uses, not just the public login page. The founder was at the keyboard for login and the crawl; the active scan itself was driven over ZAP's local API against a local instance of the production build, throttled to 2 threads with a 50 ms delay for the single-threaded development server.
Coverage: the spider found 228 URLs in 20 seconds; 160 distinct application paths were scanned across the main run plus a four-path supplemental pass. That included 40 paths under the company-admin console; the Construction Handover surfaces (engagement, first-occupancy, photos, imports and the Procore connection page); Unit Acceptance (list and launch); Integrations and the Developers API-key page; technician tickets; inspections, including a specific inspection's execution page; and reports. Reaching specific numbered tenant records — a ticket, a handover engagement, an inspection — is the evidence that the session was real: an unauthenticated run sees only login, signup, forgot-password, robots and sitemap.
Excluded by design: logout, the MFA verification path (an active scan would either lock the session out or exercise a code brute-force path that is neither useful nor safe to fuzz), account settings, and the public form endpoints (Cloudflare Turnstile was switched off locally so the intercepting proxy could work, so those routes were kept out of scope to avoid sending real demo-request email). Third-party hosts that appear in browser traffic (Cloudflare, Google) were excluded as noise. Not exercised, honestly: the Procore import-review page, which needs a completed import run that the local database did not hold. It is recorded as a gap for the next round.
The login rate limiter answered the scanner with 429 Too Many Requests mid-run — the limiter working as designed under a fast scripted client, not an outage.
| Risk | Alert types | Instances | Disposition |
|---|---|---|---|
| HIGH | 1 | 3 | 0 after triage. One alert type (SQL Injection), confirmed false positive — see below. |
| MEDIUM | 4 | 19 | 2 accepted architectural trade-offs (CSP) · 1 local-only artifact (no TLS on the dev server) · 1 mixed: accepted third-party script + a false positive against a CSS comment. |
| LOW | 4 | 15 | 1 accepted (the same third-party script) · 1 local-only artifact · 2 false positives. |
| INFORMATIONAL | 6 | 202 | Scanner notices and developer comments; one XSS hot-spot confirmed non-exploitable. |
Fifteen alert types, 239 instances, from the JSON export's own per-alert count fields, cross-checked against the instance lists. ZAP's live summary API reads higher raw totals during a run because it counts every repeat of the same finding across query variants; the type counts are identical either way and are the numbers this report stands on.
month and year on the on-call calendar route (two instances); property_id on the unit-status transitions route (one instance). The evidence field is empty on all three — no matched database error, no leaked row.' AND '1'='1' --) and a control payload return identical responses. Both routes integer-coerce these query parameters before anything reaches the database. The calendar route wraps int() in a try/except that falls back to the current year and month on any non-numeric input, then range-checks and falls back again on an out-of-range value; the transitions route reads property_id with Flask's type=int, which returns None for any non-integer string. Every payload therefore collapses to the same coerced value and the same page — which is exactly what the differential test reads as "no difference." Both route files were searched for raw SQL (.execute(, text(, from sqlalchemy import text): zero hits. Every query path in both is ORM-bound. Disposition: false positive, no code change. Effective High count for this round: 0.| Alert | Instances | Disposition |
|---|---|---|
| CSP: script-src 'unsafe-inline' | 5 | ACCEPTED Unchanged from the June round. The application ships a large number of inline scripts and inline event handlers across its templates, and a script nonce is all-or-nothing — adding one makes browsers ignore 'unsafe-inline' and breaks every inline handler at once. The nonce refactor is tracked as a documented hardening reference; the rest of the policy is strict (default-src 'self', frame-ancestors 'none', object-src 'none', base-uri 'self', allow-listed script and frame hosts, form-action limited to the app's own origin, Stripe Checkout and the integration-provider connection endpoints). |
| CSP: style-src 'unsafe-inline' | 5 | ACCEPTED Same trade-off for inline style= attributes; folded into the same tracked hardening item. |
| HTTP Only Site | 4 | LOCAL-ONLY New alert type in ZAP 2.17.0. ZAP's own note reads "Failed to connect … via https://localhost:5000" — the local development server has no TLS listener, so the HTTPS probe could not connect. Production is HTTPS-only behind Cloudflare with HSTS (max-age=15552000; includeSubDomains), confirmed live the same morning. This alert cannot fire against production. |
| Sub Resource Integrity attribute missing | 5 | ACCEPTED + FALSE POSITIVE Three instances are the Cloudflare Turnstile script (challenges.cloudflare.com) on the login and forgot-password pages. Cloudflare serves it as a rotating file, so it cannot be pinned with a static integrity hash without breaking on rotation — accepted, and allow-listed in CSP. Two instances are a confirmed false positive: ZAP's SRI regex matched a usage-example <link> inside a stylesheet's own header comment, not a tag the server actually renders. |
| Alert | Instances | Disposition |
|---|---|---|
| Cross-domain JavaScript source file inclusion | 4 | ACCEPTED The same Turnstile script, on the same auth pages. Required for the public forms; same disposition as June. |
| Server leaks version via "Server" header | 5 | LOCAL-ONLY The local development server advertises Werkzeug/3.1.8 Python/3.12.10. Production answers Server: cloudflare for the identical request. No action on production. |
| Timestamp disclosure — Unix | 5 | FALSE POSITIVE The matched value is the static-asset cache-busting ?v= stamp set at boot — the same value on every CSS URL — not a timestamp of any sensitive event. Same class as June. |
| Big redirect detected | 1 | FALSE POSITIVE One probe hit a protected route without a valid session and was 302-redirected to login with a next= parameter, as designed. The 425-byte body is the framework's standard redirect stub, sized by the long URL the scanner itself constructed. No application data rides in it. |
| Alert | Instances | Disposition |
|---|---|---|
| Information disclosure — suspicious comments | 115 | Developer comments in HTML and JavaScript (implementation notes, TODOs) across 53 paths. Not secrets, credentials or tokens. The count scales with crawl breadth — June's smaller crawl produced 6. |
| Session management response identified | 72 | ZAP identifying the session cookie across every authenticated page it reached. No missing-HttpOnly or missing-Secure alert fired anywhere in the set; the live cookie carries Secure; HttpOnly; SameSite=Lax. |
| Modern web application | 5 | A crawl-strategy notice (the app is JavaScript-driven). No vulnerability. |
| User agent fuzzer | 5 | No differential behaviour under unusual User-Agent strings. |
| User-controllable HTML attribute (potential XSS) | 4 | Hot-spot detection only — ZAP's own text says it "requires further review." Spot-checked: the reflected tab value is rendered through Jinja auto-escaping, which is on by default and unmodified in this application, so ", <, > and & are entity-escaped before they reach the attribute. Non-exploitable. |
| GET for POST | 1 | The login route renders on GET and processes credentials only on POST, from the form body — a GET carrying identifier/password in the query string falls through to the plain login form untouched. ZAP's own text: this "does not represent a security weakness unto itself." |
Because the scan ran against a plain-HTTP local instance, the security-header set was confirmed against production the same morning (2026-09-10, https://app.fixgrid.app/auth/login): Strict-Transport-Security, Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, and the same Content-Security-Policy directive set ZAP captured locally. Every header is emitted by a single after-request hook that runs on every response in both environments; the application adds HSTS only on secure requests, which is why the local capture lacked it and production carries it. The only other difference is the Server value (Cloudflare edge versus the development server). Both "local dev server" findings above are therefore genuinely local-only, not gaps in what production serves.
Server header).Server version leak stop appearing as local-only artifacts and the record shows only findings that could theoretically reach production.Nuclei is ProjectDiscovery's template-driven scanner: known CVEs, exposed files and backups, misconfigurations, exposed admin panels, default credentials and technology fingerprints. It fills the "is anything exposed, or running a known-vulnerable component" lane that broad DAST and static analysis don't focus on. Against a custom Flask application most templates will not match by design — the value is the absence of exposure findings across roughly ten thousand checks. Denial-of-service, intrusive and fuzzing template classes were excluded.
Run of record: concurrency 2, rate limit 5 requests/second, 20-second timeout, one retry. 16,743 of 16,853 planned requests completed (99%); the 232 errors are request timeouts against the throttle, not findings — no template that errored produced a partial or ambiguous match. A health probe against the application read 200 every 45 seconds throughout the 65-minute run.
Result: 12 matches, all Informational. Zero Low, Medium, High or Critical.
| # | Template | Severity | Disposition |
|---|---|---|---|
| 1 | options-method | INFO | GET, OPTIONS, HEAD on /. Framework default; expected. |
| 2 | robots-txt | INFO | /robots.txt exists. Intentional. |
| 3 | missing-cookie-samesite-strict | INFO | Deliberate. The session cookie is SameSite=Lax + HttpOnly by explicit configuration. The app's own forms hand off to Stripe Checkout and to integration-provider connection flows that return via a top-level cross-site navigation; Strict would drop the session on that return and log the user out mid-flow. Lax still blocks the cookie on cross-site POSTs and embeds. |
| 4–5 | tech-detect:python (×2) | INFO | Fingerprinted the application as Python. Expected; no version disclosed. |
| 6 | weak-csp-detect:unsafe-script-src | INFO | The same accepted CSP 'unsafe-inline' trade-off ZAP reports above; the nonce refactor is the tracked hardening step. |
| 7 | form-detection | INFO | Login form detected at /auth/login. Expected. |
| 8 | missing-sri | INFO | The Cloudflare Turnstile script — accepted, same class as ZAP's SRI Medium. |
| 9 | robots-txt-endpoint | INFO | robots.txt lists the login and signup paths. Intentional public paths; no sensitive disclosure. |
| 10 | http-missing-security-headers:strict-transport-security | INFO | Local-only. The application sets HSTS only on secure requests, by design; production sends it (confirmed live 2026-09-10). |
| 11 | http-missing-security-headers:x-permitted-cross-domain-policies | INFO | Legacy Flash/PDF cross-domain header. Low value on a modern application; optional, unchanged from June. |
| 12 | http-missing-security-headers:cross-origin-embedder-policy | INFO | Deliberately not set. COEP would block the asset CDN and the Turnstile cross-origin embeds. Evaluated and declined, unchanged from June. |
The June finding did not recur. The 2026-06-21 Nuclei baseline flagged a missing Cross-Origin-Opener-Policy; the header was added the same day and is set unconditionally on every response. None of this round's 12 matches involve COOP, confirming the fix is still in effect. Consistent with June overall: 10–12 informational matches and zero real findings across both runs.
Bandit performs static analysis on Python source, flagging hardcoded credentials, injection risks, weak cryptography and unsafe calls.
Command: bandit -r routes services models utils app.py config.py -q -ll
Scope: 207,175 lines across routes/, services/, models/, utils/, app.py and config.py. (The May 1, 2026 archive scanned 25,252 lines across four packages; the application and its scan scope have both grown since.)
| Severity | Count | Status |
|---|---|---|
| High | 1 | ACCEPTED — documented below |
| Medium | 0 | CLEAR |
| Low | 228 | Below the round's gate The run reports Medium and above (-ll); the Low total is the metrics summary. None rose to the gate. |
app.run(debug=True)app.py.pip-audit resolves every dependency declared in requirements.txt against the PyPA advisory database and OSV.dev.
Command: pip-audit -r requirements.txt | Result, verbatim: No known vulnerabilities found.
Semgrep ran with the continuous-integration job's exact configuration — --config=auto --error, one SQLAlchemy performance-hint rule excluded, no timeout — over routes/, models/, services/ and utils/.
Result, verbatim: Ran 289 rules on 458 files: 0 findings, exit code 0.
The same Semgrep job runs in GitHub Actions on every push to the main branch and on every pull request, with --error so that any finding fails the job. This is the CI security gate referenced on the Security overview; it is live, not planned.
| Tool | Type | Cadence | Source |
|---|---|---|---|
| Bandit | Static analysis (SAST) | Before every push to production · monthly full-toolchain round | CWE / Bandit ruleset |
| pip-audit | Dependency CVE scan | Before every push to production · monthly full-toolchain round | PyPA Advisory DB / OSV.dev |
| Semgrep | SAST (Python / Flask rules) | GitHub Actions on every push and pull request (--error gate) · monthly round in Docker | Semgrep registry (--config=auto) |
| OWASP ZAP | Dynamic scan (DAST), authenticated | Monthly · this round 2026-09-10 | OWASP attack signatures |
| Nuclei | Known-CVE / exposure / misconfiguration templates | Monthly, alongside ZAP · this round 2026-09-09 | ProjectDiscovery nuclei-templates |
Every round produces a dated evidence record per tool, with raw exports retained. Findings are dispositioned as fixed, false positive, accepted trade-off (with the reasoning written down), or local-only artifact. Accepted trade-offs are re-stated in each round rather than filtered out, so the count a reader sees is the count the tool produced.
| Control | Implementation | Status |
|---|---|---|
| Tenant isolation | Every query is scoped to the company at the database-query level. No post-fetch access checks. | ACTIVE |
| Authentication | Server-side sessions, bcrypt password hashing, invite-based user creation. Multi-factor — passkeys / WebAuthn and email codes — enforceable for staff. | ACTIVE |
| CSRF protection | Global CSRF protection; an explicit token on every POST form and AJAX request. (Visible in this round's evidence: the scanner's captured login form carries the token.) | ACTIVE |
| Session cookie | Secure; HttpOnly; SameSite=Lax — confirmed live 2026-09-10. No cookie-flag alert fired in either scanner. | VERIFIED |
| Security headers | CSP (default-src 'self', frame-ancestors 'none', object-src 'none', base-uri 'self'), HSTS, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy — one after-request hook on every response. Confirmed live 2026-09-10. | VERIFIED |
| Rate limiting | Public endpoints and login are rate-limited (login answered the scanner with 429 mid-run). The v1 API is limited per key. | ACTIVE |
| Transport security | HTTPS enforced at the Cloudflare edge with HSTS; assets served from a CDN-backed custom domain. | ACTIVE |
| Dependency & code scanning | pip-audit: no known vulnerabilities (2026-09-09). Semgrep: 0 findings, and gating every push in CI. Bandit: Medium 0. | CURRENT |
| Secret management | API keys and credentials via environment variables only; no hardcoded secrets in the codebase. | ACTIVE |
| Permission model | Role-based permissions with per-company override matrix; module-level feature flags enforced at the route level. Every authenticated route is permission-gated. | ACTIVE |
| Audit log | Append-only security audit log across nine event categories — authentication, MFA, authorization, user lifecycle, admin actions, billing, webhooks, security, data exports. | ACTIVE |