Skip to content

Commit f3f867f

Browse files
committed
Many external sites can be edited at once
The list's ticked sites can now be changed together with Edit the ticked sites: application, executive and technical owner, environment, status, external auth and whitelist needed, change frequency, default scan profile, no scan, tags, comments, proxy, scan sign in and repositories. Each field has a Change box and only the ticked ones are applied, so everything else stays as each site has it; typing in a field ticks it. Tags are added, removed or replaced, comments replaced or added to, repositories added, replaced or cleared. Every change is checked for every site with the same rules as Edit before any is written, and if one site will not take it, nothing changes and the message names that site. The API does the same through a PATCH of the assets list with ids.
1 parent 23d1d98 commit f3f867f

8 files changed

Lines changed: 355 additions & 0 deletions

File tree

‎app/core/api_reference.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,22 @@ def _external_endpoints() -> list:
513513
_f("no_scan_reason", "string", False, "Why not.")] + _DETAILS,
514514
body_example={"no_scan": True, "no_scan_reason": "Vendor hosted"},
515515
example=_ASSET),
516+
_ext("ext-bulk", "PATCH", "/external/assets", "Change many sites",
517+
"The same changes on every site in ids, all or nothing: if any site will not "
518+
"take a change, nothing is changed and the message names it. Takes the same "
519+
"fields as changing one site. tags_mode is add, remove or replace; "
520+
"comments_mode is replace or append; repositories_mode is add, replace or clear.",
521+
body=[_f("ids", "array", True, "The sites to change, up to 500.")] + _DETAILS + [
522+
_f("tags", "array", False, "Tags, as tags_mode says."),
523+
_f("tags_mode", "string", False, "add, remove or replace."),
524+
_f("comments_mode", "string", False, "replace or append."),
525+
_f("repositories_mode", "string", False, "add, replace or clear."),
526+
_f("no_scan", "boolean", False, "Never scan them."),
527+
_f("no_scan_reason", "string", False, "Why not.")],
528+
body_example={"ids": [12, 14, 15], "environment": "dev",
529+
"technical_owner": "team@example.com",
530+
"tags": ["ecommerce"], "tags_mode": "add", "whitelist_needed": True},
531+
example={"changed": 3, "hosts": ["a.example.com", "b.example.com", "c.example.com"]}),
516532
_ext("ext-delete", "DELETE", "/external/assets/{asset_id}", "Remove a site",
517533
"Removes the site and its scan history. 409 while a scan runs.",
518534
[asset_id], example={}),

‎app/core/external.py‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,93 @@ def set_proxy(asset, value) -> None:
333333
asset.proxy = store._encrypt(text)
334334

335335

336+
BULK_FIELDS = ("application", "executive_owner", "technical_owner", "environment",
337+
"status", "external_auth_needed", "whitelist_needed",
338+
"change_frequency_days", "default_profile")
339+
340+
341+
def bulk_update(db, assets: list, changes: dict, actor: str, user=None) -> list:
342+
"""Apply the same changes to many sites, all or nothing.
343+
344+
changes holds only what is to change:
345+
details {field: value} for BULK_FIELDS, checked like Edit
346+
tags (add | remove | replace, [tags])
347+
comments (replace | append, text)
348+
no_scan (bool, reason)
349+
proxy a proxy address, or "" to remove it
350+
auth (kind, {values}) as Edit takes it; kind "" removes it
351+
repositories (add | replace | clear, [(repository id, branch)])
352+
353+
Every site is checked before any is written; the first one that will not
354+
take a change raises ValueError naming it, and the caller rolls back.
355+
-> the hosts changed
356+
"""
357+
from app.core import external_auth
358+
359+
envs = environments(db)
360+
details = dict(changes.get("details") or {})
361+
application = details.pop("application", None)
362+
cleaned = clean_details(details, partial=True, envs=envs) if details else {}
363+
tags = changes.get("tags")
364+
if tags and tags[0] not in ("add", "remove", "replace"):
365+
raise ValueError("Tags are added, removed or replaced.")
366+
comments = changes.get("comments")
367+
if comments and comments[0] not in ("replace", "append"):
368+
raise ValueError("Comments are replaced or added to.")
369+
repos = changes.get("repositories")
370+
if repos and repos[0] not in ("add", "replace", "clear"):
371+
raise ValueError("Repositories are added, replaced or cleared.")
372+
changed = []
373+
for asset in assets:
374+
try:
375+
if application is not None:
376+
asset.application = str(application)[:200].strip()
377+
for key, value in cleaned.items():
378+
setattr(asset, key, value)
379+
if tags:
380+
mode, values = tags
381+
have = asset.tag_list()
382+
wanted = clean_tags(values)
383+
if mode == "add":
384+
set_tags(asset, have + wanted)
385+
elif mode == "remove":
386+
drop = {t.casefold() for t in wanted}
387+
set_tags(asset, [t for t in have if t.casefold() not in drop])
388+
else:
389+
set_tags(asset, wanted)
390+
if comments:
391+
mode, text = comments
392+
text = str(text or "").strip()
393+
if mode == "append" and asset.notes and text:
394+
text = (asset.notes.rstrip() + "\n" + text)
395+
asset.notes = text[:4000] if (mode == "replace" or text) else asset.notes
396+
if changes.get("no_scan") is not None:
397+
flag, reason = changes["no_scan"]
398+
asset.no_scan = bool(flag)
399+
asset.no_scan_reason = (str(reason or "")[:300] if flag else "")
400+
if "proxy" in changes:
401+
set_proxy(asset, changes["proxy"] or "")
402+
if changes.get("auth") is not None:
403+
kind, values = changes["auth"]
404+
external_auth.save(asset, kind, values, actor, asset.parent_domain)
405+
if repos:
406+
mode, picks = repos
407+
if mode == "add":
408+
current = [tuple(p.split("|", 1)) for p in picks_of(db, asset)]
409+
merged = [(int(r), b) for r, b in current]
410+
merged += [p for p in picks if p not in merged]
411+
set_picks(db, asset, merged, actor, user)
412+
elif mode == "replace":
413+
set_picks(db, asset, picks, actor, user)
414+
else:
415+
set_picks(db, asset, [], actor, user)
416+
except ValueError as exc:
417+
raise ValueError(f"{asset.host}: {exc}")
418+
changed.append(asset.host)
419+
db.flush()
420+
return changed
421+
422+
336423
def profile_for(asset: ExternalAsset) -> str:
337424
"""The profile a scan of this site starts with when nobody picks one."""
338425
from app.core import dast_engines

‎app/web/routes_external.py‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ def list_page(request: Request, q: str = "", tag: str = "", app: str = "",
227227

228228
context.update(active_page="external", rows=rows, totals=totals,
229229
profiles=dast_engines.PROFILES, default_profile=dast_engines.DEFAULT_PROFILE,
230+
auth_kinds=external_auth_mod.KINDS,
230231
q=q, tag=tag, app=app, grade=grade, dast=dast, risk=risk,
231232
scope=scope, all_tags=all_tags, apps=apps, opts=opts,
232233
env=env, status=status, environments=envs,
@@ -378,6 +379,69 @@ def _ticked(form) -> list:
378379
return list(dict.fromkeys(ids))[:500]
379380

380381

382+
def _bulk_changes(form) -> dict:
383+
"""What the bulk edit dialog asks to change: only the ticked fields."""
384+
def on(name):
385+
return _checked(form, f"apply_{name}")
386+
387+
changes, details = {}, {}
388+
for key in external.BULK_FIELDS:
389+
if on(key):
390+
value = form.get(key)
391+
if key in ("external_auth_needed", "whitelist_needed"):
392+
value = str(value or "").lower() in ("1", "yes", "true", "on")
393+
details[key] = value if value is not None else ""
394+
if details:
395+
changes["details"] = details
396+
if on("tags"):
397+
changes["tags"] = (str(form.get("tags_mode") or "add"),
398+
str(form.get("tags") or "").replace(";", ",").split(","))
399+
if on("comments"):
400+
changes["comments"] = (str(form.get("comments_mode") or "replace"),
401+
clean_text(form.get("comments"), "Comments", limit=4000, required=False))
402+
if on("no_scan"):
403+
changes["no_scan"] = (str(form.get("no_scan") or "").lower() in ("1", "yes"),
404+
clean_text(form.get("no_scan_reason"), "Reason", limit=300, required=False))
405+
if on("proxy"):
406+
changes["proxy"] = "" if form.get("proxy_mode") == "clear" else str(form.get("proxy") or "")
407+
if changes["proxy"] == "" and form.get("proxy_mode") != "clear":
408+
raise ValueError("Give the proxy address, or choose to remove it.")
409+
if on("auth"):
410+
changes["auth"] = (str(form.get("auth_kind") or ""), {
411+
key: form.get(f"auth_{key}") or "" for key in (
412+
"header_name", "header_value", "cookie", "login_url", "username",
413+
"password", "username_field", "password_field")})
414+
if on("repositories"):
415+
changes["repositories"] = (str(form.get("repositories_mode") or "add"),
416+
external.parse_picks(form.getlist("pick")))
417+
return changes
418+
419+
420+
@router.post("/external/bulk-edit")
421+
async def bulk_edit(request: Request, user: User = Depends(current_user)):
422+
"""Change the same things on every ticked site, all or nothing."""
423+
await check_csrf(request)
424+
form = await request.form()
425+
with session_scope() as db:
426+
_guard(db, user)
427+
ids = _ticked(form)
428+
if not ids:
429+
return redirect(_back("/external", "Tick the sites to change first.", "error"))
430+
try:
431+
changes = _bulk_changes(form)
432+
if not changes:
433+
return redirect(_back("/external", "Tick Change beside what to change.", "error"))
434+
assets = db.query(ExternalAsset).filter(ExternalAsset.id.in_(ids)).all()
435+
changed = external.bulk_update(db, assets, changes, user.username, user)
436+
except ValueError as exc:
437+
db.rollback()
438+
return redirect(_back("/external", f"Nothing was changed. {exc}", "error"))
439+
record(db, "external.bulk_edit", actor=user.username, target=f"{len(changed)} sites",
440+
detail=(", ".join(sorted(changes)) + ": " + ", ".join(changed))[:500],
441+
source_ip=client_ip(request))
442+
return redirect(_back("/external", f"Changed {len(changed)} site{'s' if len(changed) != 1 else ''}."))
443+
444+
381445
@router.post("/external/delete-many")
382446
async def delete_many(request: Request, user: User = Depends(current_user)):
383447
"""Remove the ticked sites and their scan history. One with a scan
@@ -1258,6 +1322,73 @@ async def api_patch(asset_id: int, request: Request, caller=Depends(_api_caller(
12581322
return _view(db, asset, me, request)
12591323

12601324

1325+
@api.patch("/assets")
1326+
async def api_bulk_patch(request: Request, caller=Depends(_api_caller())):
1327+
"""Change the same things on many sites, all or nothing."""
1328+
body = await _body(request)
1329+
ids = body.get("ids")
1330+
if not isinstance(ids, list) or not ids or not all(isinstance(i, int) for i in ids) or len(ids) > 500:
1331+
raise HTTPException(status_code=400, detail="ids is a list of up to 500 site ids.")
1332+
with session_scope() as db:
1333+
me = _api_me(db, caller)
1334+
assets = db.query(ExternalAsset).filter(ExternalAsset.id.in_(ids)).all()
1335+
missing = set(ids) - {a.id for a in assets}
1336+
if missing:
1337+
raise HTTPException(status_code=404, detail=f"No such sites: {sorted(missing)[:10]}")
1338+
changes, details = {}, {}
1339+
try:
1340+
for key in external.BULK_FIELDS:
1341+
if key in body:
1342+
details[key] = body[key]
1343+
src = _api_details({k: v for k, v in body.items() if k in external.BULK_FIELDS})
1344+
details.update(src)
1345+
if "application" in body:
1346+
if not isinstance(body["application"], str):
1347+
raise ValueError("application has to be a string.")
1348+
details["application"] = body["application"]
1349+
if details:
1350+
changes["details"] = details
1351+
for key, modes in (("tags", ("add", "remove", "replace")),
1352+
("comments", ("replace", "append")),
1353+
("repositories", ("add", "replace", "clear"))):
1354+
if key in body:
1355+
mode = body.get(f"{key}_mode") or modes[0]
1356+
if mode not in modes:
1357+
raise ValueError(f"{key}_mode is one of {', '.join(modes)}.")
1358+
value = body[key]
1359+
if key == "repositories":
1360+
value = external.picks_from_names(db, value or [])
1361+
elif key == "tags" and (not isinstance(value, list)
1362+
or not all(isinstance(t, str) for t in value)):
1363+
raise ValueError("tags has to be a list of strings.")
1364+
elif key == "comments" and not isinstance(value, str):
1365+
raise ValueError("comments has to be a string.")
1366+
changes[key] = (mode, value)
1367+
if "no_scan" in body:
1368+
if not isinstance(body["no_scan"], bool):
1369+
raise ValueError("no_scan has to be true or false.")
1370+
changes["no_scan"] = (body["no_scan"], str(body.get("no_scan_reason") or ""))
1371+
if "proxy" in body:
1372+
if body["proxy"] is not None and not isinstance(body["proxy"], str):
1373+
raise ValueError("proxy has to be a string, or null to remove it.")
1374+
changes["proxy"] = body["proxy"] or ""
1375+
if "scan_auth" in body:
1376+
auth = body["scan_auth"] or {}
1377+
if not isinstance(auth, dict) or not all(isinstance(v, str) for v in auth.values()):
1378+
raise ValueError("scan_auth has to be an object of strings, or null.")
1379+
changes["auth"] = (auth.get("kind", ""), auth)
1380+
if not changes:
1381+
raise ValueError("Name at least one thing to change.")
1382+
changed = external.bulk_update(db, assets, changes, me.username, me)
1383+
except ValueError as exc:
1384+
db.rollback()
1385+
raise HTTPException(status_code=400, detail=f"Nothing was changed. {exc}")
1386+
record(db, "external.bulk_edit", actor=me.username, target=f"{len(changed)} sites",
1387+
detail=(", ".join(sorted(changes)) + f", api token {caller.token.prefix}")[:500],
1388+
source_ip=client_ip(request))
1389+
return {"changed": len(changed), "hosts": changed}
1390+
1391+
12611392
@api.delete("/assets/{asset_id}")
12621393
def api_delete(asset_id: int, request: Request, caller=Depends(_api_caller())):
12631394
with session_scope() as db:

‎app/web/static/app.css‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,3 +1158,11 @@ details.code-behind > summary::before { content: "\25B8"; color: var(--text-fain
11581158
details.code-behind[open] > summary::before { content: "\25BE"; }
11591159
.code-summary { font-weight: 400; font-size: .9rem; display: flex; gap: .25rem; align-items: center;
11601160
margin-left: auto; }
1161+
/* Bulk edit: a Change box beside each field. */
1162+
.bulk-row { display: grid; grid-template-columns: 6.5rem 1fr; gap: .6rem; align-items: start;
1163+
padding: .45rem 0; border-bottom: 1px dashed var(--border); }
1164+
.bulk-row .bulk-apply { margin: .2rem 0 0; }
1165+
.bulk-label { font-weight: 600; font-size: .9rem; margin-bottom: .2rem; }
1166+
.bulk-body input[type=text], .bulk-body input[type=password], .bulk-body input[type=url],
1167+
.bulk-body input[type=number], .bulk-body select, .bulk-body textarea { width: 100%; }
1168+
.bulk-mode { display: grid; grid-template-columns: 12rem 1fr; gap: .4rem; }

‎app/web/static/external.js‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,43 @@
370370
window.addEventListener("hashchange", open);
371371
window.addEventListener("load", open);
372372
})();
373+
374+
/* Bulk edit: a field that is touched is a field to change, and the ticked
375+
* sites in the list go with the form. */
376+
(function () {
377+
"use strict";
378+
var form = document.querySelector("[data-bulk-form]");
379+
if (!form) { return; }
380+
form.querySelectorAll("[data-bulk-field]").forEach(function (row) {
381+
var apply = row.querySelector('input[name^="apply_"]');
382+
function mark(event) {
383+
if (event.target !== apply) { apply.checked = true; apply.defaultChecked = true; }
384+
}
385+
row.addEventListener("input", mark);
386+
row.addEventListener("change", mark);
387+
});
388+
var count = form.querySelector("[data-bulk-count]");
389+
function ticked() {
390+
return Array.prototype.filter.call(
391+
document.querySelectorAll('input[name="id"][form="pick"]'), function (b) { return b.checked; });
392+
}
393+
document.addEventListener("click", function (event) {
394+
if (event.target.closest && event.target.closest('[data-modal="bulk-edit-modal"]') && count) {
395+
setTimeout(function () {
396+
var n = ticked().length;
397+
count.textContent = "(" + n + " site" + (n === 1 ? "" : "s") + ")";
398+
form.querySelectorAll('input[name^="apply_"]').forEach(function (b) { b.defaultChecked = false; b.checked = false; });
399+
}, 0);
400+
}
401+
});
402+
form.addEventListener("submit", function (event) {
403+
form.querySelectorAll('input[name="id"][type="hidden"]').forEach(function (n) { n.remove(); });
404+
var picks = ticked();
405+
if (!picks.length) { event.preventDefault(); return; }
406+
picks.forEach(function (box) {
407+
var hidden = document.createElement("input");
408+
hidden.type = "hidden"; hidden.name = "id"; hidden.value = box.value;
409+
form.appendChild(hidden);
410+
});
411+
});
412+
})();

0 commit comments

Comments
 (0)