@@ -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" )
382446async 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}" )
12621393def api_delete (asset_id : int , request : Request , caller = Depends (_api_caller ())):
12631394 with session_scope () as db :
0 commit comments