diff --git a/AGENTS.md b/AGENTS.md index e5c3e6f..625b8bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ pass. Pure-Python, **stdlib only, zero runtime dependencies**; it shells out to | Command | Purpose | |---------|---------| | `pipx install --editable .` | Install the CLI from source (or `pip install -e .` in a 3.11+ venv) | -| `python3 -m unittest discover -s tests` | Run the suite (136 tests, stdlib only, no network) | +| `python3 -m unittest discover -s tests` | Run the suite (139 tests, stdlib only, no network) | | `websec run ./target` | Full pipeline → `FACTS.json` + `AGENT-BRIEFING.md` + `probes/` | | `websec doctor ./target` | Show which optional scanners are installed | | `websec proof` | Score recon coverage vs the vuln-app corpus (needs network on first clone) | @@ -54,7 +54,7 @@ docguard diagnose # guard → emit AI fix prompts 1. **Before any work**: read `docs-canonical/` and run `docguard guard` to see the compliance state. 2. **After changing code or docs**: re-run `docguard guard`; keep the numbers (16 extractors, 15 sink - classes, 136 tests, 10/10 proof) consistent across every doc — DocGuard's metrics-consistency + classes, 139 tests, 10/10 proof) consistent across every doc — DocGuard's metrics-consistency validator cross-checks them. 3. **Update `CHANGELOG.md`** for any user-visible change. 4. **Document drift**: if code must deviate from a canonical doc, add a `// DRIFT: reason` (or diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b727f1..8508266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Removed +## [0.6.2] — 2026-06-12 + +### Added +- **Report-the-passes for cookies (P3)** — `transport_security` now reports a cookie-hardening PASS + (`HttpOnly + Secure + SameSite` present → ✓, surfaced in the briefing's §3c "report-the-pass / gap" + line) and flags the gap as a new `insecure-cookie` finding (CWE-1004/614) when a flag is missing. + Saying "checked ✓" builds trust and turns the control into a regression assertion. + ## [0.6.1] — 2026-06-12 Precision fixes found by re-running 0.6.0 on the same Cloudflare Worker. diff --git a/README.md b/README.md index b6e7235..47e496b 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ upload, cross-tenant BOLA, role/authz gaps). ## Tests ```bash -python3 -m unittest discover -s tests # stdlib only, no Noir/network — 136 tests +python3 -m unittest discover -s tests # stdlib only, no Noir/network — 139 tests ``` ## Releasing (maintainer) diff --git a/docs-canonical/ENVIRONMENT.md b/docs-canonical/ENVIRONMENT.md index bd4eee6..2f2dbea 100644 --- a/docs-canonical/ENVIRONMENT.md +++ b/docs-canonical/ENVIRONMENT.md @@ -96,6 +96,6 @@ Never point the dynamic phase at production. ```bash git clone https://github.com/raccioly/websec-validator && cd websec-validator pipx install --editable . # or: pip install -e . in a 3.11+ venv -python3 -m unittest discover -s tests # 136 tests, stdlib only +python3 -m unittest discover -s tests # 139 tests, stdlib only docguard guard # validate the documentation (CDD) ``` diff --git a/docs-canonical/TEST-SPEC.md b/docs-canonical/TEST-SPEC.md index 78e9eef..004fbcf 100644 --- a/docs-canonical/TEST-SPEC.md +++ b/docs-canonical/TEST-SPEC.md @@ -10,11 +10,11 @@ > Last updated: 2026-06-10 The suite is **stdlib `unittest` only** — no third-party test runner, no network, no Noir, no running -app. **136 tests** across three files run in ~3s and gate every release (the `publish.yml` workflow +app. **139 tests** across three files run in ~3s and gate every release (the `publish.yml` workflow also installs the built wheel and smoke-runs `websec run`). ```bash -python3 -m unittest discover -s tests # 136 tests, stdlib only +python3 -m unittest discover -s tests # 139 tests, stdlib only ``` --- diff --git a/pyproject.toml b/pyproject.toml index 43fdd06..471f374 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "websec-validator" -version = "0.6.1" +version = "0.6.2" description = "Local-first security recon that briefs your AI coding agent: facts + tailored probe scripts, code-in / artifacts-out. No LLM, no server, no running app." readme = "README.md" requires-python = ">=3.11" diff --git a/src/websec_validator/briefing.py b/src/websec_validator/briefing.py index 94706eb..6776d31 100644 --- a/src/websec_validator/briefing.py +++ b/src/websec_validator/briefing.py @@ -83,6 +83,15 @@ def render(facts: dict, scanners: dict, scan_results: list, probe_manifest: list pii_section = ("\n".join(f"- **{f.get('severity')}** {f.get('kind')} — `{f.get('file')}`" for f in pii_findings[:20]) if pii_findings else "_no obvious raw-PII responses / dead masking controls_") ws_line = (facts.get("client_integrity", {}) or {}).get("websocket_auth", "no websocket detected") + _cs = (facts.get("transport_security", {}) or {}).get("cookie_security") + if _cs: + if _cs.get("httponly") and _cs.get("secure") and _cs.get("samesite"): + cookie_line = "✓ HttpOnly + Secure + SameSite present (checked — verify against the live Set-Cookie)" + else: + _miss = [n for n, k in (("HttpOnly", "httponly"), ("Secure", "secure"), ("SameSite", "samesite")) if not _cs.get(k)] + cookie_line = f"⚠ cookie set WITHOUT {', '.join(_miss)} — an auth/session cookie should be HttpOnly + Secure + SameSite" + else: + cookie_line = "_no Set-Cookie detected_" gql = facts.get("graphql", {}) if gql.get("present"): @@ -205,6 +214,8 @@ def render(facts: dict, scanners: dict, scan_results: list, probe_manifest: list **WebSocket auth model (CSWSH determinant — is it an ambient cookie?):** {ws_line} +**Cookie hardening (report-the-pass / gap):** {cookie_line} + **File-upload security (#2b — sniff bytes, derive stored name, nosniff on serve):** {up_section} diff --git a/src/websec_validator/extractors/transport_security.py b/src/websec_validator/extractors/transport_security.py index 61af7ce..2895220 100644 --- a/src/websec_validator/extractors/transport_security.py +++ b/src/websec_validator/extractors/transport_security.py @@ -41,6 +41,12 @@ # applies even with no frontend framework. This is the gap that missed a Cloudflare Worker's CSP. HTML_CONTENT = re.compile(r"]|text/html|res\.send\(\s*[`'\"]\s*<|c\.html\(", re.I) FRONTEND_FW = {"react", "next", "nextjs", "vue", "nuxt", "svelte", "sveltekit", "angular", "astro", "remix", "solid"} +# Cookie hardening — "report the PASS" (HttpOnly+Secure+SameSite ✓ builds trust + is a regression +# assertion) and flag the gap. Flags are matched per cookie-setting file (lenient — a positive lead). +SET_COOKIE = re.compile(r"set-?cookie|res\.cookie\(|cookies\.set\(|\.setCookie\(|c\.cookie\(", re.I) +CK_HTTPONLY = re.compile(r"httponly", re.I) +CK_SECURE = re.compile(r";\s*secure\b|\bsecure\s*[:=]\s*true|\bsecure\s*:\s*!", re.I) +CK_SAMESITE = re.compile(r"samesite", re.I) class TransportSecurityExtractor(Extractor): @@ -55,6 +61,7 @@ def extract(self, ctx: RepoContext, facts: dict) -> dict: hsts_present = hsts_sub = hsts_preload = False html_surface = bool(frameworks & FRONTEND_FW) inline_handlers = [] + sets_cookie = ck_httponly = ck_secure = ck_samesite = False hsts_files, hsts_api_only, hsts_html = [], True, False # config manifests carry headers too (next.config, vercel.json, netlify.toml, _headers) @@ -87,6 +94,11 @@ def extract(self, ctx: RepoContext, facts: dict) -> dict: hsts_html = True elif not API_SCOPED.search(rel): hsts_api_only = False # a non-API, non-HTML place (e.g. global edge middleware) + if SET_COOKIE.search(blob): + sets_cookie = True + ck_httponly = ck_httponly or bool(CK_HTTPONLY.search(blob)) + ck_secure = ck_secure or bool(CK_SECURE.search(blob)) + ck_samesite = ck_samesite or bool(CK_SAMESITE.search(blob)) if CSP_ANY.search(manifests): csp_present = True @@ -155,12 +167,28 @@ def extract(self, ctx: RepoContext, facts: dict) -> dict: "detail": f"HSTS is present but missing {', '.join(gaps)} — add where the domain " "model allows (don't preload a domain whose subdomains aren't all HTTPS)."}) + # 0.6.2: report the cookie-hardening PASS (✓ builds trust + is a regression assertion), or flag the gap. + passes, cookie_security = [], None + if sets_cookie: + cookie_security = {"httponly": ck_httponly, "secure": ck_secure, "samesite": ck_samesite} + if ck_httponly and ck_secure and ck_samesite: + passes.append("cookies set HttpOnly + Secure + SameSite (checked ✓)") + else: + miss = [n for n, ok in (("HttpOnly", ck_httponly), ("Secure", ck_secure), + ("SameSite", ck_samesite)) if not ok] + findings.append({"severity": "LOW", "kind": "cookie-flags", "attack_class": "insecure-cookie", + "detail": f"A cookie is set without {', '.join(miss)} — an auth/session cookie should be " + "HttpOnly (no JS read), Secure (HTTPS-only), and SameSite=Lax/Strict (CSRF). " + "Verify against the live Set-Cookie."}) + return { "web_surface": web_surface, "html_surface": html_surface, "csp_present": csp_present, "strict_csp": strict_csp, "csp_has_unsafe": csp_unsafe, "hsts_present": hsts_present, "hsts_includes_subdomains": hsts_sub, "hsts_preload": hsts_preload, "hsts_files": sorted(set(hsts_files))[:20], "inline_event_handlers": sorted(set(inline_handlers)), + "cookie_security": cookie_security, + "passes": passes, "findings": findings, "note": ("CSP/HSTS baseline audit — these are the enabling controls for the client trust boundary. " "LOW/architectural: verify against the LIVE response headers (a static scan can't see the edge/CDN " diff --git a/src/websec_validator/findings.py b/src/websec_validator/findings.py index 57d3705..cb43817 100644 --- a/src/websec_validator/findings.py +++ b/src/websec_validator/findings.py @@ -88,6 +88,8 @@ "ASVS V11.1.4", ["API4:2023 Unrestricted Resource Consumption", "API6:2023 Unrestricted Access to Sensitive Business Flows"]), "redundant-secret-fetch": (["CWE-200 Information Exposure"], "ASVS V2.10", ["API8:2023 Misconfiguration"]), + "insecure-cookie": (["CWE-1004 Sensitive Cookie Without HttpOnly", "CWE-614 Sensitive Cookie Without Secure"], + "ASVS V3.4.1", ["API8:2023 Misconfiguration"]), } REMEDIATION = { "missing-auth": "Add an auth guard to the handler (e.g. requireAuth()/getServerSession()), or a " @@ -152,6 +154,8 @@ "redundant-secret-fetch": "Fetch each secret-manager key ONCE per request and reuse it; use the project's existing " "secret-provider abstraction instead of a bespoke loader (smaller exposure window + " "consistency).", + "insecure-cookie": "Set auth/session cookies `HttpOnly` (blocks JS/XSS theft) + `Secure` (HTTPS-only) + " + "`SameSite=Lax`/`Strict` (CSRF). Verify against the live Set-Cookie header.", } _DEFAULT_REM = "Review and remediate per the cited standard." diff --git a/tests/test_pentest_regressions.py b/tests/test_pentest_regressions.py index d86bc01..2748344 100644 --- a/tests/test_pentest_regressions.py +++ b/tests/test_pentest_regressions.py @@ -618,5 +618,24 @@ def test_named_provider_key_is_high_not_generic_via_gitleaks(self): self.assertIn("ROTATE", row["title"]) +class CookieReportPassTests(unittest.TestCase): + """0.6.2 report-the-passes: cookie HttpOnly+Secure+SameSite → a PASS; a missing flag → a finding.""" + + def test_all_flags_is_a_pass_no_finding(self): + out = TransportSecurityExtractor().extract(repo({"a.ts": + "res.headers.set('Set-Cookie', 'sid=x; HttpOnly; Secure; SameSite=Lax');\n"}), {}) + self.assertTrue(any("HttpOnly" in p for p in out["passes"])) + self.assertFalse(any(f["kind"] == "cookie-flags" for f in out["findings"])) + + def test_missing_flag_is_a_finding(self): + out = TransportSecurityExtractor().extract(repo({"a.ts": "res.cookie('sid', v, {httpOnly:true});\n"}), {}) + self.assertTrue(any(f["attack_class"] == "insecure-cookie" for f in out["findings"])) + + def test_no_cookie_no_noise(self): + out = TransportSecurityExtractor().extract(repo({"a.ts": "const x = 1;\n"}), {}) + self.assertEqual(out["passes"], []) + self.assertIsNone(out["cookie_security"]) + + if __name__ == "__main__": unittest.main()