diff --git a/scripts/parse_intigriti.py b/scripts/parse_intigriti.py new file mode 100755 index 0000000..0ceb9d8 --- /dev/null +++ b/scripts/parse_intigriti.py @@ -0,0 +1,643 @@ +#!/usr/bin/env python3 +"""Create a draft harness engagement from a public Intigriti program.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import tempfile +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable +from urllib.parse import quote, unquote, urlsplit + +import yaml + + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from lib.harness_config import ConfigError, extract_host, load_engagement, normalize_pattern, pattern_matches + + +HANDLE_RE = re.compile(r"[A-Za-z0-9_-]+") +DOMAIN_RE = re.compile( + r"(?|\{\s*username\s*\})", re.IGNORECASE) +SEVERITY_FLOORS = ( + (9.5, "exceptional"), + (9.0, "critical"), + (7.0, "high"), + (4.0, "medium"), + (0.0, "low"), +) +NON_WEB_TYPES = {2: "Android package", 3: "iOS store id", 6: "Other"} +ROE_GATES = ( + "mutation_allowed", + "sensitive_data_access_allowed", + "credential_use_allowed", + "pivoting_allowed", + "availability_impact_allowed", + "destructive_allowed", +) + + +class DraftDumper(yaml.SafeDumper): + """Readable YAML dumper that uses literal blocks for assembled prose.""" + + +def _represent_string(dumper: DraftDumper, value: str) -> yaml.ScalarNode: + style = "|" if "\n" in value else None + return dumper.represent_scalar("tag:yaml.org,2002:str", value, style=style) + + +DraftDumper.add_representer(str, _represent_string) + + +def parse_program_ref(value: str) -> tuple[str, str]: + candidate = value.strip() + if not candidate: + raise ValueError("program reference must be an Intigriti URL or company/program slug") + + if "://" in candidate: + parsed = urlsplit(candidate) + if parsed.scheme not in {"http", "https"} or parsed.hostname != "app.intigriti.com": + raise ValueError("program URL must be on app.intigriti.com") + segments = [unquote(segment) for segment in parsed.path.split("/") if segment] + try: + programs_at = segments.index("programs") + company, program = segments[programs_at + 1 : programs_at + 3] + except (ValueError, IndexError) as exc: + raise ValueError("cannot extract company/program from Intigriti URL") from exc + else: + segments = candidate.strip("/").split("/") + if len(segments) != 2: + raise ValueError("program slug must use company/program format") + company, program = segments + + if not HANDLE_RE.fullmatch(company) or not HANDLE_RE.fullmatch(program): + raise ValueError("program slug must use valid company/program handles") + return company, program + + +def _latest_snapshot(program: dict[str, Any], key: str) -> dict[str, Any]: + snapshots = program.get(key) + if not isinstance(snapshots, list) or not snapshots: + raise ValueError(f"program has no {key} snapshots") + valid = [item for item in snapshots if isinstance(item, dict) and isinstance(item.get("createdAt"), (int, float))] + if not valid: + raise ValueError(f"program has no valid {key} snapshots") + latest = max(valid, key=lambda item: item["createdAt"]) + content = latest.get("content") + if not isinstance(content, dict): + raise ValueError(f"latest {key} snapshot has invalid content") + return content + + +def _severity_label(score: float) -> str: + for minimum, label in SEVERITY_FLOORS: + if score >= minimum: + return label + raise ValueError(f"invalid paid CVSS floor: {score}") + + +def _bounty_value(bounty_range: dict[str, Any]) -> float: + minimum = bounty_range.get("minBounty") + if not isinstance(minimum, dict): + return 0.0 + value = minimum.get("value", 0) + if isinstance(value, bool) or not isinstance(value, (int, float)): + return 0.0 + return float(value) + + +def parse_tiers(program: dict[str, Any]) -> dict[int, dict[str, Any]]: + content = _latest_snapshot(program, "bountyTables") + rows = content.get("bountyRows") + if not isinstance(rows, list): + raise ValueError("latest bounty table has no bounty rows") + + tiers: dict[int, dict[str, Any]] = {} + for row in rows: + if not isinstance(row, dict): + continue + tier_id = row.get("bountyTierId") + ranges = row.get("bountyRanges") + if isinstance(tier_id, bool) or not isinstance(tier_id, int) or not isinstance(ranges, list): + continue + paid_floors = [ + float(item["minScore"]) + for item in ranges + if isinstance(item, dict) + and isinstance(item.get("minScore"), (int, float)) + and not isinstance(item.get("minScore"), bool) + and _bounty_value(item) > 0 + ] + if not paid_floors: + continue + floor = min(paid_floors) + tiers[tier_id] = {"min_score": floor, "report_floor": _severity_label(floor)} + + if not tiers: + raise ValueError("program has no paying tier; refusing to create an empty engagement") + return tiers + + +def _normalize_asset_target(asset_type: int, name: str) -> str: + if asset_type == 1: + return extract_host(name) + if asset_type == 7: + kind, suffix = normalize_pattern(name) + if kind != "wildcard": + raise ValueError(f"wildcard asset is not a wildcard hostname: {name}") + return f"*.{suffix}" + if name.startswith("*."): + kind, suffix = normalize_pattern(name) + if kind != "wildcard": + raise ValueError(f"cannot normalize unknown asset as target: {name}") + return f"*.{suffix}" + return extract_host(name) + + +def _unknown_asset_action(name: str, type_id: Any, input_func: Callable[[str], str]) -> str: + answer = input_func( + f"Unknown Intigriti asset typeId {type_id!r} for {name!r}. " + "Put in [N]otes, include as [T]arget, or [S]kip? [N]: " + ).strip().lower() + choices = {"": "notes", "n": "notes", "notes": "notes", "t": "target", "target": "target", "s": "skip", "skip": "skip"} + if answer not in choices: + raise ValueError(f"invalid unknown-asset choice: {answer!r}") + return choices[answer] + + +def parse_scope( + program: dict[str, Any], + tiers: dict[int, dict[str, Any]], + *, + assume_yes: bool = False, + input_func: Callable[[str], str] = input, +) -> dict[str, Any]: + content = _latest_snapshot(program, "assetsCollection") + assets = content.get("assetsAndGroups") + if not isinstance(assets, list): + raise ValueError("latest assets snapshot has no assetsAndGroups") + + targets: list[str] = [] + tier_patterns: dict[int, list[str]] = {tier_id: [] for tier_id in tiers} + non_web: list[str] = [] + unknown_notes: list[str] = [] + + for asset in assets: + if not isinstance(asset, dict): + continue + tier_id = asset.get("bountyTierId") + if tier_id not in tiers: + continue + name = str(asset.get("name") or "").strip() + type_id = asset.get("typeId") + if not name: + continue + if type_id in (1, 7): + target = _normalize_asset_target(type_id, name) + if target not in targets: + targets.append(target) + if target not in tier_patterns[tier_id]: + tier_patterns[tier_id].append(target) + continue + if type_id in NON_WEB_TYPES: + label = NON_WEB_TYPES[type_id] + description = str(asset.get("description") or "").strip() + suffix = f" β€” {description}" if description else "" + non_web.append(f"{label}: {name}{suffix}") + continue + + action = "notes" if assume_yes else _unknown_asset_action(name, type_id, input_func) + if action == "target": + target = _normalize_asset_target(-1, name) + if target not in targets: + targets.append(target) + if target not in tier_patterns[tier_id]: + tier_patterns[tier_id].append(target) + elif action == "notes": + unknown_notes.append(f"Unknown typeId {type_id}: {name}") + + if not targets: + raise ValueError("paying tiers contain no web targets") + return { + "targets": targets, + "tier_patterns": tier_patterns, + "non_web": non_web, + "unknown_notes": unknown_notes, + } + + +def _latest_structured_roe(program: dict[str, Any]) -> dict[str, Any]: + candidates: list[dict[str, Any]] = [] + snapshots = program.get("rulesOfEngagements") + if not isinstance(snapshots, list): + raise ValueError("program has no rulesOfEngagements snapshots") + for snapshot in snapshots: + if not isinstance(snapshot, dict): + continue + wrapper = snapshot.get("content") + inner = wrapper.get("content") if isinstance(wrapper, dict) else None + if not isinstance(inner, dict) or not isinstance(inner.get("testingRequirements"), dict): + continue + copy = dict(inner) + created_at = inner.get("createdAt", snapshot.get("createdAt", 0)) + copy["_selection_created_at"] = created_at if isinstance(created_at, (int, float)) else 0 + candidates.append(copy) + if not candidates: + raise ValueError("program has no structured rules of engagement with testing requirements") + return max(candidates, key=lambda item: item["_selection_created_at"]) + + +def _snapshot_markdown(program: dict[str, Any], key: str) -> str: + content = _latest_snapshot(program, key).get("content") + return content if isinstance(content, str) else "" + + +def _choose_rate_limit(values: list[int], input_func: Callable[[str], str]) -> int: + strictest = min(values) + choices = ", ".join(str(item) for item in sorted(set(values))) + answer = input_func( + f"Rate-limit conflict ({choices} requests/sec). " + f"Use the stricter {strictest} requests/sec? [Y/n, or enter listed value]: " + ).strip().lower() + if answer in {"", "y", "yes"}: + return strictest + if answer in {"n", "no"}: + return max(values) + try: + selected = int(answer) + except ValueError as exc: + raise ValueError(f"invalid rate-limit choice: {answer!r}") from exc + if selected not in values: + raise ValueError(f"rate-limit choice must be one of: {choices}") + return selected + + +def _required_headers(requirements: dict[str, Any], username: str) -> dict[str, str]: + headers: dict[str, str] = {} + request_header = requirements.get("requestHeader") + if isinstance(request_header, str) and request_header.strip(): + if ":" not in request_header: + raise ValueError("requestHeader must use 'Name: value' format") + name, value = (part.strip() for part in request_header.split(":", 1)) + value = USERNAME_PLACEHOLDER_RE.sub(username, value) + if not name or not value: + raise ValueError("requestHeader must contain a non-empty name and value") + headers[name] = value + user_agent = requirements.get("userAgent") + if isinstance(user_agent, str) and user_agent.strip(): + headers["User-Agent"] = USERNAME_PLACEHOLDER_RE.sub(username, user_agent.strip()) + return headers + + +def parse_roe( + program: dict[str, Any], + username: str, + *, + assume_yes: bool = False, + input_func: Callable[[str], str] = input, +) -> dict[str, Any]: + structured = _latest_structured_roe(program) + requirements = structured["testingRequirements"] + automated = requirements.get("automatedTooling") + structured_rate = ( + int(automated) + if isinstance(automated, int) and not isinstance(automated, bool) and automated > 0 + else None + ) + prose = "\n\n".join( + filter( + None, + ( + str(program.get("description") or ""), + _snapshot_markdown(program, "inScopes"), + _snapshot_markdown(program, "outOfScopes"), + str(structured.get("description") or ""), + ), + ) + ) + prose_rates = [int(match.group(1)) for match in RATE_RE.finditer(prose) if int(match.group(1)) > 0] + rate_values = ([structured_rate] if structured_rate is not None else []) + prose_rates + unique_rates = sorted(set(rate_values)) + if len(unique_rates) > 1 and not assume_yes: + rate_limit = _choose_rate_limit(unique_rates, input_func) + else: + rate_limit = min(unique_rates) if unique_rates else None + return { + "rate_limit": rate_limit, + "required_headers": _required_headers(requirements, username), + "intigriti_me": requirements.get("intigritiMe") is True, + "safe_harbour": structured.get("safeHarbour") is True, + "description": str(structured.get("description") or "").strip(), + "scanner_prohibited": bool(re.search(r"automatic scanners?|automated scans?", prose, re.IGNORECASE)), + } + + +def _employee_account_prose(program: dict[str, Any]) -> str: + blocks: list[str] = [] + in_scope = _snapshot_markdown(program, "inScopes") + heading = re.search(r"(?im)^#{1,6}\s+Employee user accounts\s*$", in_scope) + if heading: + section = in_scope[heading.start() :] + next_heading = re.search(r"(?im)^#{1,6}\s+", section[heading.end() - heading.start() :]) + if next_heading: + section = section[: heading.end() - heading.start() + next_heading.start()] + blocks.append(section.strip()) + + assets = _latest_snapshot(program, "assetsCollection").get("assetsAndGroups") + if isinstance(assets, list): + for asset in assets: + if not isinstance(asset, dict) or asset.get("typeId") != 6: + continue + text = "\n".join(str(asset.get(key) or "") for key in ("name", "description")).strip() + if re.search(r"\baccounts?\b", text, re.IGNORECASE): + blocks.append(text) + return "\n\n".join(dict.fromkeys(blocks)) + + +def _prose_domain_carveouts(account_prose: str, targets: list[str]) -> list[str]: + candidates: list[str] = [] + for match in DOMAIN_RE.finditer(account_prose): + candidate = match.group(0).lower().rstrip(".") + if candidate in targets: + continue + exact = candidate[2:] if candidate.startswith("*.") else candidate + if any(pattern_matches(exact, target) for target in targets): + continue + if candidate not in candidates: + candidates.append(candidate) + return candidates + + +def _confirm_domain_carveouts(candidates: list[str], input_func: Callable[[str], str]) -> list[str]: + answer = input_func( + "Add prose-only domain/account carve-outs to out_of_scope " + f"({', '.join(candidates)})? [Y/n]: " + ).strip().lower() + if answer in {"", "y", "yes"}: + return candidates + if answer in {"n", "no"}: + return [] + raise ValueError(f"invalid out-of-scope choice: {answer!r}") + + +def _key_objectives(in_scope: str) -> list[str]: + marker = re.search(r"(?im)^\*\*Key objectives\*\*\s*:\s*$", in_scope) + if not marker: + return [] + objectives: list[str] = [] + for line in in_scope[marker.end() :].splitlines(): + stripped = line.strip() + if not stripped: + if objectives: + break + continue + bullet = re.match(r"^[*-]\s+(.+)$", stripped) + if not bullet: + if objectives: + break + continue + objectives.append(bullet.group(1).strip()) + return objectives + + +def _tier_objective(tier_name: str, floor: str, patterns: list[str]) -> str: + joined = ", ".join(patterns) + if floor == "low": + return f"{tier_name}: test the full Low-to-Exceptional severity range on {joined}" + return f"{tier_name}: report only {floor.title()}-or-higher findings on {joined}" + + +def _build_notes( + program: dict[str, Any], + scope: dict[str, Any], + roe: dict[str, Any], + account_prose: str, +) -> str: + name = str(program.get("name") or program.get("handle") or "unknown") + sections = [f'Program: Intigriti public "{name}".'] + if roe["safe_harbour"]: + sections[0] += " Safe harbour applies." + requirements: list[str] = [] + for header, value in roe["required_headers"].items(): + requirements.append(f"- Mandatory request header: {header}: {value}") + if roe["rate_limit"] is not None: + requirements.append(f"- Automated tooling rate cap: {roe['rate_limit']} requests/second") + if roe["scanner_prohibited"]: + requirements.append("- Automatic vulnerability scanners are prohibited; use targeted, low-load testing") + if roe["intigriti_me"]: + requirements.append("- Test accounts must be registered with an @intigriti.me email address") + if requirements: + sections.append("Mandatory testing requirements:\n" + "\n".join(requirements)) + if account_prose: + sections.append("Domain/account carve-outs from program prose:\n" + account_prose) + if scope["non_web"]: + sections.append("In-scope non-web assets (not emitted as web targets):\n" + "\n".join(f"- {item}" for item in scope["non_web"])) + if scope["unknown_notes"]: + sections.append("Unknown asset types kept out of web targets:\n" + "\n".join(f"- {item}" for item in scope["unknown_notes"])) + out_of_scope = _snapshot_markdown(program, "outOfScopes").strip() + if out_of_scope: + sections.append("Non-qualifying / out-of-scope vulnerability classes (verbatim from Intigriti):\n" + out_of_scope) + return ("\n\n".join(sections).strip() + "\n").expandtabs(2) + + +def build_yaml( + program: dict[str, Any], + username: str, + *, + assume_yes: bool = False, + input_func: Callable[[str], str] = input, +) -> dict[str, Any]: + tiers = parse_tiers(program) + scope = parse_scope(program, tiers, assume_yes=assume_yes, input_func=input_func) + roe = parse_roe(program, username, assume_yes=assume_yes, input_func=input_func) + account_prose = _employee_account_prose(program) + candidates = _prose_domain_carveouts(account_prose, scope["targets"]) + out_of_scope = ( + candidates + if assume_yes or not candidates + else _confirm_domain_carveouts(candidates, input_func) + ) + + yaml_tiers: dict[str, dict[str, Any]] = {} + objectives = _key_objectives(_snapshot_markdown(program, "inScopes")) + for tier_id, tier in tiers.items(): + patterns = scope["tier_patterns"].get(tier_id, []) + if not patterns: + continue + tier_name = f"tier_{tier_id}" + yaml_tiers[tier_name] = { + "report_floor": tier["report_floor"], + "patterns": patterns, + } + objectives.append(_tier_objective(tier_name, tier["report_floor"], patterns)) + + program_name = str(program.get("name") or program.get("handle") or "Intigriti program") + description = str(program.get("description") or "").strip() + intent = f"Authorized black-box web and API security testing of {program_name} under its public Intigriti program." + if roe["safe_harbour"]: + intent += " Safe harbour applies." + if description: + intent += f" Program context: {description}" + + generated: dict[str, Any] = { + "name": str(program.get("handle") or "").strip(), + "targets": scope["targets"], + "out_of_scope": out_of_scope, + "egress_support": [], + "tiers": yaml_tiers, + "source_path": None, + "source_ref": None, + "audit_include": [], + "audit_exclude": ["node_modules", "vendor", "dist", ".git"], + "intent": intent, + "objectives": objectives, + } + for gate in ROE_GATES: + generated[gate] = False + rate_limit = roe["rate_limit"] + generated["rate_limit_enabled"] = rate_limit is not None + if rate_limit is not None: + generated["rate_limit"] = { + "requests_per_second": rate_limit, + "burst": rate_limit, + "max_concurrency": 2, + "per_tool": { + tool: {"requests_per_second": 2, "burst": 2, "max_concurrency": 1} + for tool in ("nuclei", "ffuf", "schemathesis") + }, + } + generated.update( + { + "max_threads": 2, + "time_window": "any", + "retention": {"status": "active", "owner": username, "expires_at": ""}, + "required_headers": roe["required_headers"], + "oob_host": "", + "test_credentials": [], + "notes": _build_notes(program, scope, roe, account_prose), + } + ) + return generated + + +def render_yaml(generated: dict[str, Any]) -> str: + return yaml.dump(generated, Dumper=DraftDumper, sort_keys=False, allow_unicode=True) + + +def fetch_program(company: str, program: str) -> dict[str, Any]: + url = ( + "https://app.intigriti.com/api/core/public/programs/" + f"{quote(company, safe='')}/{quote(program, safe='')}" + ) + request = urllib.request.Request(url, headers={"User-Agent": "web-pentest-harness-intigriti-parser/1"}) + try: + # URL origin and scheme are fixed above; only validated handles are interpolated. + with urllib.request.urlopen(request, timeout=30) as response: # nosec B310 + payload = response.read() + except urllib.error.HTTPError as exc: + if exc.code in {401, 403}: + raise ValueError( + "program is not public (needs authentication) β€” this tool handles public programs only" + ) from exc + raise ValueError(f"Intigriti API returned HTTP {exc.code}") from exc + except urllib.error.URLError as exc: + raise ValueError(f"cannot fetch Intigriti program: {exc.reason}") from exc + try: + parsed = json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("Intigriti API returned a non-JSON response") from exc + if not isinstance(parsed, dict): + raise ValueError("Intigriti API returned an invalid program document") + return parsed + + +def _load_program_file(path: Path) -> dict[str, Any]: + try: + parsed = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ValueError(f"cannot read JSON fixture {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise ValueError(f"saved program is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise ValueError("saved program JSON root must be an object") + return parsed + + +def _validate_rendered(rendered: str) -> None: + with tempfile.TemporaryDirectory(prefix="intigriti-draft-") as temp_dir: + candidate = Path(temp_dir) / "engagement.yaml" + candidate.write_text(rendered, encoding="utf-8") + load_engagement(candidate) + + +def _write_draft(output: Path, rendered: str, *, force: bool) -> None: + if output.exists() and not force: + raise ValueError(f"{output} already exists; pass --force to overwrite it") + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp") + try: + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(rendered) + os.replace(temporary, output) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def _argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("program", help="Intigriti program URL or company/program slug") + parser.add_argument("--username", required=True, help="Intigriti username for required header templates") + parser.add_argument("--out", type=Path, help="output directory (default: engagements//)") + parser.add_argument("--from-file", type=Path, metavar="JSON", help="parse saved public program JSON") + parser.add_argument("--yes", action="store_true", help="accept safe defaults without prompting") + parser.add_argument("--force", action="store_true", help="overwrite an existing engagement.yaml") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _argument_parser().parse_args(argv) + try: + company, program_handle = parse_program_ref(args.program) + program = _load_program_file(args.from_file) if args.from_file else fetch_program(company, program_handle) + generated = build_yaml(program, args.username, assume_yes=args.yes) + rendered = render_yaml(generated) + _validate_rendered(rendered) + handle = generated["name"] or program_handle + out_dir = args.out if args.out is not None else ROOT / "engagements" / handle + output = out_dir / "engagement.yaml" + _write_draft(output, rendered, force=args.force) + except (ValueError, ConfigError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + print(f"Draft engagement written: {output}") + print( + f"Summary: {len(generated['targets'])} web targets, " + f"{len(generated['tiers'])} paying tiers, " + f"{len(generated['out_of_scope'])} explicit carve-outs." + ) + print("Review the draft before activation; .active_engagement was not changed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/intigriti_heretechnologies.json b/tests/fixtures/intigriti_heretechnologies.json new file mode 100644 index 0000000..7d52767 --- /dev/null +++ b/tests/fixtures/intigriti_heretechnologies.json @@ -0,0 +1,553 @@ +{ + "_fixtureMetadata": { + "source": "Intigriti public program API", + "sanitized": true, + "note": "Only parser-relevant public program fields are retained; researcher activity, user identifiers, asset identifiers, and platform metadata are omitted." + }, + "description": "HERE Technologies, is a global company that’s rooted in the evolution of digital maps and location technology. We offer a location data and technology platform, that moves people, businesses and cities forward by harnessing the power of location. The HERE platform caters to a variety of tasks related to bringing your own data, map, service, logic and algorithms for location enrichment.", + "status": 3, + "companyHandle": "heretechnologies", + "handle": "heretechnologies", + "name": "Here Technologies", + "assetsCollection": [ + { + "createdAt": 1738848944, + "content": { + "assetsAndGroups": [ + { + "typeId": 7, + "name": "*.account.api.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 7, + "name": "*.account.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 7, + "name": "*.mobilitygraph.hereapi.com", + "bountyTierId": 3, + "description": "Including, but not limited to, following applications:\nhttps://predictor.mobilitygraph.hereapi.com/\nhttps://profile.mobilitygraph.hereapi.com/\nhttps://subscription.mobilitygraph.hereapi.com/\n" + }, + { + "typeId": 7, + "name": "*.router.hereapi.com", + "bountyTierId": 3, + "description": "Including, but not limited to, following applications:\nhttps://matrix.router.hereapi.com/\nhttps://subp-als.matrix.router.hereapi.com/v8/matrix\nhttps://transit.router.hereapi.com/\nhttps://intermodal.router.hereapi.com/" + }, + { + "typeId": 7, + "name": "*.scbe.api.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 7, + "name": "*.subp-router.hereapi.com", + "bountyTierId": 3, + "description": "Including, but not limited to, following application:\nhttps://vip-als.subp-router.hereapi.com/\n" + }, + { + "typeId": 3, + "name": "955837609", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 2, + "name": "com.here.app.maps", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 1, + "name": "https://jaguar.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 1, + "name": "https://landrover.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 6, + "name": "Leaked/compromised employee accounts *.here.com", + "bountyTierId": 3, + "description": "Any leaked/compromised employee accounts within the domain @here.com for any service in domains *.here.com (excluding unverified accounts on account.here.com) and here.okta.com, for example @here.com account for in.here.com. If you come across any such accounts, whether compromised due to malware infections, security breaches, or any other cause, please notify us. Depending on account setup and environment valid (belongs to HERE employee and active) and non-duplicate reports will be accepted with Low/Medium severity. Please make sure to provide as much information as you can on the source and the reason of account compromise, for example - date of compromise, hostname, stealer id, source you obtained info from.\nPlease use the CSV format for all credentials. For example:\n\n`username, password, service, source, date_of_leak`\n\nIf the exact `date_of_leak` is unknown, use \"unknown\" instead.\n\n" + }, + { + "typeId": 7, + "name": "*.here.com", + "bountyTierId": 2, + "description": "This scope (*.here.com) is for Log4J and Spring4Shell RCE vulnerabilities only." + }, + { + "typeId": 7, + "name": "*.hereapi.com", + "bountyTierId": 2, + "description": "This scope (*.hereapi.com) is for Log4J and Spring4Shell RCE vulnerabilities only." + } + ] + } + }, + { + "createdAt": 1738849281, + "content": { + "assetsAndGroups": [ + { + "typeId": 7, + "name": "*.account.api.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 7, + "name": "*.account.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 7, + "name": "*.mobilitygraph.hereapi.com", + "bountyTierId": 3, + "description": "Including, but not limited to, following applications:\nhttps://predictor.mobilitygraph.hereapi.com/\nhttps://profile.mobilitygraph.hereapi.com/\nhttps://subscription.mobilitygraph.hereapi.com/\n" + }, + { + "typeId": 7, + "name": "*.router.hereapi.com", + "bountyTierId": 3, + "description": "Including, but not limited to, following applications:\nhttps://matrix.router.hereapi.com/\nhttps://subp-als.matrix.router.hereapi.com/v8/matrix\nhttps://transit.router.hereapi.com/\nhttps://intermodal.router.hereapi.com/" + }, + { + "typeId": 7, + "name": "*.scbe.api.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 7, + "name": "*.subp-router.hereapi.com", + "bountyTierId": 3, + "description": "Including, but not limited to, following application:\nhttps://vip-als.subp-router.hereapi.com/\n" + }, + { + "typeId": 3, + "name": "955837609", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 2, + "name": "com.here.app.maps", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 1, + "name": "https://jaguar.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 1, + "name": "https://landrover.here.com", + "bountyTierId": 3, + "description": null + }, + { + "typeId": 6, + "name": "Leaked/compromised employee accounts *.here.com", + "bountyTierId": 3, + "description": "Any leaked/compromised employee accounts within the domain @here.com for any service in domains *.here.com (excluding unverified accounts on account.here.com) and here.okta.com, for example @here.com account for in.here.com. If you come across any such accounts, whether compromised due to malware infections, security breaches, or any other cause, please notify us. Depending on account setup and environment valid (belongs to HERE employee and active) and non-duplicate reports will be accepted with Low/Medium severity. Please make sure to provide as much information as you can on the source and the reason of account compromise, for example - date of compromise, hostname, stealer id, source you obtained info from.\n\nPlease use the CSV format for all credentials. For example:\n\n`username, password, service, source, date_of_leak`\n\nIf the exact `date_of_leak` is unknown, use \"unknown\" instead.\n\n" + }, + { + "typeId": 7, + "name": "*.here.com", + "bountyTierId": 2, + "description": "This scope (*.here.com) is for Log4J and Spring4Shell RCE vulnerabilities only." + }, + { + "typeId": 7, + "name": "*.hereapi.com", + "bountyTierId": 2, + "description": "This scope (*.hereapi.com) is for Log4J and Spring4Shell RCE vulnerabilities only." + } + ] + } + } + ], + "bountyTables": [ + { + "createdAt": 1665068453, + "content": { + "currency": "EUR", + "bountyRows": [ + { + "bountyRanges": [ + { + "minScore": 0.1, + "maxScore": 3.9, + "minBounty": { + "value": 15.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 15.0, + "currency": "EUR" + } + }, + { + "minScore": 4.0, + "maxScore": 6.9, + "minBounty": { + "value": 250.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 250.0, + "currency": "EUR" + } + }, + { + "minScore": 7.0, + "maxScore": 8.9, + "minBounty": { + "value": 1000.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 1000.0, + "currency": "EUR" + } + }, + { + "minScore": 9.0, + "maxScore": 9.4, + "minBounty": { + "value": 1600.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 1600.0, + "currency": "EUR" + } + }, + { + "minScore": 9.5, + "maxScore": 10.0, + "minBounty": { + "value": 2000.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 2000.0, + "currency": "EUR" + } + } + ], + "bountyTierId": 3 + }, + { + "bountyRanges": [ + { + "minScore": 0.1, + "maxScore": 3.9, + "minBounty": { + "value": 0.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 0.0, + "currency": "EUR" + } + }, + { + "minScore": 4.0, + "maxScore": 6.9, + "minBounty": { + "value": 0.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 0.0, + "currency": "EUR" + } + }, + { + "minScore": 7.0, + "maxScore": 8.9, + "minBounty": { + "value": 0.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 0.0, + "currency": "EUR" + } + }, + { + "minScore": 9.0, + "maxScore": 9.4, + "minBounty": { + "value": 0.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 0.0, + "currency": "EUR" + } + }, + { + "minScore": 9.5, + "maxScore": 10.0, + "minBounty": { + "value": 1000.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 1000.0, + "currency": "EUR" + } + } + ], + "bountyTierId": 2 + } + ], + "rewardPolicy": null + } + }, + { + "createdAt": 1705705701, + "content": { + "currency": "EUR", + "bountyRows": [ + { + "bountyRanges": [ + { + "minScore": 0.1, + "maxScore": 3.9, + "minBounty": { + "value": 50.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 50.0, + "currency": "EUR" + } + }, + { + "minScore": 4.0, + "maxScore": 6.9, + "minBounty": { + "value": 250.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 250.0, + "currency": "EUR" + } + }, + { + "minScore": 7.0, + "maxScore": 8.9, + "minBounty": { + "value": 1000.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 1000.0, + "currency": "EUR" + } + }, + { + "minScore": 9.0, + "maxScore": 9.4, + "minBounty": { + "value": 1600.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 1600.0, + "currency": "EUR" + } + }, + { + "minScore": 9.5, + "maxScore": 10.0, + "minBounty": { + "value": 2000.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 2000.0, + "currency": "EUR" + } + } + ], + "bountyTierId": 3 + }, + { + "bountyRanges": [ + { + "minScore": 0.1, + "maxScore": 3.9, + "minBounty": { + "value": 0.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 0.0, + "currency": "EUR" + } + }, + { + "minScore": 4.0, + "maxScore": 6.9, + "minBounty": { + "value": 0.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 0.0, + "currency": "EUR" + } + }, + { + "minScore": 7.0, + "maxScore": 8.9, + "minBounty": { + "value": 0.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 0.0, + "currency": "EUR" + } + }, + { + "minScore": 9.0, + "maxScore": 9.4, + "minBounty": { + "value": 0.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 0.0, + "currency": "EUR" + } + }, + { + "minScore": 9.5, + "maxScore": 10.0, + "minBounty": { + "value": 1000.0, + "currency": "EUR" + }, + "maxBounty": { + "value": 1000.0, + "currency": "EUR" + } + } + ], + "bountyTierId": 2 + } + ], + "rewardPolicy": null + } + } + ], + "rulesOfEngagements": [ + { + "createdAt": 1630996471, + "content": { + "content": { + "description": "\n#### Our promise to you\nWe know how it could be frustrating sometimes to wait for a response after submitting a vulnerability report (been there) and we want to ensure you that we will be in touch with you within following timelines:\n* 2 business days for every exceptional and critical severity issue you report\n* 5 business days for every high severity issue\n*10 business days for every medium severity issue\n*15 business days for every low severity issue\n\nDepending on issue severity our promise is to be in touch with you on regular bases to provide updates from our side.\n\n* We are happy to respond to any questions, please use the button in the right top corner for this.\n* We respect the safe harbor clause that you can find below\n\n#### Your promise to us\n\n* All reports should be written in English\n* Provide detailed but to the point reproduction steps\n* Include a clear attack scenario. How will this affect us exactly?\n* Remember: quality over quantity!\n* Please do *not* discuss or post vulnerabilities without our consent (including PoC's on YouTube and Vimeo)\n* Please do *not* use automatic scanners (be creative and do it yourself!). We cannot accept any submissions found by using automatic scanners. Scanners also won't improve your skills, and can cause a high server load (we'd like to put our time in thanking researchers rather than blocking their IP's πŸ˜‰)\n", + "testingRequirements": null, + "safeHarbour": true, + "createdAt": 1630996471 + } + } + }, + { + "createdAt": 1633438119, + "content": { + "content": { + "description": "\n#### Our promise to you\nWe know how it could be frustrating sometimes to wait for a response after submitting a vulnerability report (been there) and we want to ensure you that we will be in touch with you within following timelines:\n* 2 business days for every exceptional and critical severity issue you report\n* 5 business days for every high severity issue\n*10 business days for every medium severity issue\n*15 business days for every low severity issue\n\nDepending on issue severity our promise is to be in touch with you on regular bases to provide updates from our side.\n\n* We are happy to respond to any questions, please use the button in the right top corner for this.\n* We respect the safe harbor clause that you can find below\n\n#### Your promise to us\n\n* All reports should be written in English\n* Provide detailed but to the point reproduction steps\n* Include a clear attack scenario. How will this affect us exactly?\n* Remember: quality over quantity!\n* Please do *not* discuss or post vulnerabilities without our consent (including PoC's on YouTube and Vimeo)\n* Please do *not* use automatic scanners (be creative and do it yourself!). We cannot accept any submissions found by using automatic scanners. Scanners also won't improve your skills, and can cause a high server load (we'd like to put our time in thanking researchers rather than blocking their IP's πŸ˜‰)\n", + "testingRequirements": { + "intigritiMe": true, + "automatedTooling": 5, + "userAgent": null, + "requestHeader": null + }, + "safeHarbour": true, + "createdAt": 1633438119 + } + } + }, + { + "createdAt": 1655797963, + "content": { + "content": { + "description": "\n#### Our promise to you\nWe know how it could be frustrating sometimes to wait for a response after submitting a vulnerability report (been there) and we want to ensure you that we will be in touch with you within following timelines:\n* 2 business days for every exceptional and critical severity issue you report\n* 5 business days for every high severity issue\n* 10 business days for every medium severity issue\n* 15 business days for every low severity issue\n\nDepending on issue severity our promise is to be in touch with you on regular bases to provide updates from our side.\n\n* We are happy to respond to any questions, please use the button in the right top corner for this.\n* We respect the safe harbor clause that you can find below\n\n#### Your promise to us\n\n* All reports should be written in English\n* Provide detailed but to the point reproduction steps\n* Include a clear attack scenario. How will this affect us exactly?\n* Remember: quality over quantity!\n* Please do *not* discuss or post vulnerabilities without our consent (including PoC's on YouTube and Vimeo)\n* Please do *not* use automatic scanners (be creative and do it yourself!). We cannot accept any submissions found by using automatic scanners. Scanners also won't improve your skills, and can cause a high server load (we'd like to put our time in thanking researchers rather than blocking their IP's πŸ˜‰)\n", + "testingRequirements": { + "intigritiMe": true, + "automatedTooling": 5, + "userAgent": null, + "requestHeader": null + }, + "safeHarbour": true, + "createdAt": 1655797963 + } + } + }, + { + "createdAt": 1738849006, + "content": { + "content": { + "description": "\n#### Our promise to you\nWe know how it could be frustrating sometimes to wait for a response after submitting a vulnerability report (been there) and we want to ensure you that we will be in touch with you within following timelines:\n* 2 business days for every exceptional and critical severity issue you report\n* 5 business days for every high severity issue\n* 10 business days for every medium severity issue\n* 15 business days for every low severity issue\n\nDepending on issue severity our promise is to be in touch with you on regular bases to provide updates from our side.\n\n* We are happy to respond to any questions, please use the button in the right top corner for this.\n* We respect the safe harbor clause that you can find below\n\n#### Your promise to us\n\n* All reports should be written in English\n* Provide detailed but to the point reproduction steps\n* Include a clear attack scenario. How will this affect us exactly?\n* Remember: quality over quantity!\n* Please do *not* discuss or post vulnerabilities without our consent (including PoC's on YouTube and Vimeo)\n* Please do *not* use automatic scanners (be creative and do it yourself!). We cannot accept any submissions found by using automatic scanners. Scanners also won't improve your skills, and can cause a high server load (we'd like to put our time in thanking researchers rather than blocking their IP's πŸ˜‰)\n", + "testingRequirements": { + "intigritiMe": true, + "automatedTooling": 5, + "userAgent": "", + "requestHeader": "X-Bug-Bounty: " + }, + "safeHarbour": true, + "createdAt": 1738849006 + } + } + } + ], + "inScopes": [ + { + "createdAt": 1721239221, + "content": { + "content": "We are happy to announce our first bug bounty program! We've done our best to clean most of our known critical and high severity issues and now would like to request your help to spot the ones we missed!\n\n#### Application\n\n**Key objectives**:\n* Authentication bypass\n* Authorization bypass\n* Infrastructure access\n\nWe are particularly interested, but not limited to, to find out how one can exploit our systems to:\n \n* Horizontal or vertical privilege escalation (account takeover, auth bypass, etc)\n* XSS (no self-xss and only on latest versions of browsers)\n* RCE\n* Injection (SQL/NoSQL/LDAP/etc)\n* Broken authentication\n* Sensitive data exposure\n* XXE\n* Broken Access Control\n* Insecure Deserialization\n\n#### Mobile\n\nWhat we explicitly interested in, bit not limited to:\n* SSO implementation\n* Exposed data (Shared libraries, Exported Components, etc)\n* Issues with deep links\n* Forgotten dev/debug/etc API endpoints not specifically related to the application's features.\n* Information disclosure through APIs\n* Client-side code injections\n* Authorization issues\n* Authentication issues\n* User data stored in public directory\n* Hardcoded secrets (used for sensitive actions)\n\n#### Employee user accounts\n* Employee accounts within the domain @here.com for any service in domains *.here.com (excluding unverified accounts on account.here.com) and here.okta.com.\n" + } + }, + { + "createdAt": 1721239795, + "content": { + "content": "We are happy to announce our first bug bounty program! We've done our best to clean most of our known critical and high severity issues and now would like to request your help to spot the ones we missed!\n\n#### Application\n\n**Key objectives**:\n* Authentication bypass\n* Authorization bypass\n* Infrastructure access\n\nWe are particularly interested, but not limited to, to find out how one can exploit our systems to:\n \n* Horizontal or vertical privilege escalation (account takeover, auth bypass, etc)\n* XSS (no self-xss and only on latest versions of browsers)\n* RCE\n* Injection (SQL/NoSQL/LDAP/etc)\n* Broken authentication\n* Sensitive data exposure\n* XXE\n* Broken Access Control\n* Insecure Deserialization\n\n#### Mobile\n\nWhat we explicitly interested in, bit not limited to:\n* SSO implementation\n* Exposed data (Shared libraries, Exported Components, etc)\n* Issues with deep links\n* Forgotten dev/debug/etc API endpoints not specifically related to the application's features.\n* Information disclosure through APIs\n* Client-side code injections\n* Authorization issues\n* Authentication issues\n* User data stored in public directory\n* Hardcoded secrets (used for sensitive actions)\n\n#### Employee user accounts\n* Active HERE employee accounts within the domain @here.com for any service in domains *.here.com (excluding unverified accounts on account.here.com) and here.okta.com.\n" + } + } + ], + "outOfScopes": [ + { + "createdAt": 1721239112, + "content": { + "content": "#### Application\n\n* API key disclosure without proven business impact\n* Wordpress usernames disclosure\n * Pre-Auth Account takeover/OAuth squatting\n * Self-XSS that cannot be used to exploit other users\n * Verbose messages/files/directory listings without disclosing any sensitive information\n * CORS misconfiguration on non-sensitive endpoints\n * Missing cookie flags\n * Missing security headers\n * Cross-site Request Forgery with no or low impact\n * Presence of autocomplete attribute on web forms\n * Reverse tabnabbing\n * Bypassing rate-limits or the non-existence of rate-limits.\n * Best practices violations (password complexity, expiration, re-use, etc.)\n * Clickjacking without proven impact/unrealistic user interaction\n * CSV Injection\n * Sessions not being invalidated (logout, enabling 2FA, etc.)\n * Tokens leaked to third parties\n * Anything related to email spoofing, SPF, DMARC or DKIM\n * Content injection without being able to modify the HTML\n * Username/email enumeration\n * Email bombing\n * HTTP Request smuggling without any proven impact\n * Homograph attacks\n * XMLRPC enabled\n * Banner grabbing/Version disclosure\n * Not stripping metadata of files\n * Same-site scripting\n * Subdomain takeover without taking over the subdomain\n * Arbitrary file upload without proof of the existence of the uploaded file\n * Blind SSRF without proven business impact (pingbacks are not sufficient)\n * Disclosed/misconfigured Google Maps API keys\n * Host header injection without proven business impact\n\n#### General\n\n * In case that a reported vulnerability was already known to the company from their own tests, it will be flagged as a duplicate\n * Theoretical security issues with no realistic exploit scenario(s) or attack surfaces, or issues that would require complex end user interactions to be exploited\n * Spam, social engineering and physical intrusion\n * DoS/DDoS attacks or brute force attacks\n * Vulnerabilities that only work on software that no longer receive security updates\n * Attacks requiring physical access to a victim's computer/device, man in the middle or compromised customer user accounts\n * Recently discovered zero-day vulnerabilities found in in-scope assets within 14 days after the public release of a patch or mitigation may be reported, but are usually not eligible for a bounty\n * Reports that state that software is out of date/vulnerable without a proof-of-concept\n\n#### Mobile\n\n * Shared links leaked through the system clipboard\n * Any URIs leaked because a malicious app has permission to view URIs opened\n * The absence of certificate pinning\n * Sensitive data in URLs/request bodies when protected by TLS\n * Lack of obfuscation\n * Path disclosure in the binary\n * Lack of jailbreak & root detection\n * Crashes due to malformed URL Schemes\n * Lack of binary protection (anti-debugging) controls, mobile SSL pinning\n * Snapshot/Pasteboard leakage\n * Runtime hacking exploits (exploits only possible in a jailbroken environment)\n * API key leakage used for insensitive activities/actions\n\n#### The following tests are prohibited\n* **Brute Force / Automated Scans**\n\t- It is prohibited to perform automated scans / brute force attempts. More specifically sending multiple automated requests trying different combinations/characters/.. on a single endpoint. (e.g. login form, search form, contact form,.. ) If you would like to perform such a test and have good reason to believe that there is a vulnerabilty please let us know.\n\t- It is prohibited to perform more than 10 requests per second.\n* **Denial of Service**\n\t- Denial of Service\n\t- Distributed Denial of Service\n\t- Any similar high load testing\n\n#### Out of scope domains\n\n * Any domain that is not listed in the Domains section, is out of scope for this program" + } + }, + { + "createdAt": 1774453763, + "content": { + "content": "#### Application\n\n* API key disclosure without proven business impact\n* Wordpress usernames disclosure\n* SSL/TLS misconfigurations and best-practice violations without clear server-side impact\n * Pre-Auth Account takeover/OAuth squatting\n * Self-XSS that cannot be used to exploit other users\n * Verbose messages/files/directory listings without disclosing any sensitive information\n * CORS misconfiguration on non-sensitive endpoints\n * Missing cookie flags\n * Missing security headers\n * Cross-site Request Forgery with no or low impact\n * Presence of autocomplete attribute on web forms\n * Reverse tabnabbing\n * Bypassing rate-limits or the non-existence of rate-limits.\n * Best practices violations (password complexity, expiration, re-use, etc.)\n * Clickjacking without proven impact/unrealistic user interaction\n * CSV Injection\n * Sessions not being invalidated (logout, enabling 2FA, etc.)\n * Tokens leaked to third parties\n * Anything related to email spoofing, SPF, DMARC or DKIM\n * Content injection without being able to modify the HTML\n * Username/email enumeration\n * Email bombing\n * HTTP Request smuggling without any proven impact\n * Homograph attacks\n * XMLRPC enabled\n * Banner grabbing/Version disclosure\n * Not stripping metadata of files\n * Same-site scripting\n * Subdomain takeover without taking over the subdomain\n * Arbitrary file upload without proof of the existence of the uploaded file\n * Blind SSRF without proven business impact (pingbacks are not sufficient)\n * Disclosed/misconfigured Google Maps API keys\n * Host header injection without proven business impact\n\n#### General\n\n * In case that a reported vulnerability was already known to the company from their own tests, it will be flagged as a duplicate\n * Theoretical security issues with no realistic exploit scenario(s) or attack surfaces, or issues that would require complex end user interactions to be exploited\n * Spam, social engineering and physical intrusion\n * DoS/DDoS attacks or brute force attacks\n * Vulnerabilities that only work on software that no longer receive security updates\n * Attacks requiring physical access to a victim's computer/device, man in the middle or compromised customer user accounts\n * Recently discovered zero-day vulnerabilities found in in-scope assets within 14 days after the public release of a patch or mitigation may be reported, but are usually not eligible for a bounty\n * Reports that state that software is out of date/vulnerable without a proof-of-concept\n\n#### Mobile\n\n * Shared links leaked through the system clipboard\n * Any URIs leaked because a malicious app has permission to view URIs opened\n * The absence of certificate pinning\n * Sensitive data in URLs/request bodies when protected by TLS\n * Lack of obfuscation\n * Path disclosure in the binary\n * Lack of jailbreak & root detection\n * Crashes due to malformed URL Schemes\n * Lack of binary protection (anti-debugging) controls, mobile SSL pinning\n * Snapshot/Pasteboard leakage\n * Runtime hacking exploits (exploits only possible in a jailbroken environment)\n * API key leakage used for insensitive activities/actions\n\n#### The following tests are prohibited\n* **Brute Force / Automated Scans**\n\t- It is prohibited to perform automated scans / brute force attempts. More specifically sending multiple automated requests trying different combinations/characters/.. on a single endpoint. (e.g. login form, search form, contact form,.. ) If you would like to perform such a test and have good reason to believe that there is a vulnerabilty please let us know.\n\t- It is prohibited to perform more than 10 requests per second.\n* **Denial of Service**\n\t- Denial of Service\n\t- Distributed Denial of Service\n\t- Any similar high load testing\n\n#### Out of scope domains\n\n * Any domain that is not listed in the Domains section, is out of scope for this program" + } + } + ] +} diff --git a/tests/test_parse_intigriti.py b/tests/test_parse_intigriti.py new file mode 100644 index 0000000..86c2277 --- /dev/null +++ b/tests/test_parse_intigriti.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Offline contract tests for the Intigriti engagement parser.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +from lib.harness_config import load_engagement, scope_decision + + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "parse_intigriti.py" +FIXTURE = ROOT / "tests" / "fixtures" / "intigriti_heretechnologies.json" +TEST_USERNAME = "synthetic-researcher" + + +def load_module(): + spec = importlib.util.spec_from_file_location("parse_intigriti", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class IntigritiParserTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = load_module() + cls.program = json.loads(FIXTURE.read_text(encoding="utf-8")) + + def test_parser_entry_point_exists(self) -> None: + self.assertTrue(SCRIPT.is_file()) + + def test_parser_api_is_available(self) -> None: + for name in ("parse_program_ref", "parse_tiers", "parse_scope", "parse_roe", "build_yaml"): + self.assertTrue(hasattr(self.module, name), name) + + def generated(self) -> dict: + return self.module.build_yaml(self.program, TEST_USERNAME, assume_yes=True) + + def test_fixture_is_minimized_and_contains_no_secret_material(self) -> None: + self.assertNotIn("lastContributors", self.program) + self.assertNotIn("lastActivity", self.program) + self.assertNotIn("programId", self.program) + forbidden_keys = { + "authorization", "cookie", "password", "passwd", "secret", + "token", "accesstoken", "refreshtoken", "apikey", "privatekey", + } + + def keys(value): + if isinstance(value, dict): + for key, child in value.items(): + yield str(key).replace("_", "").replace("-", "").casefold() + yield from keys(child) + elif isinstance(value, list): + for child in value: + yield from keys(child) + + self.assertEqual(forbidden_keys.intersection(keys(self.program)), set()) + raw = FIXTURE.read_text(encoding="utf-8") + secret_patterns = ( + r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", + r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b", + r"\bBearer\s+[A-Za-z0-9._~+/=-]{12,}", + r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", + ) + for pattern in secret_patterns: + self.assertIsNone(re.search(pattern, raw, re.IGNORECASE), pattern) + + def test_program_reference_accepts_slug_and_detail_url_only(self) -> None: + expected = ("heretechnologies", "heretechnologies") + self.assertEqual(self.module.parse_program_ref("heretechnologies/heretechnologies"), expected) + self.assertEqual( + self.module.parse_program_ref( + "https://app.intigriti.com/researcher/programs/" + "heretechnologies/heretechnologies/detail" + ), + expected, + ) + with self.assertRaisesRegex(ValueError, "company/program"): + self.module.parse_program_ref("here.com") + + def test_golden_scope_contains_only_paying_web_assets(self) -> None: + generated = self.generated() + self.assertEqual( + generated["targets"], + [ + "*.account.api.here.com", + "*.account.here.com", + "*.mobilitygraph.hereapi.com", + "*.router.hereapi.com", + "*.scbe.api.here.com", + "*.subp-router.hereapi.com", + "jaguar.here.com", + "landrover.here.com", + "*.here.com", + "*.hereapi.com", + ], + ) + self.assertNotIn("955837609", generated["targets"]) + self.assertNotIn("com.here.app.maps", generated["targets"]) + self.assertNotIn("Leaked/compromised employee accounts *.here.com", generated["targets"]) + self.assertIn("955837609", generated["notes"]) + self.assertIn("com.here.app.maps", generated["notes"]) + self.assertIn("Leaked/compromised employee accounts", generated["notes"]) + + def test_tier_floors_are_derived_from_paid_ranges(self) -> None: + generated = self.generated() + containing = { + pattern: tier["report_floor"] + for tier in generated["tiers"].values() + for pattern in tier["patterns"] + } + self.assertEqual(containing["*.here.com"], "exceptional") + self.assertEqual(containing["*.hereapi.com"], "exceptional") + self.assertEqual(containing["*.account.here.com"], "low") + self.assertNotIn("account.here.com", containing) + + def test_rate_header_and_roe_defaults_are_fail_closed(self) -> None: + generated = self.generated() + self.assertEqual(generated["rate_limit"]["requests_per_second"], 5) + self.assertEqual(generated["required_headers"], {"X-Bug-Bounty": TEST_USERNAME}) + for gate in ( + "mutation_allowed", + "sensitive_data_access_allowed", + "credential_use_allowed", + "pivoting_allowed", + "availability_impact_allowed", + "destructive_allowed", + ): + self.assertIs(generated[gate], False) + + def test_unrelated_api_metadata_is_never_copied_to_the_draft(self) -> None: + program = json.loads(json.dumps(self.program)) + sentinel = "sensitive-sentinel-never-copy" + program["privateMetadata"] = {"accessToken": sentinel} + rendered = self.module.render_yaml( + self.module.build_yaml(program, TEST_USERNAME, assume_yes=True) + ) + self.assertNotIn(sentinel, rendered) + + def test_generated_yaml_round_trips_through_scope_guard(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "engagement.yaml" + path.write_text(yaml.safe_dump(self.generated(), sort_keys=False), encoding="utf-8") + loaded = load_engagement(path) + self.assertTrue(scope_decision(loaded, "account.here.com")[0]) + self.assertTrue(scope_decision(loaded, "a.account.here.com")[0]) + self.assertFalse(scope_decision(loaded, "here.com")[0]) + self.assertFalse(scope_decision(loaded, "here.com.evil.com")[0]) + self.assertFalse(scope_decision(loaded, "here.okta.com")[0]) + + def test_cli_writes_draft_without_activating_or_overwriting(self) -> None: + active = ROOT / ".active_engagement" + active_before = active.read_bytes() if active.exists() else None + with tempfile.TemporaryDirectory() as temp_dir: + out = Path(temp_dir) / "here" + command = [ + sys.executable, + str(SCRIPT), + "heretechnologies/heretechnologies", + "--username", + TEST_USERNAME, + "--from-file", + str(FIXTURE), + "--out", + str(out), + "--yes", + ] + first = subprocess.run(command, cwd=ROOT, capture_output=True, text=True) + self.assertEqual(first.returncode, 0, first.stderr) + draft = out / "engagement.yaml" + self.assertTrue(draft.is_file()) + self.assertEqual(stat.S_IMODE(draft.stat().st_mode), 0o600) + self.assertIn("Draft engagement written", first.stdout) + second = subprocess.run(command, cwd=ROOT, capture_output=True, text=True) + self.assertNotEqual(second.returncode, 0) + self.assertIn("--force", second.stderr) + active_after = active.read_bytes() if active.exists() else None + self.assertEqual(active_after, active_before) + + def test_no_paying_tier_is_rejected(self) -> None: + program = json.loads(json.dumps(self.program)) + latest = max(program["bountyTables"], key=lambda item: item["createdAt"]) + for row in latest["content"]["bountyRows"]: + for bounty_range in row["bountyRanges"]: + bounty_range["minBounty"]["value"] = 0 + with self.assertRaisesRegex(ValueError, "paying tier"): + self.module.build_yaml(program, TEST_USERNAME, assume_yes=True) + + +if __name__ == "__main__": + unittest.main()