Security · Scan report

FixGrid Platform — Security & Readiness Report

Report date: September 10, 2026  |  Scans run: September 9–10, 2026  |  Target: the production build (the tree running on app.fixgrid.app that morning), exercised on a local instance — authenticated as a Company Admin for ZAP, unauthenticated for Nuclei  |  Static scope: 207,175 lines

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.

0
High severity · after triage
(ZAP raised 1 type; confirmed false positive)
4
Medium alert types (ZAP)
accepted trade-offs or local-only
0
Dependency CVEs
pip-audit
0
Real vulnerabilities
Nuclei · 10,133 templates
Overall posture: PASS. Zero High or Critical findings after triage across five tools. ZAP's single High-risk alert type (SQL Injection, 3 instances) is a confirmed false positive of its boolean-comparison heuristic — worked through in full below. The four Medium types are two accepted Content-Security-Policy trade-offs, one local-only artifact of scanning a plain-HTTP development server, and one mixed accepted / false-positive. pip-audit reported no known vulnerabilities, Semgrep reported zero findings, and Bandit's one High is the documented dev-only debug launcher. Nuclei matched 12 informational templates and nothing above.
How to read the counts. Severity counts are alert types, with instance counts (one per URL + parameter) alongside, taken from each tool's own export — not from a summary screen. The underlying exports and evidence records are retained with this round and can be provided with your diligence request via [email protected]. This page reports scanner output; it is not a third-party penetration test — that engagement is sequenced on the Security overview.

Scan 1 — OWASP ZAP (authenticated dynamic scan, DAST)

OWASP ZAPv2.17.0 · Active Scan over the local API · 2026-09-10 · authenticated as Company Admin0 HIGH AFTER TRIAGE

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.

RiskAlert typesInstancesDisposition
HIGH130 after triage. One alert type (SQL Injection), confirmed false positive — see below.
MEDIUM4192 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.
LOW4151 accepted (the same third-party script) · 1 local-only artifact · 2 false positives.
INFORMATIONAL6202Scanner 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.

High — SQL Injection (1 type, 3 instances) · confirmed false positive

SQL Injection HIGH · CONFIDENCE MEDIUM FALSE POSITIVE
Parameters: 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.
Triage — False Positive: this ZAP rule is a boolean-comparison heuristic: it flags a parameter when the "true" payload (' 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.

Medium — 4 alert types, 19 instances

AlertInstancesDisposition
CSP: script-src 'unsafe-inline'5ACCEPTED 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'5ACCEPTED Same trade-off for inline style= attributes; folded into the same tracked hardening item.
HTTP Only Site4LOCAL-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 missing5ACCEPTED + 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.

Low — 4 alert types, 15 instances

AlertInstancesDisposition
Cross-domain JavaScript source file inclusion4ACCEPTED The same Turnstile script, on the same auth pages. Required for the public forms; same disposition as June.
Server leaks version via "Server" header5LOCAL-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 — Unix5FALSE 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 detected1FALSE 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.

Informational — 6 alert types, 202 instances

AlertInstancesDisposition
Information disclosure — suspicious comments115Developer 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 identified72ZAP 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 application5A crawl-strategy notice (the app is JavaScript-driven). No vulnerability.
User agent fuzzer5No differential behaviour under unusual User-Agent strings.
User-controllable HTML attribute (potential XSS)4Hot-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 POST1The 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."

Production header parity

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.

June → September delta

What the next round improves

Scan 2 — Nuclei (known-CVE, exposure and misconfiguration templates)

Nucleiv3.9.0 · nuclei-templates v10.4.8 · 10,133 templates · unauthenticated · 2026-09-090 REAL VULNERABILITIES

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.

Two earlier attempts were discarded, not counted. A default-concurrency run hung the single-threaded development server and skipped the host as "unresponsive" 30 times — it scanned nothing. A second attempt was stopped when the application was found down mid-scan. A run that skips its target reads as clean; it is not. Only the throttled run above is reported.

Result: 12 matches, all Informational. Zero Low, Medium, High or Critical.

#TemplateSeverityDisposition
1options-methodINFOGET, OPTIONS, HEAD on /. Framework default; expected.
2robots-txtINFO/robots.txt exists. Intentional.
3missing-cookie-samesite-strictINFODeliberate. 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–5tech-detect:python (×2)INFOFingerprinted the application as Python. Expected; no version disclosed.
6weak-csp-detect:unsafe-script-srcINFOThe same accepted CSP 'unsafe-inline' trade-off ZAP reports above; the nonce refactor is the tracked hardening step.
7form-detectionINFOLogin form detected at /auth/login. Expected.
8missing-sriINFOThe Cloudflare Turnstile script — accepted, same class as ZAP's SRI Medium.
9robots-txt-endpointINFOrobots.txt lists the login and signup paths. Intentional public paths; no sensitive disclosure.
10http-missing-security-headers:strict-transport-securityINFOLocal-only. The application sets HSTS only on secure requests, by design; production sends it (confirmed live 2026-09-10).
11http-missing-security-headers:x-permitted-cross-domain-policiesINFOLegacy Flash/PDF cross-domain header. Low value on a modern application; optional, unchanged from June.
12http-missing-security-headers:cross-origin-embedder-policyINFODeliberately 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.

Scan 3 — Bandit (static application security testing)

BanditPython SAST · 2026-09-09 · against the pushed working treeMEDIUM 0 · HIGH 1 ACCEPTED

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.)

SeverityCountStatus
High1ACCEPTED — documented below
Medium0CLEAR
Low228Below the round's gate The run reports Medium and above (-ll); the Low total is the metrics summary. None rose to the gate.
B201 HIGH ACCEPTED · DEV-ONLY app.run(debug=True)
Location: the local-run launcher at the bottom of app.py.
Triage — Accepted: the flagged line is the development launcher, executed only when the file is run directly on a developer machine. Production does not start the application through this line. It is the same single accepted High carried by every prior scan round, and it is documented rather than suppressed so the count stays honest.

Scan 4 — pip-audit (dependency CVE scan)

pip-auditPyPA Advisory Database / OSV.dev · 2026-09-09NO KNOWN VULNERABILITIES

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.

On Safety: the May 2026 archive listed Safety CLI as a second advisory source and recorded that its scan could not complete without an account. Safety was not part of this round. pip-audit is the dependency scanner of record; no claim is made for a second database.

Scan 5 — Semgrep (SAST, CI's exact configuration)

SemgrepDocker semgrep/semgrep · CI flags · 2026-09-090 FINDINGS

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.

Scan methodology & cadence

ToolTypeCadenceSource
BanditStatic analysis (SAST)Before every push to production · monthly full-toolchain roundCWE / Bandit ruleset
pip-auditDependency CVE scanBefore every push to production · monthly full-toolchain roundPyPA Advisory DB / OSV.dev
SemgrepSAST (Python / Flask rules)GitHub Actions on every push and pull request (--error gate) · monthly round in DockerSemgrep registry (--config=auto)
OWASP ZAPDynamic scan (DAST), authenticatedMonthly · this round 2026-09-10OWASP attack signatures
NucleiKnown-CVE / exposure / misconfiguration templatesMonthly, alongside ZAP · this round 2026-09-09ProjectDiscovery 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.

Security controls summary

ControlImplementationStatus
Tenant isolationEvery query is scoped to the company at the database-query level. No post-fetch access checks.ACTIVE
AuthenticationServer-side sessions, bcrypt password hashing, invite-based user creation. Multi-factor — passkeys / WebAuthn and email codes — enforceable for staff.ACTIVE
CSRF protectionGlobal 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 cookieSecure; HttpOnly; SameSite=Lax — confirmed live 2026-09-10. No cookie-flag alert fired in either scanner.VERIFIED
Security headersCSP (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 limitingPublic endpoints and login are rate-limited (login answered the scanner with 429 mid-run). The v1 API is limited per key.ACTIVE
Transport securityHTTPS enforced at the Cloudflare edge with HSTS; assets served from a CDN-backed custom domain.ACTIVE
Dependency & code scanningpip-audit: no known vulnerabilities (2026-09-09). Semgrep: 0 findings, and gating every push in CI. Bandit: Medium 0.CURRENT
Secret managementAPI keys and credentials via environment variables only; no hardcoded secrets in the codebase.ACTIVE
Permission modelRole-based permissions with per-company override matrix; module-level feature flags enforced at the route level. Every authenticated route is permission-gated.ACTIVE
Audit logAppend-only security audit log across nine event categories — authentication, MFA, authorization, user lifecycle, admin actions, billing, webhooks, security, data exports.ACTIVE
Prepared from the September 2026 monthly full-toolchain security round · FixGrid Platform · September 10, 2026
OWASP ZAP 2.17.0 · Nuclei 3.9.0 (nuclei-templates 10.4.8) · Bandit · pip-audit · Semgrep (289 rules) · Python 3.12 · Werkzeug 3.1.8
Previous point-in-time report: May 1, 2026 (archived)  ·  Controls and roadmap: Security overview  ·  Questions: [email protected]