Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2733e99
Implement first slice: questionnaire ingestion + reachability downgrade
contantlab Jun 12, 2026
79c6fe1
Implement Grype adapter slice: declarative normalize + registry
contantlab Jun 12, 2026
7d117fe
Implement policy slice: deterministic re-scoring engine
contantlab Jun 12, 2026
fc08d2b
Implement chaining slice + wire full pipeline (reach -> chain -> score)
contantlab Jun 12, 2026
a2955a5
Implement conditions_map: category defaults + enrichment
contantlab Jun 12, 2026
2a2c311
Implement CLI + reporting: runnable ingest -> reason -> report
contantlab Jun 12, 2026
50c6333
Implement PostgresGraphStore + FindingsStore (durable persistence)
contantlab Jun 12, 2026
2f0d1bf
Implement SBOM ingestion + component->asset resolution
contantlab Jun 12, 2026
ed34f10
Implement Terraform ingestion + reconciliation report
contantlab Jun 12, 2026
b217c53
Implement LLM chain narratives (advisory, runs last)
contantlab Jun 12, 2026
09e8573
Fix migrations to run one statement per execute() (psycopg3)
contantlab Jun 12, 2026
0e9bb56
Verify Postgres live; fix idle-in-transaction locking + test isolation
contantlab Jun 12, 2026
953d087
Implement diagrams-as-code ingestion (Mermaid)
contantlab Jun 12, 2026
6c0e79d
Implement LLM enrichment proposals (advisory, human-reviewed)
contantlab Jun 12, 2026
03c7efa
Run live Grype end to end; fix adapter version extraction
contantlab Jun 12, 2026
22ab93b
Implement ZapAdapter; run live DAST end to end
contantlab Jun 12, 2026
17e634f
Wire CI: Postgres service + live grype, gated tests run
contantlab Jun 12, 2026
84ef5cb
Richer Azure IaC coverage (NSG, public IP, NIC, app gateway, key vault)
contantlab Jun 12, 2026
593e3f8
DAST host->asset resolution: ZAP findings flow through scoring
contantlab Jun 12, 2026
25c25b5
Azure backend routing + NSG segmentation (parity with AWS)
contantlab Jun 12, 2026
29c9291
Datastore zone placement: Terraform graphs yield crown-jewel chains
contantlab Jun 12, 2026
ce65f5f
CloudFormation provider (third IaC dialect)
contantlab Jun 12, 2026
e87f023
Front Door backend routing (FQDN-based)
contantlab Jun 12, 2026
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
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest

# Postgres as a service container -> the gated integration tests run for real
# (recursive-CTE reachability, ON CONFLICT merge, JSONB, migrations, score history).
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: reachability
POSTGRES_PASSWORD: reachability
POSTGRES_DB: reachability
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U reachability"
--health-interval 5s
--health-timeout 5s
--health-retries 10

env:
PYTHONPATH: core
REACHABILITY_TEST_DSN: postgresql://reachability:reachability@localhost:5432/reachability

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install package + dev deps
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Install grype
# grype on PATH -> the gated live SCA test scans the SBOM fixture for real
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Lint
run: ruff check core

- name: Test (Postgres service + live grype)
run: pytest core/tests -q
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,6 @@ desktop.ini
# --- Logs ---
*.log
logs/

# --- local tool binaries (grype, etc.) ---
.tools/
133 changes: 124 additions & 9 deletions adapters/zap/adapter.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,149 @@
"""ZAP DAST adapter — code adapter (effort-ladder rung 2).

Default OSS scanner for the `dast` capability. ZAP runs as a daemon with a REST
API (stateful session, spider + active scan), so it needs code rather than a
declarative YAML. Subclasses CodeAdapter; ``normalize()`` is kept pure and
unit-testable against recorded ZAP alert JSON (see core/tests/test_normalize/).
Contract: schema/03-adapter-interface.md.
API (stateful session: spider + passive/active scan), so it needs code rather than
a declarative YAML. ``normalize()`` is kept pure and unit-testable against recorded
ZAP alert JSON (see core/tests/fixtures/zap_alerts.json). Contract:
schema/01-finding-schema.md + schema/03-adapter-interface.md.
"""

from __future__ import annotations

import hashlib
import json
import time
import urllib.parse
import urllib.request
import uuid
from typing import Iterator

from reachability.adapters.code_adapter import CodeAdapter
from reachability.adapters.protocol import ScanTarget

_FINDING_NS = uuid.uuid5(uuid.NAMESPACE_URL, "https://reachability.dev/finding")

# ZAP risk -> canonical level (ZAP has no "critical").
_RISK_LEVEL = {"Informational": "info", "Info": "info", "Low": "low",
"Medium": "medium", "High": "high"}
# ZAP confidence -> DAST evidence confidence (01-finding-schema.md).
_CONFIDENCE = {"False Positive": "tentative", "Low": "tentative",
"Medium": "firm", "High": "confirmed", "Confirmed": "confirmed"}
# CWE -> our category vocabulary (categories.md). Unmapped -> "unknown" (never
# auto-downgraded, only flagged — we don't guess a category we can't justify).
_CWE_CATEGORY = {
"79": "xss", "89": "sql_injection", "78": "command_injection", "77": "command_injection",
"22": "path_traversal", "98": "path_traversal", "918": "ssrf",
"94": "code_injection", "502": "code_injection", "434": "file_upload",
"200": "info_disclosure", "215": "info_disclosure", "548": "info_disclosure",
"497": "info_disclosure",
"287": "auth_bypass", "288": "auth_bypass", "306": "missing_authentication",
"639": "broken_access_control", "284": "broken_access_control", "798": "secret_exposure",
"16": "security_misconfiguration", "693": "security_misconfiguration",
"1021": "security_misconfiguration", "1004": "security_misconfiguration",
"525": "security_misconfiguration", "614": "security_misconfiguration",
}


class ZapAdapter(CodeAdapter):
name = "zap"
kind = "dast"

def __init__(self, base_url: str = "http://127.0.0.1:8090", api_key: str | None = None) -> None:
self.base_url = base_url.rstrip("/")
self.api_key = api_key

# --- lifecycle --------------------------------------------------------
def healthcheck(self) -> bool:
raise NotImplementedError("scaffold: ping ZAP daemon REST API")
try:
# short timeout: discovery must stay fast when no ZAP daemon is running
self.version = self._get("/JSON/core/view/version/", _timeout=3).get("version", "unknown")
return True
except Exception:
return False

def run(self, target: ScanTarget) -> Iterator[dict]:
raise NotImplementedError("scaffold: spider + active scan, stream alerts -> normalize")
active = bool(target.options.get("active"))
for url in target.targets:
self._spider(url)
self._wait_passive()
if active:
self._active_scan(url)
for alert in self._get("/JSON/alert/view/alerts/", baseurl=url).get("alerts", []):
yield self.normalize(alert)

# --- mapping (pure, testable) -----------------------------------------
def normalize(self, raw_item: dict) -> dict:
# Map one ZAP alert -> normalized finding. evidence.confidence is REQUIRED
# for dast (01-finding-schema.md): map ZAP confidence -> tentative|firm|confirmed.
raise NotImplementedError("scaffold")
cwe = str(raw_item.get("cweid", "")).strip()
cwes = [f"CWE-{cwe}"] if cwe and cwe not in ("-1", "0") else []
category = _CWE_CATEGORY.get(cwe, "unknown")
risk = (raw_item.get("risk") or "").split(" ")[0]
url = raw_item.get("url", "")
parts = urllib.parse.urlsplit(url) if url else urllib.parse.SplitResult("", "", "", "", "")
port = parts.port or (443 if parts.scheme == "https" else 80 if parts.scheme else None)

finding = {
"schema_version": "1.0",
"adapter": {"name": self.name, "kind": self.kind, "version": getattr(self, "version", "unknown")},
"title": raw_item.get("name") or raw_item.get("alert", ""),
"description": raw_item.get("description", ""),
"category": {"type": category, "cwe": cwes},
"cve": [],
"base_severity": {"level": _RISK_LEVEL.get(risk, "info"), "source": "scanner"},
"asset_ref": {"locator": {"url": url, "host": parts.hostname, "port": port}},
"evidence": {
"confidence": _CONFIDENCE.get(raw_item.get("confidence"), "tentative"),
"request": f"{raw_item.get('method', 'GET')} {url}".strip(),
"response_snippet": raw_item.get("evidence", ""),
},
"remediation": {
"summary": raw_item.get("solution", ""),
"references": [r for r in [raw_item.get("reference")] if r],
},
"raw": raw_item,
}
fp = self._fingerprint(finding)
finding["fingerprint"] = fp
finding["finding_id"] = str(uuid.uuid5(_FINDING_NS, f"{self.name}:{fp}"))
return finding

# --- ZAP REST helpers -------------------------------------------------
def _get(self, path: str, _timeout: int = 30, **params) -> dict:
if self.api_key:
params["apikey"] = self.api_key
query = urllib.parse.urlencode(params)
url = f"{self.base_url}{path}" + (f"?{query}" if query else "")
with urllib.request.urlopen(url, timeout=_timeout) as resp:
return json.loads(resp.read().decode())

def _spider(self, target_url: str, timeout_s: int = 180) -> None:
scan_id = self._get("/JSON/spider/action/scan/", url=target_url).get("scan")
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if int(self._get("/JSON/spider/view/status/", scanId=scan_id).get("status", 0)) >= 100:
return
time.sleep(1)

def _wait_passive(self, timeout_s: int = 120) -> None:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if int(self._get("/JSON/pscan/view/recordsToScan/").get("recordsToScan", 0)) == 0:
return
time.sleep(1)

def _active_scan(self, target_url: str, timeout_s: int = 600) -> None:
scan_id = self._get("/JSON/ascan/action/scan/", url=target_url).get("scan")
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if int(self._get("/JSON/ascan/view/status/", scanId=scan_id).get("status", 0)) >= 100:
return
time.sleep(2)

def _fingerprint(self, finding: dict) -> str:
basis = {"category": finding["category"]["type"],
"url": finding["asset_ref"]["locator"].get("url"),
"title": finding["title"]}
digest = hashlib.sha256(json.dumps(basis, sort_keys=True, default=str).encode()).hexdigest()
return f"sha256-{digest}"


ADAPTER = ZapAdapter # registry entrypoint
Loading
Loading