Upload files to "redflare/core"
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
url: str
|
||||
host: str
|
||||
scheme: str
|
||||
port: int
|
||||
|
||||
@property
|
||||
def authority(self) -> str:
|
||||
default = (self.scheme == "http" and self.port == 80) or (
|
||||
self.scheme == "https" and self.port == 443
|
||||
)
|
||||
return self.host if default else f"{self.host}:{self.port}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
run_id: str
|
||||
target: str
|
||||
module: str
|
||||
category: str
|
||||
title: str
|
||||
severity: str
|
||||
confidence: float
|
||||
description: str
|
||||
evidence: dict[str, Any] = field(default_factory=dict)
|
||||
tags: list[str] = field(default_factory=list)
|
||||
timestamp: str = field(default_factory=utc_now)
|
||||
id: str = ""
|
||||
test_id: str = ""
|
||||
standards: dict[str, list[dict[str, str]]] = field(default_factory=dict)
|
||||
remediation: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.id:
|
||||
evidence = json.dumps(self.evidence, sort_keys=True, default=str)
|
||||
material = "\x00".join([self.target, self.module, self.category, self.title, evidence])
|
||||
self.id = sha256(material.encode()).hexdigest()[:16]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleResult:
|
||||
module: str
|
||||
target: str
|
||||
status: str = "completed"
|
||||
findings: list[Finding] = field(default_factory=list)
|
||||
observations: dict[str, Any] = field(default_factory=dict)
|
||||
artifacts: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
duration_seconds: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["findings"] = [finding.to_dict() for finding in self.findings]
|
||||
return value
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from redflare.modules.base import Module, ModuleContext
|
||||
from .correlation import correlate
|
||||
from .models import ModuleResult, Target
|
||||
from .standards import enrich_finding, registry_document
|
||||
from .storage import RunStore
|
||||
|
||||
|
||||
class Runner:
|
||||
def __init__(self, store: RunStore, modules: list[Module], context: ModuleContext, workers: int = 2):
|
||||
self.store = store
|
||||
self.modules = modules
|
||||
self.context = context
|
||||
self.workers = max(1, workers)
|
||||
|
||||
def run(self, targets: list[Target]) -> tuple[list[ModuleResult], dict]:
|
||||
self.store.write_manifest(
|
||||
{
|
||||
"run_id": self.store.run_id,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"targets": [asdict(target) for target in targets],
|
||||
"modules": [module.name for module in self.modules],
|
||||
"authorized_acknowledgement": True,
|
||||
}
|
||||
)
|
||||
results: list[ModuleResult] = []
|
||||
with ThreadPoolExecutor(max_workers=self.workers) as executor:
|
||||
future_map = {
|
||||
executor.submit(self._run_target_pipeline, target): target for target in targets
|
||||
}
|
||||
for future in as_completed(future_map):
|
||||
target = future_map[future]
|
||||
try:
|
||||
target_results = future.result()
|
||||
except Exception as exc:
|
||||
target_results = [ModuleResult(
|
||||
"pipeline",
|
||||
target.url,
|
||||
status="error",
|
||||
errors=[f"{type(exc).__name__}: {exc}"],
|
||||
)]
|
||||
for result in target_results:
|
||||
for finding in result.findings:
|
||||
enrich_finding(finding)
|
||||
results.append(result)
|
||||
self.store.write_result(result)
|
||||
correlated = correlate(self.store.run_id, results)
|
||||
for result in correlated:
|
||||
for finding in result.findings:
|
||||
enrich_finding(finding)
|
||||
results.append(result)
|
||||
self.store.write_result(result)
|
||||
surface_graph = self.context.surface_graph.snapshot()
|
||||
self.store.write_surface_graph(surface_graph)
|
||||
self.store.write_test_registry(registry_document())
|
||||
return results, self.store.finalize(results, surface_graph=surface_graph)
|
||||
|
||||
def _run_target_pipeline(self, target: Target) -> list[ModuleResult]:
|
||||
results = []
|
||||
for module in self.modules:
|
||||
self.context.emit(target.url, module.name, "start", module.description)
|
||||
try:
|
||||
result = module.run(target, self.context)
|
||||
except Exception as exc:
|
||||
result = ModuleResult(
|
||||
module.name,
|
||||
target.url,
|
||||
status="error",
|
||||
errors=[f"{type(exc).__name__}: {exc}"],
|
||||
)
|
||||
kind = "success" if result.status == "completed" else result.status
|
||||
message = f"finished in {result.duration_seconds:.2f}s; findings={len(result.findings)}"
|
||||
if result.errors:
|
||||
message += f"; {result.errors[-1]}"
|
||||
self.context.emit(target.url, module.name, kind, message)
|
||||
results.append(result)
|
||||
return results
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .models import Target
|
||||
|
||||
|
||||
class ScopeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScopePolicy:
|
||||
allowed_hosts: set[str] = field(default_factory=set)
|
||||
allow_public: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | None, allow_public: bool = False) -> "ScopePolicy":
|
||||
if not path:
|
||||
return cls(allow_public=allow_public)
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return cls(
|
||||
allowed_hosts={str(host).lower() for host in data.get("allowed_hosts", [])},
|
||||
allow_public=allow_public or bool(data.get("allow_public", False)),
|
||||
)
|
||||
|
||||
def validate(self, target: Target) -> None:
|
||||
host = target.host.lower()
|
||||
if self.allowed_hosts and host not in self.allowed_hosts:
|
||||
raise ScopeError(f"{host} is not listed in allowed_hosts")
|
||||
if not self.allow_public and is_public_host(host):
|
||||
raise ScopeError(
|
||||
f"{host} resolves to public address space; use --allow-public only for authorized scope"
|
||||
)
|
||||
|
||||
|
||||
def normalize_target(value: str) -> Target:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ScopeError("target cannot be empty")
|
||||
if "://" not in value:
|
||||
value = "https://" + value
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ScopeError(f"unsupported target: {value}")
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
normalized = f"{parsed.scheme}://{parsed.hostname}"
|
||||
if port not in {80, 443}:
|
||||
normalized += f":{port}"
|
||||
normalized += parsed.path.rstrip("/") or ""
|
||||
return Target(normalized, parsed.hostname, parsed.scheme, port)
|
||||
|
||||
|
||||
def is_public_host(host: str) -> bool:
|
||||
try:
|
||||
addresses = {item[4][0] for item in socket.getaddrinfo(host, None)}
|
||||
except socket.gaierror as exc:
|
||||
raise ScopeError(f"cannot resolve {host}: {exc}") from exc
|
||||
for address in addresses:
|
||||
ip = ipaddress.ip_address(address)
|
||||
if not (ip.is_private or ip.is_loopback or ip.is_link_local):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from .models import Finding, ModuleResult
|
||||
|
||||
|
||||
def target_run_id(targets: Iterable[object]) -> str:
|
||||
values = list(targets)
|
||||
first = values[0] if values else None
|
||||
authority = str(getattr(first, "authority", None) or getattr(first, "host", None) or "assessment")
|
||||
slug = re.sub(r"[^A-Za-z0-9._-]+", "_", authority).strip("._-") or "assessment"
|
||||
extra = f"_plus_{len(values) - 1}" if len(values) > 1 else ""
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"scan_{slug}{extra}_{stamp}"
|
||||
|
||||
|
||||
class RunStore:
|
||||
def __init__(self, base: str = "runs", run_id: str | None = None):
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self.run_id = run_id or f"run_{stamp}"
|
||||
root = Path(base)
|
||||
candidate = root / self.run_id
|
||||
sequence = 1
|
||||
while candidate.exists():
|
||||
candidate = root / f"{self.run_id}_{sequence:02d}"
|
||||
sequence += 1
|
||||
self.root = candidate
|
||||
self.modules = self.root / "modules"
|
||||
self.artifacts = self.root / "artifacts"
|
||||
self.modules.mkdir(parents=True)
|
||||
self.artifacts.mkdir(parents=True)
|
||||
self.findings_file = self.root / "findings.jsonl"
|
||||
self.findings_file.touch()
|
||||
|
||||
def write_manifest(self, manifest: dict) -> None:
|
||||
self._write_json(self.root / "manifest.json", manifest)
|
||||
|
||||
def write_result(self, result: ModuleResult) -> None:
|
||||
safe_target = result.target.replace("://", "_").replace("/", "_").replace(":", "_")
|
||||
self._write_json(self.modules / f"{safe_target}__{result.module}.json", result.to_dict())
|
||||
with self.findings_file.open("a", encoding="utf-8") as handle:
|
||||
for finding in result.findings:
|
||||
handle.write(json.dumps(finding.to_dict(), sort_keys=True) + "\n")
|
||||
|
||||
def write_surface_graph(self, graph: dict) -> None:
|
||||
self._write_json(self.root / "attack_surface.json", graph)
|
||||
|
||||
def write_test_registry(self, registry: dict) -> None:
|
||||
self._write_json(self.root / "test_registry.json", registry)
|
||||
|
||||
def finalize(self, results: Iterable[ModuleResult], surface_graph: dict | None = None) -> dict:
|
||||
results = list(results)
|
||||
findings = deduplicate_findings(
|
||||
[finding for result in results for finding in result.findings]
|
||||
)
|
||||
with self.findings_file.open("w", encoding="utf-8") as handle:
|
||||
for finding in findings:
|
||||
handle.write(json.dumps(finding.to_dict(), sort_keys=True) + "\n")
|
||||
summary = {
|
||||
"run_id": self.run_id,
|
||||
"module_results": len(results),
|
||||
"completed": sum(result.status == "completed" for result in results),
|
||||
"errors": sum(result.status == "error" for result in results),
|
||||
"findings": len(findings),
|
||||
"by_severity": count_by(finding.severity for finding in findings),
|
||||
"by_module": count_by(finding.module for finding in findings),
|
||||
"by_category": count_by(finding.category for finding in findings),
|
||||
"sensitive_exposures": sum(
|
||||
finding.category == "sensitive-data-exposure" for finding in findings
|
||||
),
|
||||
"known_cves": sum(
|
||||
finding.category == "known-vulnerable-component" for finding in findings
|
||||
),
|
||||
"attack_surface": (surface_graph or {}).get("summary", {}),
|
||||
}
|
||||
self._write_json(self.root / "summary.json", summary)
|
||||
(self.root / "report.html").write_text(
|
||||
render_html(summary, findings, results), encoding="utf-8"
|
||||
)
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def _write_json(path: Path, value: dict) -> None:
|
||||
path.write_text(json.dumps(value, indent=2, sort_keys=True), encoding="utf-8")
|
||||
|
||||
|
||||
def count_by(values: Iterable[str]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for value in values:
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
return dict(sorted(counts.items()))
|
||||
|
||||
|
||||
def deduplicate_findings(findings: list[Finding]) -> list[Finding]:
|
||||
canonical_categories = {
|
||||
"browser-security-headers": "security-headers",
|
||||
}
|
||||
selected: dict[tuple[str, str], Finding] = {}
|
||||
sources: dict[tuple[str, str], set[str]] = {}
|
||||
for finding in findings:
|
||||
category = canonical_categories.get(finding.category, finding.category)
|
||||
key = (
|
||||
finding.target,
|
||||
category if category == "security-headers" else finding.id,
|
||||
)
|
||||
sources.setdefault(key, set()).add(finding.module)
|
||||
current = selected.get(key)
|
||||
if current is None or finding.confidence > current.confidence:
|
||||
selected[key] = replace(
|
||||
finding,
|
||||
category=category,
|
||||
evidence=dict(finding.evidence),
|
||||
)
|
||||
for key, finding in selected.items():
|
||||
modules = sorted(sources[key])
|
||||
if len(modules) > 1:
|
||||
finding.evidence["corroborated_by"] = modules
|
||||
finding.tags = sorted(set(finding.tags + ["corroborated"]))
|
||||
return list(selected.values())
|
||||
|
||||
|
||||
def render_html(
|
||||
summary: dict,
|
||||
findings: list[Finding],
|
||||
results: list[ModuleResult] | None = None,
|
||||
) -> str:
|
||||
rows = []
|
||||
for finding in sorted(findings, key=lambda item: (item.severity, item.target), reverse=True):
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(finding.severity)}</td>"
|
||||
f"<td>{html.escape(finding.test_id)}</td>"
|
||||
f"<td>{html.escape(finding.module)}</td>"
|
||||
f"<td>{html.escape(finding.target)}</td>"
|
||||
f"<td>{html.escape(finding.title)}</td>"
|
||||
f"<td>{html.escape(finding.description)}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
assessments = []
|
||||
for result in sorted(results or [], key=lambda item: (item.target, item.module)):
|
||||
module_findings = "".join(
|
||||
"<article class=\"finding\">"
|
||||
f"<h4>[{html.escape(finding.severity.upper())}] {html.escape(finding.title)}</h4>"
|
||||
f"<p><strong>Test ID:</strong> {html.escape(finding.test_id or 'unmapped')}</p>"
|
||||
f"<p>{html.escape(finding.description)}</p>"
|
||||
f"{render_reference_links(finding)}"
|
||||
f"<pre>{html.escape(json.dumps(finding.evidence, indent=2, default=str))}</pre>"
|
||||
f"<p><strong>Remediation:</strong> {html.escape(finding.remediation or 'Review and remediate based on verified impact.')}</p>"
|
||||
"</article>"
|
||||
for finding in result.findings
|
||||
) or "<p>No module findings.</p>"
|
||||
errors = "".join(f"<li>{html.escape(error)}</li>" for error in result.errors) or "<li>None</li>"
|
||||
artifacts = "".join(
|
||||
f"<li><code>{html.escape(artifact)}</code></li>" for artifact in result.artifacts
|
||||
) or "<li>None</li>"
|
||||
assessments.append(
|
||||
"<section class=\"module\">"
|
||||
f"<h2>{html.escape(result.module.replace('_', ' ').upper())} ASSESSMENT</h2>"
|
||||
f"<p><strong>Target:</strong> {html.escape(result.target)}<br>"
|
||||
f"<strong>Status:</strong> {html.escape(result.status.upper())} "
|
||||
f"<strong>Duration:</strong> {result.duration_seconds:.2f}s "
|
||||
f"<strong>Findings:</strong> {len(result.findings)}</p>"
|
||||
"<h3>Complete observations</h3>"
|
||||
f"<pre>{html.escape(json.dumps(result.observations, indent=2, default=str))}</pre>"
|
||||
f"<h3>Module findings</h3>{module_findings}"
|
||||
f"<h3>Errors</h3><ul>{errors}</ul>"
|
||||
f"<h3>Artifacts</h3><ul>{artifacts}</ul>"
|
||||
"</section>"
|
||||
)
|
||||
return f"""<!doctype html><html><head><meta charset="utf-8"><title>REDflare {html.escape(summary['run_id'])}</title>
|
||||
<style>
|
||||
:root{{color-scheme:dark}}body{{font-family:system-ui;margin:2rem auto;max-width:1500px;padding:0 1rem;background:#0d0d0f;color:#eee}}
|
||||
h1,h2,h3{{color:#ff6666}}.summary,.module{{background:#17171b;border:1px solid #3d3d45;border-radius:10px;padding:1rem 1.25rem;margin:1rem 0}}
|
||||
.module{{border-left:5px solid #9d2424}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;background:#09090b;border:1px solid #333;padding:1rem;border-radius:6px}}
|
||||
table{{border-collapse:collapse;width:100%}}th,td{{padding:.6rem;border:1px solid #444;text-align:left;vertical-align:top}}th{{background:#5b1515}}
|
||||
.finding{{border-left:3px solid #d99a31;padding:.1rem 1rem;margin:.75rem 0}}code{{overflow-wrap:anywhere}}
|
||||
</style></head>
|
||||
<body><h1>REDflare Final Assessment Report</h1><section class="summary"><h2>Run summary</h2><pre>{html.escape(json.dumps(summary, indent=2))}</pre></section>
|
||||
{''.join(assessments)}
|
||||
<section class="summary"><h2>Assessment artifacts</h2><ul><li><code>attack_surface.json</code></li><li><code>test_registry.json</code></li></ul></section>
|
||||
<section class="summary"><h2>Consolidated findings</h2><table><thead><tr><th>Severity</th><th>Test ID</th><th>Module</th><th>Target</th><th>Finding</th><th>Description</th></tr></thead><tbody>{''.join(rows)}</tbody></table></section></body></html>"""
|
||||
|
||||
|
||||
def render_reference_links(finding: Finding) -> str:
|
||||
links = []
|
||||
seen = set()
|
||||
for family, references in finding.standards.items():
|
||||
for reference in references:
|
||||
url = str(reference.get("url") or "")
|
||||
if not url or url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
label = str(reference.get("id") or family)
|
||||
links.append(
|
||||
f'<a href="{html.escape(url, quote=True)}" target="_blank" rel="noopener noreferrer">'
|
||||
f'{html.escape(label)}</a>'
|
||||
)
|
||||
return f"<p><strong>References:</strong> {' · '.join(links)}</p>" if links else ""
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
|
||||
def canonical_url(url: str) -> str:
|
||||
parsed = urlsplit(url)
|
||||
path = parsed.path or "/"
|
||||
return urlunsplit((parsed.scheme.lower(), parsed.netloc.lower(), path, "", ""))
|
||||
|
||||
|
||||
@dataclass
|
||||
class SurfaceParameter:
|
||||
name: str
|
||||
location: str = "unknown"
|
||||
required: bool = False
|
||||
data_type: str = "unknown"
|
||||
sources: set[str] = field(default_factory=set)
|
||||
|
||||
def merge(self, *, required: bool, data_type: str, source: str) -> None:
|
||||
self.required = self.required or required
|
||||
if self.data_type == "unknown" and data_type:
|
||||
self.data_type = data_type
|
||||
self.sources.add(source)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"location": self.location,
|
||||
"required": self.required,
|
||||
"data_type": self.data_type,
|
||||
"sources": sorted(self.sources),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SurfaceEndpoint:
|
||||
url: str
|
||||
observed_requests: set[tuple[str, str]] = field(default_factory=set)
|
||||
methods: set[str] = field(default_factory=set)
|
||||
content_types: set[str] = field(default_factory=set)
|
||||
authentication: set[str] = field(default_factory=set)
|
||||
sources: set[str] = field(default_factory=set)
|
||||
status_codes: set[int] = field(default_factory=set)
|
||||
parameters: dict[str, SurfaceParameter] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"url": self.url,
|
||||
"observed_urls": sorted({redact_url(value) for _, value in self.observed_requests}),
|
||||
"methods": sorted(self.methods),
|
||||
"content_types": sorted(self.content_types),
|
||||
"authentication": sorted(self.authentication),
|
||||
"sources": sorted(self.sources),
|
||||
"status_codes": sorted(self.status_codes),
|
||||
"parameters": [
|
||||
item.to_dict()
|
||||
for item in sorted(self.parameters.values(), key=lambda value: (value.location, value.name))
|
||||
],
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
class AttackSurfaceGraph:
|
||||
"""Thread-safe endpoint inventory shared by every module in a run."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = RLock()
|
||||
self._endpoints: dict[str, dict[str, SurfaceEndpoint]] = {}
|
||||
self._edges: dict[str, set[tuple[str, str, str]]] = {}
|
||||
self._documents: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
def add_endpoint(
|
||||
self,
|
||||
target: str,
|
||||
url: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
source: str,
|
||||
content_type: str | None = None,
|
||||
authentication: str | None = None,
|
||||
status: int | None = None,
|
||||
parameters: list[dict[str, Any]] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> SurfaceEndpoint:
|
||||
normalized = canonical_url(url)
|
||||
with self._lock:
|
||||
endpoint = self._endpoints.setdefault(target, {}).setdefault(
|
||||
normalized, SurfaceEndpoint(normalized)
|
||||
)
|
||||
endpoint.methods.add(method.upper())
|
||||
endpoint.observed_requests.add(
|
||||
(method.upper(), urlunsplit((*urlsplit(url)[:4], "")))
|
||||
)
|
||||
endpoint.sources.add(source)
|
||||
if content_type:
|
||||
endpoint.content_types.add(content_type.split(";", 1)[0].strip().lower())
|
||||
if authentication:
|
||||
endpoint.authentication.add(authentication)
|
||||
if status is not None:
|
||||
endpoint.status_codes.add(int(status))
|
||||
for name, _ in parse_qsl(urlsplit(url).query, keep_blank_values=True):
|
||||
self._merge_parameter(endpoint, name, "query", False, "string", source)
|
||||
for parameter in parameters or []:
|
||||
name = str(parameter.get("name") or "").strip()
|
||||
if name:
|
||||
self._merge_parameter(
|
||||
endpoint,
|
||||
name,
|
||||
str(parameter.get("location") or "unknown"),
|
||||
bool(parameter.get("required", False)),
|
||||
str(parameter.get("data_type") or parameter.get("type") or "unknown"),
|
||||
source,
|
||||
)
|
||||
if metadata:
|
||||
endpoint.metadata.update(metadata)
|
||||
return endpoint
|
||||
|
||||
@staticmethod
|
||||
def _merge_parameter(
|
||||
endpoint: SurfaceEndpoint,
|
||||
name: str,
|
||||
location: str,
|
||||
required: bool,
|
||||
data_type: str,
|
||||
source: str,
|
||||
) -> None:
|
||||
key = f"{location}:{name}"
|
||||
parameter = endpoint.parameters.setdefault(key, SurfaceParameter(name, location))
|
||||
parameter.merge(required=required, data_type=data_type, source=source)
|
||||
|
||||
def add_edge(self, target: str, source_url: str, destination_url: str, relation: str) -> None:
|
||||
with self._lock:
|
||||
self._edges.setdefault(target, set()).add(
|
||||
(canonical_url(source_url), canonical_url(destination_url), relation)
|
||||
)
|
||||
|
||||
def add_document(self, target: str, document: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
documents = self._documents.setdefault(target, [])
|
||||
identity = (document.get("kind"), document.get("url"))
|
||||
if not any((item.get("kind"), item.get("url")) == identity for item in documents):
|
||||
documents.append(document)
|
||||
|
||||
def endpoint_urls(self, target: str) -> list[str]:
|
||||
with self._lock:
|
||||
return sorted(self._endpoints.get(target, {}))
|
||||
|
||||
def request_urls(self, target: str, method: str = "GET") -> list[str]:
|
||||
with self._lock:
|
||||
values = {
|
||||
observed
|
||||
for endpoint in self._endpoints.get(target, {}).values()
|
||||
for observed_method, observed in endpoint.observed_requests
|
||||
if method.upper() == observed_method
|
||||
}
|
||||
return sorted(values)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
targets: dict[str, Any] = {}
|
||||
for target, endpoints in sorted(self._endpoints.items()):
|
||||
edges = [
|
||||
{"source": source, "destination": destination, "relation": relation}
|
||||
for source, destination, relation in sorted(self._edges.get(target, set()))
|
||||
]
|
||||
targets[target] = {
|
||||
"endpoints": [endpoints[url].to_dict() for url in sorted(endpoints)],
|
||||
"edges": edges,
|
||||
"documents": list(self._documents.get(target, [])),
|
||||
}
|
||||
endpoint_count = sum(len(value["endpoints"]) for value in targets.values())
|
||||
parameter_count = sum(
|
||||
len(endpoint["parameters"])
|
||||
for value in targets.values()
|
||||
for endpoint in value["endpoints"]
|
||||
)
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"summary": {
|
||||
"targets": len(targets),
|
||||
"endpoints": endpoint_count,
|
||||
"parameters": parameter_count,
|
||||
"edges": sum(len(value["edges"]) for value in targets.values()),
|
||||
"documents": sum(len(value["documents"]) for value in targets.values()),
|
||||
},
|
||||
"targets": targets,
|
||||
}
|
||||
|
||||
|
||||
def redact_url(url: str) -> str:
|
||||
parsed = urlsplit(url)
|
||||
sensitive = re.compile(r"(?:token|secret|password|passwd|api[_-]?key|auth|session|jwt)", re.I)
|
||||
query = urlencode(
|
||||
[
|
||||
(name, "<redacted>" if sensitive.search(name) else value)
|
||||
for name, value in parse_qsl(parsed.query, keep_blank_values=True)
|
||||
]
|
||||
)
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, ""))
|
||||
Reference in New Issue
Block a user