Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs-canonical/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
4 changes: 2 additions & 2 deletions docs-canonical/TEST-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

---
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions src/websec_validator/briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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}

Expand Down
28 changes: 28 additions & 0 deletions src/websec_validator/extractors/transport_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<!DOCTYPE\s+html|<html[\s>]|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):
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 "
Expand Down
4 changes: 4 additions & 0 deletions src/websec_validator/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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."

Expand Down
19 changes: 19 additions & 0 deletions tests/test_pentest_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()