Skip to content

Commit 7ec096c

Browse files
committed
Let a site point the scan at ad-hoc code when it is not a scanned repository
When the code behind a site is not one of the scanned repositories, there was no way to give the scan its routes and sinks. Now a site can name a public git URL to clone, or upload a zip or tar.gz of the code, and the whitebox read uses it the same way it uses a mapped repository: the routes guide the dynamic scan and the sinks corroborate findings. Both are read only and handled with care. The clone is refused unless it is a plain https URL to a public host and carries no credential, so it cannot be aimed at the network the scanner sits in. The archive is unpacked with limits on size and file count, and every entry is checked so nothing is written outside its own folder. Also fixes the per-account intent field so the site's own edit form saves it, not only the bulk editor.
1 parent e7127a1 commit 7ec096c

5 files changed

Lines changed: 279 additions & 3 deletions

File tree

‎app/core/dast_adhoc.py‎

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""An ad-hoc code source for the whitebox read.
2+
3+
When the code behind a site is not one of the scanned repositories, a person can
4+
point the scan at the code directly: a public git URL to clone, or an uploaded
5+
zip or tar.gz. The routes and sinks read from it then guide the dynamic scan
6+
the same way a mapped repository's would.
7+
8+
Both are read only and handled with care. The clone is refused unless it is a
9+
plain https URL to a public host, so it cannot be turned into a request against
10+
the network the scanner sits in, and it never prompts for or stores a
11+
credential. The archive is unpacked with hard limits on size and count, and
12+
every entry is checked so nothing can be written outside its own folder.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import contextlib
18+
import os
19+
import shutil
20+
import subprocess
21+
import tarfile
22+
import tempfile
23+
import zipfile
24+
from pathlib import Path
25+
26+
MAX_ARCHIVE_BYTES = 200 * 1024 * 1024 # the stored upload
27+
MAX_UNCOMPRESSED = 1024 * 1024 * 1024 # what it unpacks to
28+
MAX_ENTRIES = 50000
29+
CLONE_TIMEOUT = 180
30+
_EXT = {"zip": ".zip", "tgz": ".tar.gz"}
31+
32+
33+
def _adhoc_dir() -> Path:
34+
from app.core.external import data_dir
35+
36+
d = data_dir() / "adhoc"
37+
d.mkdir(parents=True, exist_ok=True)
38+
return d
39+
40+
41+
def archive_path(asset_id, kind: str) -> Path:
42+
return _adhoc_dir() / f"{int(asset_id)}{_EXT.get(kind, '.bin')}"
43+
44+
45+
def clear_archive(asset_id) -> None:
46+
for ext in _EXT.values():
47+
(_adhoc_dir() / f"{int(asset_id)}{ext}").unlink(missing_ok=True)
48+
49+
50+
def kind_of(filename: str) -> str:
51+
n = (filename or "").lower()
52+
if n.endswith(".zip"):
53+
return "zip"
54+
if n.endswith(".tar.gz") or n.endswith(".tgz"):
55+
return "tgz"
56+
return ""
57+
58+
59+
async def save_upload(asset_id, upload) -> str:
60+
"""Store an uploaded zip or tar.gz for a site. -> its kind, or raises
61+
ValueError with a sentence to show. Streamed, with a size cap, so a huge
62+
upload cannot fill the disk."""
63+
kind = kind_of(getattr(upload, "filename", ""))
64+
if not kind:
65+
raise ValueError("The code archive has to be a .zip or a .tar.gz file.")
66+
clear_archive(asset_id)
67+
dest = archive_path(asset_id, kind)
68+
size = 0
69+
try:
70+
with open(dest, "wb") as out:
71+
while True:
72+
chunk = await upload.read(1024 * 1024)
73+
if not chunk:
74+
break
75+
size += len(chunk)
76+
if size > MAX_ARCHIVE_BYTES:
77+
raise ValueError("The code archive is too large (over 200 MB).")
78+
out.write(chunk)
79+
except ValueError:
80+
dest.unlink(missing_ok=True)
81+
raise
82+
if size == 0:
83+
dest.unlink(missing_ok=True)
84+
raise ValueError("The code archive was empty.")
85+
return kind
86+
87+
88+
def validate_git_url(url: str) -> str:
89+
"""-> a clean https git URL, or raises ValueError. Refuses anything that is
90+
not https to a public host, and any URL carrying a credential."""
91+
from urllib.parse import urlparse
92+
93+
from app.core import external_probe
94+
95+
url = (url or "").strip()
96+
if not url:
97+
return ""
98+
parsed = urlparse(url)
99+
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
100+
raise ValueError("The git URL has to be an https address with no user name "
101+
"or password in it. For private code, upload an archive.")
102+
if not external_probe._host_is_public(parsed.hostname):
103+
raise ValueError("The git URL has to be a public host, not a private "
104+
"address. For internal code, upload an archive.")
105+
return url
106+
107+
108+
def describe(asset) -> dict:
109+
"""What the page may show about the ad-hoc source. {} when there is none."""
110+
git = getattr(asset, "adhoc_git_url", "") or ""
111+
kind = getattr(asset, "adhoc_archive", "") or ""
112+
if not git and not kind:
113+
return {}
114+
return {"git_url": git, "archive_kind": kind,
115+
"label": git or (f"uploaded {kind} archive" if kind else "")}
116+
117+
118+
def _safe_clone(url: str, dest: Path) -> str:
119+
"""Shallow clone url into dest. -> "" on success, else a short reason."""
120+
env = dict(os.environ, GIT_TERMINAL_PROMPT="0", GIT_ASKPASS="/bin/true",
121+
GCM_INTERACTIVE="never")
122+
cmd = ["git", "-c", "protocol.file.allow=never", "-c", "protocol.ext.allow=never",
123+
"-c", "core.askpass=/bin/true", "clone", "--depth", "1",
124+
"--single-branch", "--no-tags", url, str(dest)]
125+
try:
126+
r = subprocess.run(cmd, env=env, capture_output=True, text=True,
127+
timeout=CLONE_TIMEOUT)
128+
except subprocess.TimeoutExpired:
129+
return "the clone took too long and was stopped."
130+
if r.returncode != 0:
131+
return f"git clone failed: {(r.stderr or '').strip().splitlines()[-1][:200] if r.stderr else 'unknown error'}"
132+
return ""
133+
134+
135+
def _safe_zip(path: Path, dest: Path) -> str:
136+
dest_root = str(dest.resolve())
137+
total = 0
138+
with zipfile.ZipFile(path) as z:
139+
infos = z.infolist()
140+
if len(infos) > MAX_ENTRIES:
141+
return "the archive has too many files in it."
142+
for info in infos:
143+
if info.is_dir():
144+
continue
145+
target = (dest / info.filename).resolve()
146+
if target != dest and not str(target).startswith(dest_root + os.sep):
147+
return "the archive tries to write outside its own folder."
148+
total += info.file_size
149+
if total > MAX_UNCOMPRESSED:
150+
return "the archive is too large when it is unpacked."
151+
target.parent.mkdir(parents=True, exist_ok=True)
152+
with z.open(info) as src, open(target, "wb") as out:
153+
shutil.copyfileobj(src, out, 1024 * 1024)
154+
return ""
155+
156+
157+
def _safe_tar(path: Path, dest: Path) -> str:
158+
with tarfile.open(path, "r:gz") as t:
159+
members = t.getmembers()
160+
if len(members) > MAX_ENTRIES:
161+
return "the archive has too many files in it."
162+
if sum(m.size for m in members if m.isreg()) > MAX_UNCOMPRESSED:
163+
return "the archive is too large when it is unpacked."
164+
# The data filter (Python 3.12+) blocks absolute paths, traversal and
165+
# links that point outside the destination.
166+
t.extractall(dest, filter="data")
167+
return ""
168+
169+
170+
@contextlib.contextmanager
171+
def checkout(adhoc: dict):
172+
"""Materialise the ad-hoc source into a temporary folder, cleaned up on exit.
173+
174+
adhoc is {git, archive, asset_id}. Yields (root, why): root is the folder
175+
to read, or None with a short reason when there is nothing set or it failed.
176+
"""
177+
from app.core import recheck
178+
179+
git = (adhoc.get("git") or "").strip()
180+
kind = (adhoc.get("archive") or "").strip()
181+
asset_id = adhoc.get("asset_id")
182+
if not git and not kind:
183+
yield None, ""
184+
return
185+
base = recheck.memory_dir() or None
186+
tmp = tempfile.mkdtemp(prefix="gcr-adhoc-", dir=base)
187+
try:
188+
dest = Path(tmp) / "src"
189+
if git:
190+
try:
191+
url = validate_git_url(git)
192+
except ValueError as exc:
193+
yield None, str(exc)
194+
return
195+
why = _safe_clone(url, dest)
196+
else:
197+
dest.mkdir(parents=True, exist_ok=True)
198+
archive = archive_path(asset_id, kind)
199+
if not archive.exists():
200+
yield None, "the uploaded archive is missing."
201+
return
202+
why = _safe_zip(archive, dest) if kind == "zip" else _safe_tar(archive, dest)
203+
if why:
204+
yield None, why
205+
return
206+
yield str(dest), ""
207+
finally:
208+
shutil.rmtree(tmp, ignore_errors=True)

‎app/core/external.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2009,6 +2009,8 @@ def _whitebox(scan_id: int, asset_id: int, host: str) -> dict:
20092009
for branch in (l.get("branches") or [l["repo"].default_branch or ""]):
20102010
plan.append((l["repo"].id, l["full_name"], l["repo"].connection or "", branch))
20112011
plan = plan[:MAX_WHITEBOX_REPOS]
2012+
adhoc = {"git": asset.adhoc_git_url or "", "archive": asset.adhoc_archive or "",
2013+
"asset_id": asset_id} if asset is not None else {}
20122014
out = {"repos": [], "routes": [], "by_repo": {}, "sinks": []}
20132015
for repo_id, name, connection, branch in plan:
20142016
if scan_id in _stopping:
@@ -2046,6 +2048,32 @@ def _whitebox(scan_id: int, asset_id: int, host: str) -> dict:
20462048
if row is None:
20472049
db.add(ExternalAssetRepo(asset_id=asset_id, repository_id=repo_id,
20482050
source="code", added_by="scan"))
2051+
# An ad-hoc code source the person pointed at this site: a git URL or an
2052+
# uploaded archive, read the same way, when the code is not a mapped repo.
2053+
if (adhoc.get("git") or adhoc.get("archive")) and scan_id not in _stopping:
2054+
from app.core import dast_adhoc
2055+
2056+
label = (adhoc.get("git") or f"uploaded {adhoc.get('archive')} archive")[:200]
2057+
_update(scan_id, step="reading the ad-hoc code source")
2058+
entry = {"repository": f"ad-hoc: {label}", "branch": "", "routes": 0,
2059+
"host_in_code": [], "error": ""}
2060+
try:
2061+
with dast_adhoc.checkout(adhoc) as (root, why):
2062+
if root is None:
2063+
entry["error"] = why or "could not read it"
2064+
else:
2065+
got = dast_whitebox.read(root, host)
2066+
entry["routes"] = len(got["routes"])
2067+
entry["host_in_code"] = got["host_files"]
2068+
for route in got["routes"]:
2069+
if route not in out["routes"]:
2070+
out["routes"].append(route)
2071+
for sink in got.get("sinks") or []:
2072+
if len(out["sinks"]) < MAX_WHITEBOX_SINKS:
2073+
out["sinks"].append(dict(sink, repository=label))
2074+
except Exception as exc: # noqa: BLE001 - the scan carries on without it
2075+
entry["error"] = f"{type(exc).__name__}: {exc}"[:200]
2076+
out["repos"].append(entry)
20492077
out["routes"] = out["routes"][:300]
20502078
return out
20512079

‎app/core/models.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2315,6 +2315,12 @@ class ExternalAsset(Base):
23152315
# site where an engine keeps hitting its default limit, this lets it run
23162316
# longer without changing the default for every site.
23172317
engine_timeouts = Column(LONG_JSON, default="")
2318+
# An ad-hoc code source for the whitebox read, when the code behind this
2319+
# site is not one of the scanned repositories: a git URL to clone, or an
2320+
# uploaded archive (adhoc_archive holds its kind, zip or tgz; the file is in
2321+
# the data volume). Used only when set.
2322+
adhoc_git_url = Column(String(1024), default="")
2323+
adhoc_archive = Column(String(16), default="")
23182324
# The Rapid7 InsightAppSec scan config this site is scanned with, when
23192325
# that integration is on. A scan there always runs from a config made in
23202326
# Rapid7, which holds the start address, the attack template, the engine

‎app/web/routes_external.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from fastapi.responses import FileResponse, JSONResponse, Response
2626

2727
from app.core import external, rbac
28+
from app.core import dast_adhoc as dast_adhoc_mod
2829
from app.core import external_auth as external_auth_mod
2930
from app.core import external_schedule
3031
from app.core.models import ExternalScanSchedule
@@ -714,6 +715,7 @@ def detail_page(asset_id: int, request: Request, scans: int = 1,
714715
fields=external.fields_of(asset), security_headers=external.SECURITY_HEADERS,
715716
auth=external_auth_mod.describe(asset),
716717
auth_b=external_auth_mod.describe(asset, "b"),
718+
adhoc=dast_adhoc_mod.describe(asset),
717719
auth_kinds=external_auth_mod.KINDS,
718720
site_profile=external.profile_for(asset), proxy_label=external.proxy_shown(asset),
719721
has_shot=external.shot_path(asset.id).is_file(),
@@ -828,7 +830,8 @@ async def edit(asset_id: int, request: Request, user: User = Depends(current_use
828830
external_auth.save(asset, form.get("auth_kind") or "", {
829831
key: form.get(f"auth_{key}") or "" for key in (
830832
"header_name", "header_value", "cookie", "login_url",
831-
"username", "password", "username_field", "password_field")},
833+
"username", "password", "username_field", "password_field",
834+
"intent")},
832835
user.username, asset.parent_domain)
833836
if (asset.auth_kind or "") != before_kind or any(
834837
form.get(f"auth_{k}") for k in ("header_value", "cookie", "password")):
@@ -842,7 +845,8 @@ async def edit(asset_id: int, request: Request, user: User = Depends(current_use
842845
external_auth.save(asset, form.get("auth_b_kind") or "", {
843846
key: form.get(f"auth_b_{key}") or "" for key in (
844847
"header_name", "header_value", "cookie", "login_url",
845-
"username", "password", "username_field", "password_field")},
848+
"username", "password", "username_field", "password_field",
849+
"intent")},
846850
user.username, asset.parent_domain, slot="b")
847851
if (asset.auth_kind_b or "") != before_b or any(
848852
form.get(f"auth_b_{k}") for k in ("header_value", "cookie", "password")):
@@ -854,6 +858,24 @@ async def edit(asset_id: int, request: Request, user: User = Depends(current_use
854858
external.set_engine_timeouts(
855859
asset, {name: form.get(f"timeout_{name}")
856860
for name in external.TIMEOUT_ENGINES})
861+
if form.get("with_adhoc"):
862+
from app.core import dast_adhoc
863+
864+
if _checked(form, "adhoc_clear"):
865+
dast_adhoc.clear_archive(asset.id)
866+
asset.adhoc_git_url = ""
867+
asset.adhoc_archive = ""
868+
else:
869+
upload = form.get("adhoc_archive")
870+
git = str(form.get("adhoc_git_url") or "").strip()
871+
if upload is not None and getattr(upload, "filename", ""):
872+
asset.adhoc_archive = await dast_adhoc.save_upload(asset.id, upload)
873+
asset.adhoc_git_url = ""
874+
elif git:
875+
asset.adhoc_git_url = dast_adhoc.validate_git_url(git)
876+
if asset.adhoc_git_url:
877+
dast_adhoc.clear_archive(asset.id)
878+
asset.adhoc_archive = ""
857879
except ValueError as exc:
858880
db.rollback()
859881
return redirect(_back(here, str(exc), "error"))

‎app/web/templates/external_site.html‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@
369369
{% endif %}
370370

371371
<div class="modal-backdrop" id="edit-modal" hidden>
372-
<div class="modal wide"><form method="post" action="{{ base }}/external/{{ asset.id }}/edit">
372+
<div class="modal wide"><form method="post" action="{{ base }}/external/{{ asset.id }}/edit" enctype="multipart/form-data">
373373
<div class="modal-head">Edit {{ asset.host }} <button type="button" class="modal-close" data-close-modal>&times;</button></div>
374374
<div class="modal-body">
375375
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
@@ -465,6 +465,18 @@
465465
<span class="faint small">The compare checks and the AI review weigh a cross-account read against this, so an auditor that is meant to see everything is not reported as a bug.</span></label>
466466
<div class="faint small">Use a second test account that owns different data than the first. That is what lets the compare checks prove one account can reach the other's objects.</div>
467467
</details>
468+
<details class="hand-fields" {{ 'open' if adhoc }}>
469+
<summary><strong>Ad-hoc code source</strong> <span class="faint small">point the scan at the code behind this site when it is not one of the scanned repositories, so the routes and sinks still guide the scan. Optional.</span></summary>
470+
<input type="hidden" name="with_adhoc" value="1">
471+
{% if adhoc %}<div class="small" style="margin:.3rem 0">Now using: <span class="mono">{{ adhoc.label }}</span>
472+
<label class="check" style="display:inline-block;margin-left:.5rem"><input type="checkbox" name="adhoc_clear" value="1"> clear it</label></div>{% endif %}
473+
<label>Public git URL to clone
474+
<input type="url" name="adhoc_git_url" maxlength="1000" value="{{ adhoc.git_url if adhoc else '' }}" placeholder="https://github.com/org/repo.git">
475+
<span class="faint small">An https URL to a public host, with no user name or password in it.</span></label>
476+
<label>or upload a repository archive
477+
<input type="file" name="adhoc_archive" accept=".zip,.tar.gz,.tgz">
478+
<span class="faint small">A .zip or .tar.gz of the code, up to 200 MB. Use this for private or internal code. An upload replaces the git URL.</span></label>
479+
</details>
468480
<details class="hand-fields">
469481
<summary><strong>Telemetry by hand</strong> <span class="faint small">for a site the scan cannot reach, or to correct it. A value here wins over the scan's until it is cleared.</span></summary>
470482
{% for group, rows in fields %}

0 commit comments

Comments
 (0)