Skip to content

Commit a98a47e

Browse files
committed
External sites take their environments from Settings
The environments a site can be in were fixed at Prod, Dev, Stage and Clone. They are now a list under Settings, Web and external sites, that starts with those four. The first one is the default for a new site. Names are kept in lower case on the site, so every existing site stays where it was, and renaming Prod to PROD moves nothing. A site in an environment taken off the list keeps it, says it is no longer listed, and can still be saved, until somebody picks another. The column is widened to 40 characters on start.
1 parent 9f468bb commit a98a47e

13 files changed

Lines changed: 113 additions & 26 deletions

‎app/core/api_reference.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,8 @@ def _f(name, kind, required, description):
455455
_f("technical_owner", "string", False, "Email addresses, comma separated."),
456456
_f("comments", "string", False, "Anything else about the site."),
457457
_f("status", "string", False, "active or obsolete."),
458-
_f("environment", "string", False, "prod, dev, stage or clone."),
458+
_f("environment", "string", False, "One of the environments listed in Settings, "
459+
"which start as Prod, Dev, Stage and Clone."),
459460
_f("external_auth_needed", "boolean", False, "Signing in goes through something outside the site."),
460461
_f("whitelist_needed", "boolean", False, "The scanner has to be let through a firewall."),
461462
_f("change_frequency_days", "integer", False, "How often the site changes, in days."),

‎app/core/dast_report.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
328328
"profile": profile, "profile_text": dast_engines.PROFILES.get(profile, ""),
329329
"limitations": limitations, "links": links, "sast": counts,
330330
"tele": tele, "fields": fields,
331-
"environment": external.ENVIRONMENTS.get(asset.environment, asset.environment or ""),
331+
"environment": external.environments(db).get(asset.environment, asset.environment or ""),
332332
"started": started, "finished": finished,
333333
"duration": int((finished - started).total_seconds()) if started and finished else None,
334334
"organization_name": organization,

‎app/core/external.py‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,52 @@
5050
SERVER_KINDS = {"nextgen-dast": "nextgen-dast (started and read from here)",
5151
"other": "Other (scans are recorded by hand or through the API)"}
5252
BUILTIN_SERVER = "Git Code Review"
53+
# The starting list. The real one is the external.environments setting; see
54+
# environments(). A site stores the name in lower case, so renaming Prod to
55+
# PROD keeps every site in it, and these four match what sites always held.
5356
ENVIRONMENTS = {"prod": "Prod", "dev": "Dev", "stage": "Stage", "clone": "Clone"}
57+
ENV_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._()/&+-]{0,39}$")
58+
59+
60+
def env_key(name) -> str:
61+
return re.sub(r"\s+", " ", str(name or "")).strip().casefold()[:40]
62+
63+
64+
def clean_environments(raw) -> list:
65+
"""The Settings list, checked. Comma or line separated. Raises ValueError."""
66+
if isinstance(raw, str):
67+
raw = raw.replace("\r", "\n").replace("\n", ",").split(",")
68+
out, seen = [], set()
69+
for item in raw or []:
70+
name = re.sub(r"\s+", " ", str(item or "")).strip()
71+
if not name:
72+
continue
73+
if not ENV_NAME.fullmatch(name):
74+
raise ValueError(f"Environment {name[:60]!r} has to be up to 40 letters, numbers, "
75+
"spaces or . _ ( ) / & + - and start with a letter or number.")
76+
if env_key(name) not in seen:
77+
seen.add(env_key(name))
78+
out.append(name)
79+
if not out:
80+
raise ValueError("Keep at least one environment.")
81+
if len(out) > 40:
82+
raise ValueError("At most 40 environments.")
83+
return out
84+
85+
86+
def environments(db) -> dict:
87+
"""{key: name} from Settings, in its order. The first is the default."""
88+
from app.core import settings_store
89+
90+
try:
91+
names = clean_environments(settings_store.get(db, "external.environments") or [])
92+
except ValueError:
93+
names = list(ENVIRONMENTS.values())
94+
return {env_key(n): n for n in names}
95+
96+
97+
def default_environment(db) -> str:
98+
return next(iter(environments(db)))
5499
STATUSES = {"active": "Active", "obsolete": "Obsolete"}
55100
MAX_PICKS = 200
56101
MAX_BRANCHES = 300
@@ -168,7 +213,8 @@ def clean_owners(value, what: str) -> str:
168213
return text
169214

170215

171-
def clean_details(data: dict, partial: bool = False) -> dict:
216+
def clean_details(data: dict, partial: bool = False, envs: dict = None,
217+
keep_env: str = "") -> dict:
172218
"""The fields a person fills in about a site, checked.
173219
174220
data holds strings as a form sends them, or JSON values from the API.
@@ -197,9 +243,13 @@ def present(key):
197243
raise ValueError("Status is Active or Obsolete.")
198244
out["status"] = value
199245
if present("environment") or not partial:
200-
value = str(data.get("environment") or "prod").strip().lower()
201-
if value not in ENVIRONMENTS:
202-
raise ValueError("Environment is Prod, Dev, Stage or Clone.")
246+
envs = envs or ENVIRONMENTS
247+
value = env_key(data.get("environment")) or next(iter(envs))
248+
# A site may keep an environment that was taken off the list since.
249+
if value not in envs and value != env_key(keep_env):
250+
names = list(envs.values())
251+
raise ValueError("Environment is " + (", ".join(names[:-1]) + " or " + names[-1]
252+
if len(names) > 1 else names[0]) + ".")
203253
out["environment"] = value
204254
for key in ("external_auth_needed", "whitelist_needed"):
205255
if present(key) or not partial:
@@ -457,6 +507,7 @@ def add_assets(db, rows: list, actor: str, no_scan: bool = False,
457507
no_scan_reason=(row.get("no_scan_reason")
458508
or no_scan_reason or "")[:300])
459509
db.add(asset)
510+
asset.environment = default_environment(db)
460511
apply_details(asset, details or {})
461512
db.flush()
462513
if picks:
@@ -969,7 +1020,7 @@ def asset_dict(asset: ExternalAsset, links: list = None, counts: dict = None,
9691020
"executive_owner": asset.executive_owner or "",
9701021
"technical_owner": asset.technical_owner or "",
9711022
"status": asset.status or "active",
972-
"environment": asset.environment or "prod",
1023+
"environment": asset.environment or "",
9731024
"external_auth_needed": bool(asset.external_auth_needed),
9741025
"whitelist_needed": bool(asset.whitelist_needed),
9751026
"change_frequency_days": asset.change_frequency_days,

‎app/core/models.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2278,8 +2278,9 @@ class ExternalAsset(Base):
22782278
# active or obsolete. An obsolete site is kept for its history and left
22792279
# out of the nightly telemetry.
22802280
status = Column(String(16), default="active", nullable=False)
2281-
# prod, dev, stage or clone.
2282-
environment = Column(String(16), default="prod", nullable=False)
2281+
# One of the environments listed in Settings, by its name in lower case.
2282+
# The list starts as Prod, Dev, Stage and Clone.
2283+
environment = Column(String(40), default="prod", nullable=False)
22832284
# Whether a scan needs to sign in through something outside the site, and
22842285
# whether the scanner's address has to be let through a firewall first.
22852286
external_auth_needed = Column(Boolean, default=False, nullable=False)

‎app/core/settings_store.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,10 @@
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.environments": (["Prod", "Dev", "Stage", "Clone"], False,
342+
"The environments a site can be in. The first is the "
343+
"default for a new site. A site keeps one that is "
344+
"taken off the list until somebody picks another."),
341345
"external.user_agent": ("", False,
342346
"The browser every scan and telemetry read says it is. "
343347
"Empty uses a current desktop Chrome. It has to start "
@@ -498,7 +502,8 @@
498502
("Web testing", ["dast.max_age_days"]),
499503
("External sites", ["external.enabled", "external.screenshots",
500504
"external.refresh_days", "external.whitebox",
501-
"external.user_agent", "external.allow_private"]),
505+
"external.environments", "external.user_agent",
506+
"external.allow_private"]),
502507
("Limits", ["github.parallel_repos", "scan.max_concurrent",
503508
"secrets.history_window_days",
504509
"scan.timeout_per_repo", "scan.max_hours",

‎app/web/routes_api.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1899,6 +1899,13 @@ def put(key, value):
18991899
"The user agent for scans has to be one line starting Mozilla/5.0 (, "
19001900
"the way a real browser's does."))
19011901
put("external.user_agent", agent)
1902+
from app.core import external as _external
1903+
1904+
try:
1905+
put("external.environments", _external.clean_environments(
1906+
text("external.environments") or "Prod, Dev, Stage, Clone"))
1907+
except ValueError as exc:
1908+
raise HTTPException(status_code=400, detail=f"Environments: {exc}")
19021909
put("external.refresh_days", clean_int(form.get("external.refresh_days", 7) or 0,
19031910
"Read telemetry again after",
19041911
low=0, high=365))

‎app/web/routes_external.py‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def list_page(request: Request, q: str = "", tag: str = "", app: str = "",
144144
app = clean_text(app, "Application", limit=200, required=False)
145145
opts = external.options(db)
146146
mapper = external.Mapper(db, user)
147+
envs = external.environments(db)
147148
assets = db.query(ExternalAsset).order_by(ExternalAsset.host).all()
148149
blocked = external.blocked_hosts(db)
149150
every_link = {a.id: mapper.links(a) for a in assets}
@@ -166,7 +167,7 @@ def list_page(request: Request, q: str = "", tag: str = "", app: str = "",
166167
"tele": external.telemetry_of(a),
167168
"scan": scanning.get(a.id),
168169
}
169-
if env and (a.environment or "prod") != env:
170+
if env and (a.environment or "") != env:
170171
continue
171172
if status and (a.status or "active") != status:
172173
continue
@@ -217,7 +218,7 @@ def list_page(request: Request, q: str = "", tag: str = "", app: str = "",
217218
profiles=dast_engines.PROFILES, default_profile=dast_engines.DEFAULT_PROFILE,
218219
q=q, tag=tag, app=app, grade=grade, dast=dast, risk=risk,
219220
scope=scope, all_tags=all_tags, apps=apps, opts=opts,
220-
env=env, status=status, environments=external.ENVIRONMENTS,
221+
env=env, status=status, environments=envs,
221222
statuses=external.STATUSES,
222223
servers=servers,
223224
running=sum(1 for a in assets if a.telemetry_status == "running"),
@@ -235,6 +236,7 @@ def export_csv(request: Request, user: User = Depends(current_user)):
235236
with session_scope() as db:
236237
_guard(db, user)
237238
mapper = external.Mapper(db, user)
239+
envs = external.environments(db)
238240
out = io.StringIO()
239241
writer = csv.writer(out)
240242
writer.writerow(["host", "kind", "parent_domain", "application", "tags",
@@ -253,7 +255,7 @@ def export_csv(request: Request, user: User = Depends(current_user)):
253255
or [l["full_name"]]
254256
writer.writerow([_quote_formula(str(c)) for c in [
255257
a.host, a.kind, a.parent_domain, a.application, "; ".join(a.tag_list()),
256-
external.ENVIRONMENTS.get(a.environment, a.environment or ""),
258+
envs.get(a.environment, a.environment or ""),
257259
external.STATUSES.get(a.status, a.status or ""),
258260
a.executive_owner or "", a.technical_owner or "",
259261
"yes" if a.external_auth_needed else "no",
@@ -288,7 +290,7 @@ async def add(request: Request, user: User = Depends(current_user)):
288290
no_scan = _checked(form, "no_scan")
289291
reason = clean_text(form.get("no_scan_reason"), "Reason", limit=300, required=False)
290292
try:
291-
details = external.clean_details(_detail_form(form))
293+
details = external.clean_details(_detail_form(form), envs=external.environments(db))
292294
picks = external.parse_picks(form.getlist("pick"))
293295
got = external.add_assets(db, rows, user.username, no_scan, reason, tags,
294296
details=details, picks=picks, user=user)
@@ -502,7 +504,7 @@ def detail_page(asset_id: int, request: Request, scans: int = 1,
502504
auth=external_auth_mod.describe(asset), auth_kinds=external_auth_mod.KINDS,
503505
site_profile=external.profile_for(asset),
504506
picks_json=json.dumps(external.picks_of(db, asset)),
505-
environments=external.ENVIRONMENTS, statuses=external.STATUSES,
507+
environments=external.environments(db), statuses=external.STATUSES,
506508
scan_views=[external.scan_dict(s) for s in page_scans],
507509
servers=db.query(DastServer).order_by(DastServer.name).all(),
508510
profiles=dast_engines.PROFILES, active=dast_engines.ACTIVE_PROFILES,
@@ -541,7 +543,8 @@ async def edit(asset_id: int, request: Request, user: User = Depends(current_use
541543
asset.application = clean_text(form.get("application"), "Application",
542544
limit=200, required=False)
543545
try:
544-
external.apply_details(asset, external.clean_details(_detail_form(form)))
546+
external.apply_details(asset, external.clean_details(
547+
_detail_form(form), envs=external.environments(db), keep_env=asset.environment))
545548
if form.get("with_picks"):
546549
external.set_picks(db, asset, external.parse_picks(form.getlist("pick")),
547550
user.username, user)
@@ -1053,7 +1056,7 @@ def api_list(request: Request, caller=Depends(_api_caller())):
10531056
if params.get("severity"):
10541057
query = query.filter(ExternalAsset.last_dast_severity == params["severity"][:16])
10551058
if params.get("environment"):
1056-
query = query.filter(ExternalAsset.environment == params["environment"].lower()[:16])
1059+
query = query.filter(ExternalAsset.environment == external.env_key(params["environment"]))
10571060
if params.get("status"):
10581061
query = query.filter(ExternalAsset.status == params["status"].lower()[:16])
10591062
total = query.count()
@@ -1079,7 +1082,7 @@ async def api_add(request: Request, caller=Depends(_api_caller())):
10791082
me = _api_me(db, caller)
10801083
existed = db.query(ExternalAsset).filter(ExternalAsset.host == host).one_or_none()
10811084
try:
1082-
details = external.clean_details(_api_details(body))
1085+
details = external.clean_details(_api_details(body), envs=external.environments(db))
10831086
picks = external.picks_from_names(db, body.get("repositories") or [])
10841087
got = external.add_assets(db, [{
10851088
"host": host, "application": _str_field(body, "application", 200) or "",
@@ -1117,8 +1120,9 @@ async def api_patch(asset_id: int, request: Request, caller=Depends(_api_caller(
11171120
if value is not None:
11181121
asset.application = value
11191122
try:
1120-
external.apply_details(asset, external.clean_details(_api_details(body),
1121-
partial=True))
1123+
external.apply_details(asset, external.clean_details(
1124+
_api_details(body), partial=True, envs=external.environments(db),
1125+
keep_env=asset.environment))
11221126
if "repositories" in body:
11231127
external.set_picks(db, asset,
11241128
external.picks_from_names(db, body["repositories"] or []),

‎app/web/templates/_external_fields.html‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@
1717
<span class="faint small">Email only.</span></label>
1818
<label>Environment
1919
<select name="environment">
20+
{% set current = a.environment if a else (environments | list | first) %}
2021
{% for key, label in environments.items() %}
21-
<option value="{{ key }}" {{ 'selected' if (a.environment if a else 'prod') == key }}>{{ label }}</option>
22+
<option value="{{ key }}" {{ 'selected' if current == key }}>{{ label }}</option>
2223
{% endfor %}
23-
</select></label>
24+
{% if a and a.environment and a.environment not in environments %}
25+
<option value="{{ a.environment }}" selected>{{ a.environment }} (no longer in the list)</option>
26+
{% endif %}
27+
</select>
28+
<span class="faint small">The list is in Settings, under Web and external sites.</span></label>
2429
<label>Status
2530
<select name="status">
2631
{% for key, label in statuses.items() %}

‎app/web/templates/external.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@
9898
{% if a.application %}<div class="small">{{ a.application }}</div>{% endif %}
9999
<div style="margin-top:.2rem">{% for t in r.tags %}<a class="badge" href="{{ here }}?tag={{ t | urlencode }}">{{ t }}</a> {% endfor %}</div>
100100
{% if r.blocked %}<span class="badge waived" title="{{ a.no_scan_reason or 'under a domain marked no scan' }}">no scan</span>{% endif %}
101-
<div style="margin-top:.2rem"><span class="badge {{ 'high' if a.environment == 'prod' else 'info' }}">{{ environments.get(a.environment, a.environment) }}</span>
101+
<div style="margin-top:.2rem"><span class="badge {{ 'high' if (a.environment or '').startswith('prod') else 'info' }}" {% if a.environment not in environments %}title="No longer in the environments list"{% endif %}>{{ environments.get(a.environment, a.environment) }}</span>
102102
{% if a.status == 'obsolete' %}<span class="badge waived">obsolete</span>{% endif %}
103103
{% if a.external_auth_needed %}<span class="badge info" title="External auth needed">ext auth</span>{% endif %}
104104
{% if a.whitelist_needed %}<span class="badge pending" title="Whitelist needed">whitelist</span>{% endif %}</div>

‎app/web/templates/external_site.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
{%- if asset.application %} &middot; {{ asset.application }}{% endif %}
1111
&middot; added {{ asset.added_at | dt('%Y-%m-%d') }}{% if asset.added_by %} by {{ asset.added_by }}{% endif %}</div>
1212
<div style="margin-top:.3rem">
13-
<span class="badge {{ 'high' if asset.environment == 'prod' else 'info' }}">{{ environments.get(asset.environment, asset.environment) }}</span>
13+
<span class="badge {{ 'high' if (asset.environment or '').startswith('prod') else 'info' }}">{{ environments.get(asset.environment, asset.environment) }}</span>
1414
<span class="badge {{ 'waived' if asset.status == 'obsolete' else 'ok' }}">{{ statuses.get(asset.status, asset.status) }}</span>
1515
{% for t in tags %}<a class="badge" href="{{ base }}/external?tag={{ t | urlencode }}">{{ t }}</a> {% endfor %}
1616
{% if blocked %}<span class="badge waived">no scan</span> <span class="small faint">{{ blocked }}</span>{% endif %}</div>
@@ -77,7 +77,7 @@
7777
<tr><th>Application</th><td>{{ asset.application or '' }}{% if not asset.application %}<span class="faint">nobody has said</span>{% endif %}</td></tr>
7878
<tr><th>Executive owner</th><td>{% for o in (asset.executive_owner or '').split(', ') if o %}<div><a href="mailto:{{ o }}">{{ o }}</a></div>{% else %}<span class="faint">nobody has said</span>{% endfor %}</td></tr>
7979
<tr><th>Technical owner</th><td>{% for o in (asset.technical_owner or '').split(', ') if o %}<div><a href="mailto:{{ o }}">{{ o }}</a></div>{% else %}<span class="faint">nobody has said</span>{% endfor %}</td></tr>
80-
<tr><th>Environment</th><td>{{ environments.get(asset.environment, asset.environment) }}</td></tr>
80+
<tr><th>Environment</th><td>{{ environments.get(asset.environment, asset.environment) }}{% if asset.environment not in environments %} <span class="faint small">(no longer in the list in Settings)</span>{% endif %}</td></tr>
8181
<tr><th>Status</th><td>{{ statuses.get(asset.status, asset.status) }}</td></tr>
8282
<tr><th>External auth needed</th><td>{{ 'Yes' if asset.external_auth_needed else 'No' }}</td></tr>
8383
<tr><th>Whitelist needed</th><td>{{ 'Yes' if asset.whitelist_needed else 'No' }}</td></tr>

0 commit comments

Comments
 (0)