Skip to content

Commit bec9689

Browse files
committed
External sites can be scanned on a schedule, several at a time
How many engine scans this server runs at once was fixed at one. It is now a setting under Web and external sites, 1 to 6, read every time a scan waits for its turn, so a change needs no restart. A site is still never scanned twice at once. A site's page has Scheduled scans: once at a date and time, or every day, every week on a day, or every month on a day, at a time, read in the time zone set in Settings and moved when that changes. The 29th to the 31st run on the last day of a shorter month. Each schedule has its own profile, sign in and AI triage, and one that sends payloads needs the same confirmation as a scan started by hand. The scheduler looks every minute. A run that finds the site busy, marked no scan or obsolete is skipped and the schedule says why; a one time schedule ends after it runs. The list shows each site's next scheduled scan, and the API lists, adds and removes schedules.
1 parent a98a47e commit bec9689

14 files changed

Lines changed: 603 additions & 4 deletions

‎app/core/api_reference.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,37 @@ def _external_endpoints() -> list:
549549
+ _RECORD_BODY,
550550
body_example={"host": "shop.example.com", "server": "nextgen-dast",
551551
"severity": "medium", "create": True}, example=_SCAN),
552+
_ext("ext-schedules", "GET", "/external/assets/{asset_id}/schedules", "A site's schedules",
553+
"Every scheduled scan of the site, with its next and last run, and the time "
554+
"zone the times are read in.", [asset_id],
555+
example={"time_zone": "America/Chicago", "schedules": [{
556+
"id": 3, "frequency": "weekly", "when": "Every Saturday at 02:00",
557+
"profile": "quick", "authenticated": False, "ai_triage": True, "enabled": True,
558+
"next_run_at": "2026-11-07T08:00:00+00:00", "last_run_at": None,
559+
"last_result": ""}]}),
560+
_ext("ext-schedule-add", "POST", "/external/assets/{asset_id}/schedules", "Schedule a scan",
561+
"Scan the site once, or every day, week or month. Times are read in the "
562+
"time zone set in Settings. A run that finds the site already being scanned, "
563+
"or no longer allowed to be, is skipped and says so.", [asset_id],
564+
body=[_f("frequency", "string", True, "once, daily, weekly or monthly."),
565+
_f("time_of_day", "string", True, "HH:MM."),
566+
_f("run_on", "string", False, "once: the date, YYYY-MM-DD."),
567+
_f("weekday", "integer", False, "weekly: 0 is Monday."),
568+
_f("day_of_month", "integer", False, "monthly: 1 to 31; past the end "
569+
"of a short month it runs on its last day."),
570+
_f("profile", "string", False, "The site's default when left out."),
571+
_f("authenticated", "boolean", False, "Sign in with the site's scan sign in."),
572+
_f("ai", "boolean", False, "Triage each scan with the model."),
573+
_f("confirm", "boolean", False, "Needed for standard and up, and for a "
574+
"signed in scan past baseline.")],
575+
body_example={"frequency": "weekly", "weekday": 5, "time_of_day": "02:00",
576+
"profile": "quick", "ai": True},
577+
example={"id": 3, "when": "Every Saturday at 02:00",
578+
"next_run_at": "2026-11-07T08:00:00+00:00"}),
579+
_ext("ext-schedule-delete", "DELETE", "/external/assets/{asset_id}/schedules/{schedule_id}",
580+
"Remove a schedule", "Stops the schedule. Scans it already ran are kept.",
581+
[asset_id, _p("schedule_id", "The schedule's id.", where="path", kind="integer",
582+
example=3)], example={}),
552583
_ext("ext-start", "POST", "/external/assets/{asset_id}/dast", "Start a scan",
553584
"Starts a scan on this server or on a nextgen-dast server. 409 for a "
554585
"site that may not be scanned or already has one running. Standard "

‎app/core/external.py‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,7 +1190,45 @@ def refresh_stale() -> int:
11901190

11911191
# ------------------------------------------------------------------ scans
11921192

1193-
_slots = threading.Semaphore(1)
1193+
class _Slots:
1194+
"""How many engine scans run at once on this server. The limit is read
1195+
from Settings each time a scan waits, so a change takes effect on the
1196+
next scan without a restart."""
1197+
1198+
def __init__(self):
1199+
self._cond = threading.Condition()
1200+
self._busy = 0
1201+
1202+
def limit(self) -> int:
1203+
try:
1204+
from app.core import settings_store
1205+
1206+
with session_scope() as db:
1207+
value = int(settings_store.get(db, "external.max_concurrent") or 1)
1208+
except Exception: # noqa: BLE001 - a bad value is one at a time
1209+
value = 1
1210+
return max(1, min(value, MAX_CONCURRENT))
1211+
1212+
def __enter__(self):
1213+
with self._cond:
1214+
while self._busy >= self.limit():
1215+
self._cond.wait(timeout=5)
1216+
self._busy += 1
1217+
return self
1218+
1219+
def __exit__(self, *exc):
1220+
with self._cond:
1221+
self._busy -= 1
1222+
self._cond.notify_all()
1223+
return False
1224+
1225+
@property
1226+
def busy(self) -> int:
1227+
return self._busy
1228+
1229+
1230+
MAX_CONCURRENT = 6
1231+
_slots = _Slots()
11941232
_stopping: set = set()
11951233

11961234

‎app/core/external_schedule.py‎

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Scheduled scans of external sites.
2+
3+
A schedule says when, in the time zone set in Settings: once at a date and
4+
time, or every day, every week on a weekday, or every month on a day, at a
5+
time. The scheduler looks once a minute for schedules whose next run has
6+
come, starts the scan the way the Scan button would, and works out the next
7+
run. Missed runs while the server was down are not made up one by one: the
8+
schedule runs once and moves on to its next time.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import calendar
14+
import logging
15+
import re
16+
from datetime import datetime, timedelta, timezone
17+
18+
from app.core.db import session_scope
19+
from app.core.models import ExternalAsset, ExternalScanSchedule, utcnow
20+
21+
log = logging.getLogger("gcr.external")
22+
23+
FREQUENCIES = {"once": "Once", "daily": "Every day", "weekly": "Every week",
24+
"monthly": "Every month"}
25+
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
26+
MAX_PER_SITE = 10
27+
28+
29+
def zone():
30+
from app.core.scheduler import _zone
31+
32+
return _zone()
33+
34+
35+
def _aware(value):
36+
return value if value is None or value.tzinfo else value.replace(tzinfo=timezone.utc)
37+
38+
39+
def next_run(sched, after: datetime, tz=None) -> datetime | None:
40+
"""The first run strictly after `after`, in UTC. None when there is none."""
41+
tz = tz or zone()
42+
after = _aware(after)
43+
local = after.astimezone(tz)
44+
hour, minute = (int(x) for x in (sched.time_of_day or "00:00").split(":"))
45+
46+
def at(day):
47+
return datetime(day.year, day.month, day.day, hour, minute, tzinfo=tz)
48+
49+
if sched.frequency == "once":
50+
try:
51+
when = datetime.strptime(sched.run_on, "%Y-%m-%dT%H:%M").replace(tzinfo=tz)
52+
except (TypeError, ValueError):
53+
return None
54+
return when.astimezone(timezone.utc) if when > after else None
55+
if sched.frequency == "daily":
56+
candidate = at(local)
57+
if candidate <= after:
58+
candidate = at(local + timedelta(days=1))
59+
return candidate.astimezone(timezone.utc)
60+
if sched.frequency == "weekly":
61+
ahead = (int(sched.weekday or 0) - local.weekday()) % 7
62+
candidate = at(local + timedelta(days=ahead))
63+
if candidate <= after:
64+
candidate = at(local + timedelta(days=ahead + 7))
65+
return candidate.astimezone(timezone.utc)
66+
if sched.frequency == "monthly":
67+
year, month = local.year, local.month
68+
for _ in range(3):
69+
last = calendar.monthrange(year, month)[1]
70+
day = min(int(sched.day_of_month or 1), last)
71+
candidate = datetime(year, month, day, hour, minute, tzinfo=tz)
72+
if candidate > after:
73+
return candidate.astimezone(timezone.utc)
74+
month += 1
75+
if month > 12:
76+
month, year = 1, year + 1
77+
return None
78+
79+
80+
def clean(data: dict, profiles, site_has_auth: bool) -> dict:
81+
"""A schedule as a form or the API sends it, checked. Raises ValueError."""
82+
frequency = str(data.get("frequency") or "").strip().lower()
83+
if frequency not in FREQUENCIES:
84+
raise ValueError("How often is once, daily, weekly or monthly.")
85+
time_of_day = str(data.get("time_of_day") or "").strip()
86+
if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", time_of_day):
87+
raise ValueError("Give the time as HH:MM, such as 02:30.")
88+
out = {"frequency": frequency, "time_of_day": time_of_day, "run_on": "",
89+
"weekday": 0, "day_of_month": 1}
90+
if frequency == "once":
91+
day = str(data.get("run_on") or "").strip()[:10]
92+
try:
93+
datetime.strptime(day, "%Y-%m-%d")
94+
except ValueError:
95+
raise ValueError("Give the date to run on, as YYYY-MM-DD.")
96+
out["run_on"] = f"{day}T{time_of_day}"
97+
elif frequency == "weekly":
98+
try:
99+
weekday = int(data.get("weekday"))
100+
except (TypeError, ValueError):
101+
raise ValueError("Pick the day of the week.")
102+
if not 0 <= weekday <= 6:
103+
raise ValueError("Pick the day of the week.")
104+
out["weekday"] = weekday
105+
elif frequency == "monthly":
106+
try:
107+
day = int(data.get("day_of_month"))
108+
except (TypeError, ValueError):
109+
raise ValueError("Pick the day of the month.")
110+
if not 1 <= day <= 31:
111+
raise ValueError("The day of the month is 1 to 31.")
112+
out["day_of_month"] = day
113+
profile = str(data.get("profile") or "").strip().lower()
114+
if profile and profile not in profiles:
115+
raise ValueError("Pick a scan profile.")
116+
out["profile"] = profile
117+
for key in ("authenticated", "ai_triage"):
118+
value = data.get(key)
119+
if isinstance(value, str):
120+
value = value.lower() in ("1", "on", "true", "yes")
121+
out[key] = bool(value)
122+
if out["authenticated"] and not site_has_auth:
123+
raise ValueError("This site has no scan sign in to use. Set one in Edit first.")
124+
return out
125+
126+
127+
def describe(sched) -> str:
128+
"""The schedule in words, for the page and the API."""
129+
if sched.frequency == "once":
130+
return f"Once, on {sched.run_on.replace('T', ' at ')}"
131+
if sched.frequency == "daily":
132+
return f"Every day at {sched.time_of_day}"
133+
if sched.frequency == "weekly":
134+
return f"Every {WEEKDAYS[int(sched.weekday or 0)]} at {sched.time_of_day}"
135+
day = int(sched.day_of_month or 1)
136+
suffix = "th" if 10 <= day % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
137+
tail = ", or the last day of a shorter month" if day > 28 else ""
138+
return f"Every month on the {day}{suffix} at {sched.time_of_day}{tail}"
139+
140+
141+
def as_dict(sched) -> dict:
142+
return {"id": sched.id, "asset_id": sched.asset_id, "frequency": sched.frequency,
143+
"when": describe(sched), "run_on": sched.run_on or None,
144+
"time_of_day": sched.time_of_day, "weekday": sched.weekday,
145+
"day_of_month": sched.day_of_month, "profile": sched.profile or "",
146+
"authenticated": bool(sched.authenticated), "ai_triage": bool(sched.ai_triage),
147+
"enabled": bool(sched.enabled),
148+
"next_run_at": _aware(sched.next_run_at).isoformat() if sched.next_run_at else None,
149+
"last_run_at": _aware(sched.last_run_at).isoformat() if sched.last_run_at else None,
150+
"last_result": sched.last_result or "", "last_scan_id": sched.last_scan_id,
151+
"created_by": sched.created_by or ""}
152+
153+
154+
def create(db, asset: ExternalAsset, data: dict, actor: str) -> ExternalScanSchedule:
155+
from app.core import dast_engines
156+
157+
if db.query(ExternalScanSchedule).filter_by(asset_id=asset.id).count() >= MAX_PER_SITE:
158+
raise ValueError(f"A site can have at most {MAX_PER_SITE} schedules.")
159+
fields = clean(data, dast_engines.PROFILES, bool(asset.auth_kind))
160+
sched = ExternalScanSchedule(asset_id=asset.id, created_by=(actor or "")[:120], **fields)
161+
sched.next_run_at = next_run(sched, utcnow())
162+
if sched.next_run_at is None:
163+
raise ValueError("That time has already passed.")
164+
db.add(sched)
165+
db.flush()
166+
return sched
167+
168+
169+
def run_due(now: datetime = None) -> int:
170+
"""Start every scan whose time has come. The scheduler calls this each minute."""
171+
from app.core import external
172+
173+
now = now or utcnow()
174+
with session_scope() as db:
175+
if not external.enabled(db):
176+
return 0
177+
due = [s.id for s in db.query(ExternalScanSchedule).filter(
178+
ExternalScanSchedule.enabled.is_(True),
179+
ExternalScanSchedule.next_run_at.isnot(None),
180+
ExternalScanSchedule.next_run_at <= now)]
181+
started = 0
182+
for sched_id in due:
183+
with session_scope() as db:
184+
sched = db.get(ExternalScanSchedule, sched_id)
185+
asset = db.get(ExternalAsset, sched.asset_id) if sched else None
186+
if sched is None or asset is None:
187+
continue
188+
args = dict(asset_id=asset.id,
189+
profile=sched.profile or external.profile_for(asset),
190+
actor=sched.created_by, ai=sched.ai_triage,
191+
auth=sched.authenticated)
192+
obsolete = asset.status == "obsolete"
193+
if obsolete:
194+
scan_id, problem = None, "the site is obsolete"
195+
else:
196+
scan_id, problem = external.start_scan(args["asset_id"], args["profile"],
197+
args["actor"], ai_triage=args["ai"],
198+
authenticated=args["auth"])
199+
with session_scope() as db:
200+
sched = db.get(ExternalScanSchedule, sched_id)
201+
if sched is None:
202+
continue
203+
sched.last_run_at = now
204+
sched.last_scan_id = scan_id
205+
sched.last_result = (f"started a {args['profile']} scan" if scan_id
206+
else f"skipped: {problem}")[:300]
207+
sched.next_run_at = next_run(sched, now)
208+
if sched.frequency == "once":
209+
sched.enabled = False
210+
started += 1 if scan_id else 0
211+
log.info("scheduled scan %s of site %s: %s", sched_id, args["asset_id"],
212+
"started" if scan_id else f"skipped, {problem}")
213+
return started
214+
215+
216+
def refresh_all() -> None:
217+
"""Work every next run out again, after the time zone changed."""
218+
with session_scope() as db:
219+
for sched in db.query(ExternalScanSchedule).filter(ExternalScanSchedule.enabled.is_(True)):
220+
sched.next_run_at = next_run(sched, utcnow())

‎app/core/models.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2459,3 +2459,39 @@ class ExternalDastScan(Base):
24592459
note = Column(Text, default="")
24602460
recorded_by = Column(String(120), default="")
24612461
recorded_at = Column(DateTime(timezone=True), default=utcnow)
2462+
2463+
2464+
class ExternalScanSchedule(Base):
2465+
"""A dynamic scan of one site that starts on its own.
2466+
2467+
once at a date and time, or daily, weekly or monthly at a time, read in
2468+
the time zone set in Settings. next_run_at is kept in UTC so the minute
2469+
job only compares two times. A run that finds the site already being
2470+
scanned, or no longer allowed to be, is skipped and says why.
2471+
"""
2472+
2473+
__tablename__ = "external_scan_schedules"
2474+
2475+
id = Column(Integer, primary_key=True)
2476+
asset_id = Column(Integer, ForeignKey("external_assets.id", ondelete="CASCADE"),
2477+
nullable=False, index=True)
2478+
# once, daily, weekly or monthly.
2479+
frequency = Column(String(16), nullable=False, default="weekly")
2480+
# once: the local date and time to run.
2481+
run_on = Column(String(16), default="")
2482+
# HH:MM, local time.
2483+
time_of_day = Column(String(5), default="02:00")
2484+
# weekly: 0 is Monday.
2485+
weekday = Column(Integer, default=0)
2486+
# monthly: 1 to 31; past the end of a short month it runs on its last day.
2487+
day_of_month = Column(Integer, default=1)
2488+
profile = Column(String(16), default="")
2489+
authenticated = Column(Boolean, default=False, nullable=False)
2490+
ai_triage = Column(Boolean, default=False, nullable=False)
2491+
enabled = Column(Boolean, default=True, nullable=False)
2492+
next_run_at = Column(DateTime(timezone=True), index=True)
2493+
last_run_at = Column(DateTime(timezone=True))
2494+
last_result = Column(String(300), default="")
2495+
last_scan_id = Column(Integer)
2496+
created_by = Column(String(120), default="")
2497+
created_at = Column(DateTime(timezone=True), default=utcnow)

‎app/core/scheduler.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,11 @@ def start() -> None:
284284
_scheduler.add_job(external.poll_remote, IntervalTrigger(minutes=3),
285285
id="external-poll", replace_existing=True,
286286
misfire_grace_time=120, coalesce=True, max_instances=1)
287+
from app.core import external_schedule
288+
289+
_scheduler.add_job(external_schedule.run_due, IntervalTrigger(minutes=1),
290+
id="external-schedules", replace_existing=True,
291+
misfire_grace_time=50, coalesce=True, max_instances=1)
287292
_scheduler.add_job(external.refresh_stale,
288293
CronTrigger(hour=3, minute=30, timezone=_zone()),
289294
id="external-refresh", replace_existing=True,

‎app/core/settings_store.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,11 @@
338338
"Before an engine scan, read the default branch of "
339339
"the repositories mapped to the site for routes to "
340340
"add to the scan and for the site's name in the code."),
341+
"external.max_concurrent": (1, False,
342+
"How many scans run at once on this server, 1 to 6. "
343+
"Each runs its own engines, and wapiti and nuclei use "
344+
"real memory, so raise it with the container's memory "
345+
"limit in mind. A site is never scanned twice at once."),
341346
"external.environments": (["Prod", "Dev", "Stage", "Clone"], False,
342347
"The environments a site can be in. The first is the "
343348
"default for a new site. A site keeps one that is "
@@ -502,7 +507,8 @@
502507
("Web testing", ["dast.max_age_days"]),
503508
("External sites", ["external.enabled", "external.screenshots",
504509
"external.refresh_days", "external.whitebox",
505-
"external.environments", "external.user_agent",
510+
"external.max_concurrent", "external.environments",
511+
"external.user_agent",
506512
"external.allow_private"]),
507513
("Limits", ["github.parallel_repos", "scan.max_concurrent",
508514
"secrets.history_window_days",

0 commit comments

Comments
 (0)