fix(recon): 3 regex/logic correctness bugs on the run path (+ review report)#8
Merged
Merged
Conversation
- routes (flask fallback): a greedy DOTALL `methods=` group reached across the whole file, mis-assigning the LAST methods=[...] to the FIRST @route and swallowing every route in between (only routes after the final methods=[...] survived). Constrain the optional group to the single route() call. - policy_consistency: under re.I the literal `A-Z`/`a-z` range matched BOTH cases, so a lowercase-only password rule was mis-counted as ALSO requiring uppercase — masking real cross-route drift (a security false-negative). Make the range token case-sensitive via (?-i:...); keyword branches keep re.I. - iac_ci (gha-script-injection): the finding detail double-prefixed `github.` onto an already-complete `${{ github.… }}` context string. Detail-only. Each fix ships a regression test that fails pre-fix and passes post-fix. Full suite 198/198 green; stdlib-only, no new deps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
raccioly
added a commit
that referenced
this pull request
Jun 23, 2026
…acklog) Clears the "Pending — needs decision" findings from the PR #8 review. Scanner fixes are on the --scan path; dynamic fixes are the opt-in live phase. 203 tests (5 new); Checkov fix verified live (3 findings flow where 0 did). scanners.py: - Checkov was detected + executed but 100% of its output was discarded (wrong output path, no parser, no count branch). Added _norm_checkov + registered it + count branch + normalize the produced results_json.json → <key>.json. - Secret de-dup fingerprint now includes the line (trivy + gitleaks), so two DISTINCT secrets matched by the same rule in one file no longer collapse to one (hiding the second is the worst FN for a secret scanner). - _gitignored normalizes absolute paths (trivy fs) to repo-relative for `git check-ignore`, which previously matched nothing → the gitignored-secret downgrade was a silent no-op. dynamic.py: - _first_tenant() coerces a scalar tenant field to a list — mint() no longer crashes (`5[0]` TypeError) on a singular `groupId: 5` and no longer yields a single-char tenant for a scalar string. - _no_records() tests cross-tenant response emptiness STRUCTURALLY (parse JSON) instead of a 3-element string allowlist that misclassified common empty wrappers ({"items":[]}, whitespace, paginated) as LEAKs — conservative, never masks a leak. Docs: test count synced to 203 (TEST-SPEC map, ENVIRONMENT, README). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Installed
websec-validatorfrom a clean clone and exercised the whole surface (editable install, byte-compile,--help/--version,doctor,websec runon both fixtures, andrun --scanwith the real scanners shelling out). The install/build/test path is healthy — all existing tests pass on Python 3.12 and the live pipeline produces a briefing without crashing.So this is not a "make it build" PR. It's a focused correctness pass: a read-only review of the ~6.5k LOC of package code turned up three real bugs on the always-on
websec runpath, each empirically reproduced. This PR fixes exactly those three, each with a regression test that fails before the fix and passes after. Nothing else is touched.Verification:
python -m unittest discover -s tests→ 198/198 OK ·compileallclean ·ruffintroduces no new violations · livewebsec runsmoke OK. Stdlib-only, zero new dependencies.Fixed in this PR (3 — tested, minimal)
1. Flask route fallback dropped routes and mislabeled methods —
extractors/routes.pyThe regex-fallback (used when OWASP Noir is absent) for
@app.route(...)used a greedy.*withre.S, so the optionalmethods=[...]group reached across the whole file to the lastmethods=and back-tracked. Net effect on a multi-route module:findallreturned[('/a', '"POST"'), ('/c', '')]—/bvanished and/astole its verb. Since this is the Noir-absent path, a Flask app reviewed without Noir got a corrupted route map (→ wrong authz coverage, wrong probe targeting). Fix: constrain the optional group to the singleroute(...)call with[^)]*?and dropre.S. Now returns all three routes with correct verbs. (test:test_fallback_flask_multiroute_no_route_dropped)2. Password-policy drift detector masked lowercase-only regressions —
extractors/policy_consistency.py_RE_UPPER/_RE_LOWERare compiled withre.I, but their character-class branch is a literalA-Z/a-z. Underre.Ithe literalA-Zalso matchesa-z, so a lowercase-only rule(?=.*[a-z])was counted as also requiring uppercase. Consequence: a weak sibling route requiring only lowercase computed the same class-set as a strong upper+lower policy, so the strict-subset drift comparison saw them as equal and reported no drift — a security false-negative in the detector whose entire job is catching that regression. Fix: make just the range token case-sensitive with(?-i:A-Z)/(?-i:a-z); themin*/require*keyword branches keepre.I. (test:test_lowercase_only_subset_is_drift)3. GHA-injection finding detail double-prefixed
github.—extractors/iac_ci.pyUNTRUSTED.findall(...)returns the whole${{ github.… }}string, but the detail was built as"github." + c, emitting the malformedgithub.${{ github.event.pull_request.title }}. Detail-string only — detection, severity, and the SHA-context check were unaffected — but it's user-facing output handed to the agent. Fix: drop the"github." +prefix. (assertion added totest_gha_injection_in_run_flagged)Pending — needs your decision (NOT changed here)
Real findings I left alone because each is a product/behavior call rather than an obvious correctness fix. Ranked by impact; proposed fix included for each.
P1 · Checkov runs but 100% of its findings are silently discarded —
scanners.py_checkovwrites with--output-file-path <out.parent>(checkov names the fileresults_json.jsonitself), so thescanners/checkov.jsonpath the tool records never exists;_count_findingshas nocheckovbranch (always returns 0); and_PARSERShas no checkov parser, so even if the file were found, nothing normalizes it intofindings.json. Checkov is detected, executed, and its entire output is dropped. Why deferred: a real fix needs a_norm_checkov(mapping checkov'sfailed_checks[]), a severity default (checkov severities are often null off-platform), and a dedup scheme vs Trivy's overlapping misconfig findings — i.e. a noise-vs-coverage decision. Proposed: readresults_json.json, add_norm_checkov+ a_count_findingsbranch; you pick the severity default + dedup key.P2 · Secret de-dup can hide a second real secret —
scanners.py:312,335Secret fingerprints are
secret|file|rule(no line), so two distinct secrets matched by the same rule in one file collapse to one row (the second's line/title is lost). Contrast_norm_semgrep, which includes line. Why deferred: adding line could reduce legitimate cross-tool merges (gitleaks reads git history lines, trivy reads working-tree lines — they may differ). Proposed: addStartLineto the secret fingerprint (safe direction for a security tool: never hide a distinct secret; accept rare cross-tool duplicates).P3 · gitignored-secret downgrade is probably a no-op —
scanners.py:357-371,402-408_gitignoredpipes Trivy'sfilestrings togit -C <target> check-ignore --stdinand matches the echoed paths againstf["file"].git check-ignoreechoes back the exact input string, and expects repo-relative paths — buttrivy fs <abs target>typically emits absolute/root-prefixed paths, so the set comes back empty and the bug-066(b) downgrade does nothing (gitignored.env.localsecrets stay CRITICAL). Why deferred: needs confirming exactly what Trivy emits in this setup before changing path handling. Proposed: normalize both sides viaos.path.relpath(file, target)before comparing.P4 ·
websec dynamic/ calibration robustness (advanced opt-in phase)mint()crashes on a scalar tenant field —dynamic.py:76-77:tenants = user.get(field) or []; tenants[0]. A singulargroupId: 5(int) →5[0]TypeError(uncaught); a string → silently-wrong single-char tenant. Singular tenant ids are common. Proposed: coerce to a list before indexing.dynamic.py:115string-matches a 3-element empty-body allowlist ([],{},{"data":[]}). Common empty wrappers ({"items":[]},{ "data": [] }, paginated) get misclassified as LEAK. Proposed: parse JSON and test emptiness structurally.cli.py:218+calibration.samples_from_dynamic: write-auth verdicts are folded into the persistent local overlay (~/.cache/...) with no check onfail_open_suspected, even thoughfindings.build_ledgerdeliberately distrusts those same verdicts in a fail-open env. One fail-open run permanently skews futureP(real). Proposed: skip the write-auth samples when fail-open is suspected (BOLA leaks can stay).calibrate --ingestcrashes on afindings-less JSON object —cli.py:264:rows = data.get("findings", data)leavesrowsas the dict, thenfor r in rowsyields keys (str) →AttributeError. Proposed:data.get("findings", [])+ skip non-dict rows.P5 · Minor / hardening
scanners.py:420,426useSEV_ORDER[...]while:408uses.get(...,0). No trigger today (all parsers clamp), latentKeyError. → use.get(..., 1).urlopenhas no scheme guard —dynamic.py:49: afile:///internaltargetwould be followed. Low risk under the self-scan threat model. → asserthttp(s).latestsymlink replace is non-atomic — a crash betweenunlinkandsymlink_toleaves nolatest(nextdynamiccan't findFACTS.json). → temp-symlink +os.replace.code_pathagainst CWD —authz.py: heuristic/fallback routes setcode_path=rel;ctx.text(Path(cp))resolves vs CWD not root, so the handler text is often empty and the endpoint is bucketedunknown(precision loss, Noir-absent only). → resolve relative paths againstctx.root.routes.py:92proc = subprocess.run(...)is never used (lint only).Checked and found NOT to be bugs (diligence notes)
is_localhost(dynamic.py:59) correctly usesurlparse(...).hostname+ an exact-match loopback set —http://localhost@evil.com/resolves toevil.comand is rejected, so the--probe-writeslocalhost guard is sound (DNS-rebinding aside, out of scope for a self-targeting tool).shell=True), so an adversarial repo path can't inject a command.calibration.pywas verified numerically against the shippedcalibration.json(exact match) — no change needed.🤖 Generated with Claude Code