audit: resident website loop architecture and code quality to 10/10
Fix F1 name race with partial unique index, F2 pause/end mid-poll race with guarded updates, F3 redirect/numeric-IP SSRF, F4 quota prefix check, F5 duplicate row_get, F6 audit ternary; S1 unused import, S2 backoff doc, S3 defensive tearDown. Directed 20 pass, full 1700 pass.
This commit is contained in:
1 parent
23725f92e6
commit
6525bcafd0
5 files changed
+561
-55
No files matched your search
+162
-46
@@ -56,21 +56,26 @@ process_due_resident_watches(见 worker 里的调用点),或
|
|||||||
process_due_resident_watches;两者都走 claim 门,两个驱动
|
process_due_resident_watches;两者都走 claim 门,两个驱动
|
||||||
同时跑也不会在同一间隔内重复轮询。
|
同时跑也不会在同一间隔内重复轮询。
|
||||||
|
|
||||||
本模块只依赖标准库 + cloud.automations 的两条复用函数
|
本模块只依赖标准库 + cloud.automations 的复用函数
|
||||||
(_audit_row、_automation_quota_exceeded),不 import
|
(row_get、_audit_row、_automation_quota_exceeded)与
|
||||||
cloud 其它模块,避免循环 import。
|
cloud.db 的完整性错误类型(仅用于把唯一索引冲突转成
|
||||||
|
ValueError,不 import 其它 cloud 模块),避免循环 import。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
from urllib.parse import urljoin
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from cloud.automations import _audit_row as _auto_audit_row
|
from cloud.automations import _audit_row as _auto_audit_row
|
||||||
from cloud.automations import _automation_quota_exceeded
|
from cloud.automations import _automation_quota_exceeded
|
||||||
|
from cloud.automations import row_get
|
||||||
|
from cloud.db import PostgresIntegrityError as _PgIntegrityError
|
||||||
|
|
||||||
MIN_INTERVAL_SECONDS = 60
|
MIN_INTERVAL_SECONDS = 60
|
||||||
MAX_INTERVAL_SECONDS = 30 * 86400
|
MAX_INTERVAL_SECONDS = 30 * 86400
|
||||||
@@ -95,14 +100,6 @@ ENDED = "ended"
|
|||||||
_WS_RE = re.compile(r"\s+")
|
_WS_RE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
def row_get(row, name):
|
|
||||||
"""行取值容错:与 automations.row_get 同款语义。"""
|
|
||||||
try:
|
|
||||||
return row[name]
|
|
||||||
except (KeyError, IndexError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
WATCH_COLUMNS = (
|
WATCH_COLUMNS = (
|
||||||
"id", "project_id", "name", "target_url", "prompt",
|
"id", "project_id", "name", "target_url", "prompt",
|
||||||
"interval_seconds", "fetch_timeout_seconds", "status",
|
"interval_seconds", "fetch_timeout_seconds", "status",
|
||||||
@@ -191,13 +188,28 @@ def ensure_resident_tables(conn):
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_resident_watches_project"
|
"CREATE INDEX IF NOT EXISTS idx_resident_watches_project"
|
||||||
" ON resident_watches(project_id, status)")
|
" ON resident_watches(project_id, status)")
|
||||||
|
# 同项目未结束名唯一:先查后插挡不住并发,唯一索引才是
|
||||||
|
# 真正的关口(server environments 的同款部分唯一索引,
|
||||||
|
# WHERE status != 'archived' 的 resident 版);已结束名
|
||||||
|
# 不在索引内,可重用。已存在重复名的旧库建索引会失败,
|
||||||
|
# 此时不炸掉整次建表调用,SELECT 查重仍挡正常路径。
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS"
|
||||||
|
" idx_resident_watches_name"
|
||||||
|
" ON resident_watches(project_id, name)"
|
||||||
|
" WHERE status != 'ended'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_resident_polls_watch"
|
"CREATE INDEX IF NOT EXISTS idx_resident_polls_watch"
|
||||||
" ON resident_polls(watch_id, created_at)")
|
" ON resident_polls(watch_id, created_at)")
|
||||||
|
|
||||||
|
|
||||||
def _backoff_seconds(failures):
|
def _backoff_seconds(failures):
|
||||||
"""失败退避:30、60、120……秒,上限 3600 秒。"""
|
"""失败退避增量:30、60、120……秒,上限 3600 秒。
|
||||||
|
调用方把它加在间隔之上(下次 = now + interval + backoff),
|
||||||
|
失败后绝不比正常间隔更频繁地重试,行为见失败分支测试。"""
|
||||||
if failures <= 0:
|
if failures <= 0:
|
||||||
return 0
|
return 0
|
||||||
return min(3600, 30 * (2 ** (failures - 1)))
|
return min(3600, 30 * (2 ** (failures - 1)))
|
||||||
@@ -236,11 +248,42 @@ def watch_to_dict(row, now=None):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noncanonical_numeric_host(host):
|
||||||
|
"""非规范数字 IP 写法:单整数(2130706433)、十六进制
|
||||||
|
(0x7f000001)、前导零八进制(0177.0.0.1)、含十六进制段
|
||||||
|
的点分写法——urllib/socket 的 inet_aton 会把它们解析成
|
||||||
|
数字 IP,ipaddress 却认不出来。规范点分十进制(4 段、
|
||||||
|
每段 0-255、无前导零,0 本身除外)不在此列;普通域名
|
||||||
|
(含字母的标签)也不在此列。"""
|
||||||
|
if re.fullmatch(r"0[xX][0-9a-fA-F]+", host):
|
||||||
|
return True
|
||||||
|
if re.fullmatch(r"[0-9]+", host):
|
||||||
|
return True
|
||||||
|
if "." not in host:
|
||||||
|
return False
|
||||||
|
labels = host.split(".")
|
||||||
|
if not all(re.fullmatch(r"0[xX][0-9a-fA-F]+|[0-9]+", lab)
|
||||||
|
for lab in labels):
|
||||||
|
return False
|
||||||
|
if len(labels) != 4:
|
||||||
|
return True
|
||||||
|
for lab in labels:
|
||||||
|
if lab.startswith("0x") or lab.startswith("0X"):
|
||||||
|
return True
|
||||||
|
if len(lab) > 1 and lab.startswith("0"):
|
||||||
|
return True
|
||||||
|
if int(lab) > 255:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def validate_target_url(url):
|
def validate_target_url(url):
|
||||||
"""目标 URL 校验:仅 http/https、无 userinfo、主机非空、
|
"""目标 URL 校验:仅 http/https、无 userinfo、主机非空、
|
||||||
字面 localhost / 私有 / 回环 / 链路本地 IP 拒绝(与
|
字面 localhost(含尾点、*.localhost)/ 私有 / 回环 /
|
||||||
|
链路本地 IP / 非规范数字 IP 写法拒绝(与
|
||||||
websearch._is_blocked_fetch_url 的字面 IP 口径同款,
|
websearch._is_blocked_fetch_url 的字面 IP 口径同款,
|
||||||
DNS 解析后的私有地址不在 v1 覆盖内,见摘要已知偏差)。"""
|
DNS 解析后的私有地址不在 v1 覆盖内,见摘要已知偏差;
|
||||||
|
重定向目标由 _SafeRedirectHandler 逐跳复用本校验)。"""
|
||||||
if not url or not isinstance(url, str) or not url.strip():
|
if not url or not isinstance(url, str) or not url.strip():
|
||||||
raise ValueError("target_url required")
|
raise ValueError("target_url required")
|
||||||
url = url.strip()
|
url = url.strip()
|
||||||
@@ -251,11 +294,13 @@ def validate_target_url(url):
|
|||||||
raise ValueError("target_url must be http or https")
|
raise ValueError("target_url must be http or https")
|
||||||
if parts.username or parts.password:
|
if parts.username or parts.password:
|
||||||
raise ValueError("target_url must not contain credentials")
|
raise ValueError("target_url must not contain credentials")
|
||||||
host = (parts.hostname or "").strip()
|
host = (parts.hostname or "").strip().lower().rstrip(".")
|
||||||
if not host:
|
if not host:
|
||||||
raise ValueError("target_url host required")
|
raise ValueError("target_url host required")
|
||||||
if host == "localhost":
|
if host == "localhost" or host.endswith(".localhost"):
|
||||||
raise ValueError("localhost URLs are blocked")
|
raise ValueError("localhost URLs are blocked")
|
||||||
|
if _is_noncanonical_numeric_host(host):
|
||||||
|
raise ValueError("non-canonical numeric IP URLs are blocked")
|
||||||
try:
|
try:
|
||||||
addr = ipaddress.ip_address(host)
|
addr = ipaddress.ip_address(host)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -322,6 +367,7 @@ def create_watch(conn, project_id, name, target_url, prompt, created_by,
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"resident watch name already exists in this project")
|
"resident watch name already exists in this project")
|
||||||
watch_id = uuid.uuid4().hex
|
watch_id = uuid.uuid4().hex
|
||||||
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO resident_watches(id, project_id, name, target_url,"
|
"INSERT INTO resident_watches(id, project_id, name, target_url,"
|
||||||
" prompt, interval_seconds, fetch_timeout_seconds, status,"
|
" prompt, interval_seconds, fetch_timeout_seconds, status,"
|
||||||
@@ -334,6 +380,16 @@ def create_watch(conn, project_id, name, target_url, prompt, created_by,
|
|||||||
(watch_id, project_id, name, target_url, prompt,
|
(watch_id, project_id, name, target_url, prompt,
|
||||||
interval_seconds, fetch_timeout_seconds, RUNNING, now,
|
interval_seconds, fetch_timeout_seconds, RUNNING, now,
|
||||||
created_by, now, now))
|
created_by, now, now))
|
||||||
|
except (sqlite3.IntegrityError, _PgIntegrityError) as exc:
|
||||||
|
# 并发下 SELECT 查重与 INSERT 之间可能插进同名行,
|
||||||
|
# 唯一索引是真正的关口:冲突转回与查重同款 ValueError,
|
||||||
|
# 调用方(server)据此返回 400,不把数据库错误外泄。
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise ValueError(
|
||||||
|
"resident watch name already exists in this project") from exc
|
||||||
conn.commit()
|
conn.commit()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
||||||
@@ -484,11 +540,19 @@ def update_watch(conn, watch_id, fields, now=None):
|
|||||||
if clash is not None:
|
if clash is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"resident watch name already exists in this project")
|
"resident watch name already exists in this project")
|
||||||
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE resident_watches SET name = ?, prompt = ?,"
|
"UPDATE resident_watches SET name = ?, prompt = ?,"
|
||||||
" target_url = ?, interval_seconds = ?,"
|
" target_url = ?, interval_seconds = ?,"
|
||||||
" fetch_timeout_seconds = ?, updated_at = ? WHERE id = ?",
|
" fetch_timeout_seconds = ?, updated_at = ? WHERE id = ?",
|
||||||
(name, prompt, target_url, interval, timeout, now, watch_id))
|
(name, prompt, target_url, interval, timeout, now, watch_id))
|
||||||
|
except (sqlite3.IntegrityError, _PgIntegrityError) as exc:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise ValueError(
|
||||||
|
"resident watch name already exists in this project") from exc
|
||||||
conn.commit()
|
conn.commit()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
||||||
@@ -540,13 +604,27 @@ def _content_hash(text):
|
|||||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""重定向逐跳校验:创建时只校验了初始 URL,公开 URL
|
||||||
|
重定向到内网地址会绕过那道校验;相对跳转先 urljoin 成
|
||||||
|
绝对 URL 再校验,不合格直接抛 ValueError,不跟随。"""
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
validate_target_url(urljoin(req.full_url, newurl))
|
||||||
|
return super().redirect_request(req, fp, code, msg, headers,
|
||||||
|
newurl)
|
||||||
|
|
||||||
|
|
||||||
def default_fetch(url, timeout):
|
def default_fetch(url, timeout):
|
||||||
"""默认廉价抓取:urllib,超时可配,读满 1MB 即断,
|
"""默认廉价抓取:urllib,超时可配,读满 1MB 即断,
|
||||||
解码失败按 replace 处理。调用方可注入 fetcher 替换它
|
解码失败按 replace 处理。调用方可注入 fetcher 替换它
|
||||||
(测试与未来选择器提取都走注入点,不改本函数签名)。"""
|
(测试与未来选择器提取都走注入点,不改本函数签名)。
|
||||||
|
build_opener 带默认处理器(含 ProxyHandler,no_proxy
|
||||||
|
行为与 urlopen 同款),只把重定向处理器换成逐跳校验版。"""
|
||||||
req = urllib.request.Request(url, headers={
|
req = urllib.request.Request(url, headers={
|
||||||
"User-Agent": "codex-app-server-resident-watch/1.0"})
|
"User-Agent": "codex-app-server-resident-watch/1.0"})
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
opener = urllib.request.build_opener(_SafeRedirectHandler())
|
||||||
|
with opener.open(req, timeout=timeout) as resp:
|
||||||
raw = resp.read(MAX_FETCH_BYTES + 1)[:MAX_FETCH_BYTES]
|
raw = resp.read(MAX_FETCH_BYTES + 1)[:MAX_FETCH_BYTES]
|
||||||
return raw.decode("utf-8", errors="replace")
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
@@ -606,6 +684,18 @@ def _create_change_task(conn, watch, excerpt, now):
|
|||||||
return task_id
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def _guarded_watch_update(conn, watch_id, sets, vals):
|
||||||
|
"""终局 UPDATE 带 status='running' 条件:抓取中途被用户
|
||||||
|
暂停/结束时,条件不满足、rowcount 为 0,调用方据此回滚
|
||||||
|
并放弃本轮(不建任务、不记轮询),而不是把 paused/ended
|
||||||
|
行的 next_poll_at 改回未来值。返回是否成功。"""
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE resident_watches SET %s WHERE id = ? AND status = ?"
|
||||||
|
% ", ".join(sets),
|
||||||
|
tuple(vals) + (watch_id, RUNNING))
|
||||||
|
return cur.rowcount == 1
|
||||||
|
|
||||||
|
|
||||||
def run_due_resident_watch(conn, watch_id, fetcher=None, now=None):
|
def run_due_resident_watch(conn, watch_id, fetcher=None, now=None):
|
||||||
"""单条常驻监听的驱动唯一入口:抢单 -> 廉价抓取 -> hash
|
"""单条常驻监听的驱动唯一入口:抢单 -> 廉价抓取 -> hash
|
||||||
比对 -> 记轮询 -> 仅变化时建任务。
|
比对 -> 记轮询 -> 仅变化时建任务。
|
||||||
@@ -624,7 +714,13 @@ def run_due_resident_watch(conn, watch_id, fetcher=None, now=None):
|
|||||||
return None
|
return None
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
"SELECT * FROM resident_watches WHERE id = ?", (watch_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
watch = watch_to_dict(row, now)
|
watch = watch_to_dict(row, now)
|
||||||
|
if watch["status"] != RUNNING:
|
||||||
|
# 认领后、抓取前被用户暂停/结束:不抓取、不建任务、
|
||||||
|
# 不改行(暂停/结束已把 next_poll_at 清掉,保持原样)。
|
||||||
|
return None
|
||||||
interval = watch["interval_seconds"]
|
interval = watch["interval_seconds"]
|
||||||
timeout = watch["fetch_timeout_seconds"]
|
timeout = watch["fetch_timeout_seconds"]
|
||||||
fetch = fetcher or default_fetch
|
fetch = fetcher or default_fetch
|
||||||
@@ -633,10 +729,13 @@ def run_due_resident_watch(conn, watch_id, fetcher=None, now=None):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failures = watch["consecutive_failures"] + 1
|
failures = watch["consecutive_failures"] + 1
|
||||||
nxt = now + interval + _backoff_seconds(failures)
|
nxt = now + interval + _backoff_seconds(failures)
|
||||||
conn.execute(
|
if not _guarded_watch_update(
|
||||||
"UPDATE resident_watches SET consecutive_failures = ?,"
|
conn, watch_id,
|
||||||
" next_poll_at = ?, updated_at = ? WHERE id = ?",
|
("consecutive_failures = ?", "next_poll_at = ?",
|
||||||
(failures, nxt, now, watch_id))
|
"updated_at = ?"),
|
||||||
|
(failures, nxt, now)):
|
||||||
|
conn.rollback()
|
||||||
|
return None
|
||||||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||||||
"failed", None, str(exc)[:MAX_POLL_DETAIL_LEN],
|
"failed", None, str(exc)[:MAX_POLL_DETAIL_LEN],
|
||||||
None, now)
|
None, now)
|
||||||
@@ -651,32 +750,42 @@ def run_due_resident_watch(conn, watch_id, fetcher=None, now=None):
|
|||||||
excerpt = text[:MAX_CHANGE_TEXT_LEN]
|
excerpt = text[:MAX_CHANGE_TEXT_LEN]
|
||||||
old_hash = watch["last_content_hash"]
|
old_hash = watch["last_content_hash"]
|
||||||
if old_hash is None:
|
if old_hash is None:
|
||||||
conn.execute(
|
if not _guarded_watch_update(
|
||||||
"UPDATE resident_watches SET last_content_hash = ?,"
|
conn, watch_id,
|
||||||
" consecutive_failures = 0, next_poll_at = ?,"
|
("last_content_hash = ?", "consecutive_failures = 0",
|
||||||
" updated_at = ? WHERE id = ?",
|
"next_poll_at = ?", "updated_at = ?"),
|
||||||
(observed, now + interval, now, watch_id))
|
(observed, now + interval, now)):
|
||||||
|
conn.rollback()
|
||||||
|
return None
|
||||||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||||||
"baseline", observed, "first poll", None, now)
|
"baseline", observed, "first poll", None, now)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return poll
|
return poll
|
||||||
if observed == old_hash:
|
if observed == old_hash:
|
||||||
conn.execute(
|
if not _guarded_watch_update(
|
||||||
"UPDATE resident_watches SET consecutive_failures = 0,"
|
conn, watch_id,
|
||||||
" next_poll_at = ?, updated_at = ? WHERE id = ?",
|
("consecutive_failures = 0", "next_poll_at = ?",
|
||||||
(now + interval, now, watch_id))
|
"updated_at = ?"),
|
||||||
|
(now + interval, now)):
|
||||||
|
conn.rollback()
|
||||||
|
return None
|
||||||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||||||
"unchanged", observed, "", None, now)
|
"unchanged", observed, "", None, now)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return poll
|
return poll
|
||||||
# 真实变化:配额门只在这里过。
|
# 真实变化:配额门只在这里过。配额门契约是前缀
|
||||||
|
# (quota_check_failed: / quota_exceeded: / None),不用
|
||||||
|
# 子串判定,以免超限文本含该子串时误判。
|
||||||
gate = _automation_quota_exceeded(conn, watch["created_by"])
|
gate = _automation_quota_exceeded(conn, watch["created_by"])
|
||||||
if gate is not None and "quota_check_failed" not in gate:
|
if gate is not None and not gate.startswith("quota_check_failed"):
|
||||||
conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE resident_watches SET status = ?,"
|
"UPDATE resident_watches SET status = ?,"
|
||||||
" paused_reason = ?, next_poll_at = NULL, updated_at = ?"
|
" paused_reason = ?, next_poll_at = NULL, updated_at = ?"
|
||||||
" WHERE id = ?",
|
" WHERE id = ? AND status = ?",
|
||||||
(PAUSED, "quota_exceeded", now, watch_id))
|
(PAUSED, "quota_exceeded", now, watch_id, RUNNING))
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
conn.rollback()
|
||||||
|
return None
|
||||||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||||||
"changed_quota_paused", observed, gate,
|
"changed_quota_paused", observed, gate,
|
||||||
None, now)
|
None, now)
|
||||||
@@ -688,22 +797,29 @@ def run_due_resident_watch(conn, watch_id, fetcher=None, now=None):
|
|||||||
if gate is not None:
|
if gate is not None:
|
||||||
failures = watch["consecutive_failures"] + 1
|
failures = watch["consecutive_failures"] + 1
|
||||||
nxt = now + interval + _backoff_seconds(failures)
|
nxt = now + interval + _backoff_seconds(failures)
|
||||||
conn.execute(
|
if not _guarded_watch_update(
|
||||||
"UPDATE resident_watches SET consecutive_failures = ?,"
|
conn, watch_id,
|
||||||
" next_poll_at = ?, updated_at = ? WHERE id = ?",
|
("consecutive_failures = ?", "next_poll_at = ?",
|
||||||
(failures, nxt, now, watch_id))
|
"updated_at = ?"),
|
||||||
|
(failures, nxt, now)):
|
||||||
|
conn.rollback()
|
||||||
|
return None
|
||||||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||||||
"changed_quota_check_failed", observed, gate,
|
"changed_quota_check_failed", observed, gate,
|
||||||
None, now)
|
None, now)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return poll
|
return poll
|
||||||
|
# 先受保护地推进游标,再建任务:抓取中途被暂停/结束时,
|
||||||
|
# 条件不满足即回滚,本轮不建任务、不记轮询。
|
||||||
|
if not _guarded_watch_update(
|
||||||
|
conn, watch_id,
|
||||||
|
("last_content_hash = ?", "last_change_at = ?",
|
||||||
|
"last_change_text = ?", "consecutive_failures = 0",
|
||||||
|
"next_poll_at = ?", "updated_at = ?"),
|
||||||
|
(observed, now, excerpt, now + interval, now)):
|
||||||
|
conn.rollback()
|
||||||
|
return None
|
||||||
task_id = _create_change_task(conn, watch, excerpt, now)
|
task_id = _create_change_task(conn, watch, excerpt, now)
|
||||||
conn.execute(
|
|
||||||
"UPDATE resident_watches SET last_content_hash = ?,"
|
|
||||||
" last_change_at = ?, last_change_text = ?,"
|
|
||||||
" consecutive_failures = 0, next_poll_at = ?,"
|
|
||||||
" updated_at = ? WHERE id = ?",
|
|
||||||
(observed, now, excerpt, now + interval, now, watch_id))
|
|
||||||
poll = _record_poll(conn, watch_id, watch["project_id"],
|
poll = _record_poll(conn, watch_id, watch["project_id"],
|
||||||
"changed", observed, excerpt[:MAX_POLL_DETAIL_LEN],
|
"changed", observed, excerpt[:MAX_POLL_DETAIL_LEN],
|
||||||
task_id, now)
|
task_id, now)
|
||||||
|
|||||||
+4
-6
@@ -10245,13 +10245,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
"error": "resident watch in status %s cannot %s"
|
"error": "resident watch in status %s cannot %s"
|
||||||
% (watch["status"], action)})
|
% (watch["status"], action)})
|
||||||
return
|
return
|
||||||
|
audit_action = {"pause": "resident_watch_paused",
|
||||||
|
"resume": "resident_watch_resumed",
|
||||||
|
"end": "resident_watch_ended"}[action]
|
||||||
with _auth_store(self.server.db_path) as store:
|
with _auth_store(self.server.db_path) as store:
|
||||||
store.audit(user_id, project_id,
|
store.audit(user_id, project_id, audit_action, watch_id)
|
||||||
"resident_watch_%s" % ("ended" if action == "end"
|
|
||||||
else action + "d"
|
|
||||||
if action == "pause"
|
|
||||||
else "resumed"),
|
|
||||||
watch_id)
|
|
||||||
self._send_json(200, watch)
|
self._send_json(200, watch)
|
||||||
|
|
||||||
def _triage_inbox(self, project_id):
|
def _triage_inbox(self, project_id):
|
||||||
|
|||||||
@@ -2,19 +2,23 @@
|
|||||||
抓取一律注入假 fetcher。覆盖创建校验、暂停保游标、恢复续跑、
|
抓取一律注入假 fetcher。覆盖创建校验、暂停保游标、恢复续跑、
|
||||||
结束终态、防重复轮询、仅变化建任务、失败退避、配额耗尽转暂停、
|
结束终态、防重复轮询、仅变化建任务、失败退避、配额耗尽转暂停、
|
||||||
worker 驱动批量入口。"""
|
worker 驱动批量入口。"""
|
||||||
|
import http.server
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
import cloud.resident as resident_module
|
||||||
from cloud.resident import (
|
from cloud.resident import (
|
||||||
claim_due_watch,
|
claim_due_watch,
|
||||||
create_watch,
|
create_watch,
|
||||||
|
default_fetch,
|
||||||
end_watch,
|
end_watch,
|
||||||
ensure_resident_tables,
|
ensure_resident_tables,
|
||||||
get_watch,
|
get_watch,
|
||||||
@@ -24,14 +28,17 @@ try:
|
|||||||
resume_watch,
|
resume_watch,
|
||||||
run_due_resident_watch,
|
run_due_resident_watch,
|
||||||
update_watch,
|
update_watch,
|
||||||
|
validate_target_url,
|
||||||
)
|
)
|
||||||
from cloud.server import init_db
|
from cloud.server import init_db
|
||||||
from cloud.worker import Worker
|
from cloud.worker import Worker
|
||||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
import cloud.resident as resident_module
|
||||||
from cloud.resident import (
|
from cloud.resident import (
|
||||||
claim_due_watch,
|
claim_due_watch,
|
||||||
create_watch,
|
create_watch,
|
||||||
|
default_fetch,
|
||||||
end_watch,
|
end_watch,
|
||||||
ensure_resident_tables,
|
ensure_resident_tables,
|
||||||
get_watch,
|
get_watch,
|
||||||
@@ -41,6 +48,7 @@ except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.p
|
|||||||
resume_watch,
|
resume_watch,
|
||||||
run_due_resident_watch,
|
run_due_resident_watch,
|
||||||
update_watch,
|
update_watch,
|
||||||
|
validate_target_url,
|
||||||
)
|
)
|
||||||
from cloud.server import init_db
|
from cloud.server import init_db
|
||||||
from cloud.worker import Worker
|
from cloud.worker import Worker
|
||||||
@@ -314,6 +322,172 @@ class ResidentTestCase(unittest.TestCase):
|
|||||||
self.assertTrue(all(p["status"] == "changed" for p in polls))
|
self.assertTrue(all(p["status"] == "changed" for p in polls))
|
||||||
self.assertEqual(len(self._tasks()), 2)
|
self.assertEqual(len(self._tasks()), 2)
|
||||||
|
|
||||||
|
def test_url_validation_blocks_localhost_and_numeric_forms(self):
|
||||||
|
for bad in ("http://localhost./x", "http://foo.localhost/x",
|
||||||
|
"http://2130706433/x", "http://0x7f000001/x",
|
||||||
|
"http://0177.0.0.1/x", "http://0x7f.0.0.1/x"):
|
||||||
|
with self.assertRaises(ValueError, msg=bad):
|
||||||
|
validate_target_url(bad)
|
||||||
|
# 规范点分十进制公网 IP 与带尾点的普通域名仍放行。
|
||||||
|
self.assertEqual(validate_target_url("https://8.8.8.8/x"),
|
||||||
|
"https://8.8.8.8/x")
|
||||||
|
self.assertEqual(validate_target_url("https://example.com./x"),
|
||||||
|
"https://example.com./x")
|
||||||
|
|
||||||
|
def test_unique_index_guards_concurrent_name_clash(self):
|
||||||
|
idx = self.conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'index'"
|
||||||
|
" AND name = 'idx_resident_watches_name'").fetchone()
|
||||||
|
self.assertIsNotNone(idx)
|
||||||
|
self._watch()
|
||||||
|
# 正常路径仍先报 ValueError。
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self._watch()
|
||||||
|
# 绕过 SELECT 查重直接插:唯一索引本身必须拦住,
|
||||||
|
# 这才是并发下真正的关口。
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO resident_watches(id, project_id, name,"
|
||||||
|
" target_url, prompt, interval_seconds, status,"
|
||||||
|
" created_by, created_at, updated_at)"
|
||||||
|
" VALUES('dup', 'proj', 'watch1', ?, 'p', 60,"
|
||||||
|
" 'running', 'owner1', ?, ?)",
|
||||||
|
(URL, self.now, self.now))
|
||||||
|
other = self._watch(name="watch2")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
update_watch(self.conn, other["id"], {"name": "watch1"},
|
||||||
|
now=self.now)
|
||||||
|
|
||||||
|
def test_quota_check_failed_is_transient_not_paused(self):
|
||||||
|
fetcher = _Fetcher("v1")
|
||||||
|
watch = self._watch()
|
||||||
|
run_due_resident_watch(self.conn, watch["id"],
|
||||||
|
fetcher=fetcher, now=self.now)
|
||||||
|
cursor_hash = get_watch(self.conn, watch["id"],
|
||||||
|
now=self.now)["last_content_hash"]
|
||||||
|
original = resident_module._automation_quota_exceeded
|
||||||
|
resident_module._automation_quota_exceeded = lambda conn, owner: (
|
||||||
|
"quota_check_failed: boom")
|
||||||
|
try:
|
||||||
|
fetcher.value = "v2"
|
||||||
|
self.now += 61
|
||||||
|
poll = run_due_resident_watch(
|
||||||
|
self.conn, watch["id"], fetcher=fetcher, now=self.now)
|
||||||
|
finally:
|
||||||
|
resident_module._automation_quota_exceeded = original
|
||||||
|
self.assertEqual(poll["status"], "changed_quota_check_failed")
|
||||||
|
after = get_watch(self.conn, watch["id"], now=self.now)
|
||||||
|
self.assertEqual(after["status"], "running")
|
||||||
|
self.assertEqual(after["last_content_hash"], cursor_hash)
|
||||||
|
self.assertEqual(after["consecutive_failures"], 1)
|
||||||
|
self.assertEqual(after["next_poll_at"], self.now + 60 + 30)
|
||||||
|
self.assertEqual(len(self._tasks()), 0)
|
||||||
|
|
||||||
|
def test_pause_during_fetch_aborts_without_task(self):
|
||||||
|
fetcher = _Fetcher("v1")
|
||||||
|
watch = self._watch()
|
||||||
|
run_due_resident_watch(self.conn, watch["id"],
|
||||||
|
fetcher=fetcher, now=self.now)
|
||||||
|
cursor_hash = get_watch(self.conn, watch["id"],
|
||||||
|
now=self.now)["last_content_hash"]
|
||||||
|
db_path = self.db_path
|
||||||
|
|
||||||
|
def pausing_fetcher(url, timeout):
|
||||||
|
conn2 = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn2.row_factory = sqlite3.Row
|
||||||
|
pause_watch(conn2, watch["id"], now=self.now + 61)
|
||||||
|
finally:
|
||||||
|
conn2.close()
|
||||||
|
return "v2"
|
||||||
|
|
||||||
|
self.now += 61
|
||||||
|
poll = run_due_resident_watch(
|
||||||
|
self.conn, watch["id"], fetcher=pausing_fetcher, now=self.now)
|
||||||
|
self.assertIsNone(poll)
|
||||||
|
after = get_watch(self.conn, watch["id"], now=self.now)
|
||||||
|
self.assertEqual(after["status"], "paused")
|
||||||
|
self.assertIsNone(after["next_poll_at"])
|
||||||
|
self.assertEqual(after["last_content_hash"], cursor_hash)
|
||||||
|
self.assertEqual(len(self._tasks()), 0)
|
||||||
|
self.assertEqual(
|
||||||
|
[p["status"] for p in list_polls(self.conn, watch["id"])],
|
||||||
|
["baseline"])
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectFetchTestCase(unittest.TestCase):
|
||||||
|
"""重定向校验:只把 redirect-test.test 指到本地,其它主机
|
||||||
|
走原始解析;no_proxy 同款处理,tearDown 全部还原。"""
|
||||||
|
|
||||||
|
HOST = "redirect-test.test"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/go":
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", "/final")
|
||||||
|
elif self.path == "/bad":
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header(
|
||||||
|
"Location", "http://127.0.0.1:1/secret")
|
||||||
|
else:
|
||||||
|
body = b"final-body"
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.server = http.server.ThreadingHTTPServer(
|
||||||
|
("127.0.0.1", 0), Handler)
|
||||||
|
self.port = self.server.server_address[1]
|
||||||
|
self.thread = threading.Thread(
|
||||||
|
target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self._orig_getaddrinfo = socket.getaddrinfo
|
||||||
|
|
||||||
|
def patched(host, port, family=0, type=0, proto=0, flags=0):
|
||||||
|
if host == self.HOST:
|
||||||
|
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "",
|
||||||
|
("127.0.0.1", port))]
|
||||||
|
return self._orig_getaddrinfo(
|
||||||
|
host, port, family, type, proto, flags)
|
||||||
|
|
||||||
|
socket.getaddrinfo = patched
|
||||||
|
self._old_no_proxy = os.environ.get("no_proxy")
|
||||||
|
self._old_NO_PROXY = os.environ.get("NO_PROXY")
|
||||||
|
os.environ["no_proxy"] = (
|
||||||
|
(self._old_no_proxy + ",") if self._old_no_proxy
|
||||||
|
else "") + self.HOST
|
||||||
|
os.environ["NO_PROXY"] = (
|
||||||
|
(self._old_NO_PROXY + ",") if self._old_NO_PROXY
|
||||||
|
else "") + self.HOST
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
socket.getaddrinfo = self._orig_getaddrinfo
|
||||||
|
if self._old_no_proxy is None:
|
||||||
|
os.environ.pop("no_proxy", None)
|
||||||
|
else:
|
||||||
|
os.environ["no_proxy"] = self._old_no_proxy
|
||||||
|
if self._old_NO_PROXY is None:
|
||||||
|
os.environ.pop("NO_PROXY", None)
|
||||||
|
else:
|
||||||
|
os.environ["NO_PROXY"] = self._old_NO_PROXY
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join(timeout=5)
|
||||||
|
|
||||||
|
def test_relative_redirect_followed_and_private_blocked(self):
|
||||||
|
base = "http://%s:%d" % (self.HOST, self.port)
|
||||||
|
self.assertEqual(default_fetch(base + "/go", 10), "final-body")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
default_fetch(base + "/bad", 10)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -114,12 +114,17 @@ class WebsiteLoopTestCase(unittest.TestCase):
|
|||||||
returncode=0, stdout="{}", stderr=""))
|
returncode=0, stdout="{}", stderr=""))
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
# setUp 中途失败时属性可能未建:getattr 防御,避免
|
||||||
|
# tearDown 的 AttributeError 掩盖原始失败。
|
||||||
|
if getattr(self, "_orig_getaddrinfo", None) is not None:
|
||||||
socket.getaddrinfo = self._orig_getaddrinfo
|
socket.getaddrinfo = self._orig_getaddrinfo
|
||||||
if self._old_no_proxy is None:
|
if getattr(self, "_old_no_proxy", None) is None:
|
||||||
|
if "_old_no_proxy" in self.__dict__:
|
||||||
os.environ.pop("no_proxy", None)
|
os.environ.pop("no_proxy", None)
|
||||||
else:
|
else:
|
||||||
os.environ["no_proxy"] = self._old_no_proxy
|
os.environ["no_proxy"] = self._old_no_proxy
|
||||||
if self._old_NO_PROXY is None:
|
if getattr(self, "_old_NO_PROXY", None) is None:
|
||||||
|
if "_old_NO_PROXY" in self.__dict__:
|
||||||
os.environ.pop("NO_PROXY", None)
|
os.environ.pop("NO_PROXY", None)
|
||||||
else:
|
else:
|
||||||
os.environ["NO_PROXY"] = self._old_NO_PROXY
|
os.environ["NO_PROXY"] = self._old_NO_PROXY
|
||||||
@@ -128,15 +133,27 @@ class WebsiteLoopTestCase(unittest.TestCase):
|
|||||||
self.content_server.server_close()
|
self.content_server.server_close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
self.content_thread.join(timeout=5)
|
self.content_thread.join(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
self.server.shutdown()
|
self.server.shutdown()
|
||||||
self.server.server_close()
|
self.server.server_close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
self.thread.join(timeout=5)
|
self.thread.join(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
self.auth.conn.close()
|
self.auth.conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _req(self, method, path, body=None, key=None):
|
def _req(self, method, path, body=None, key=None):
|
||||||
conn = http.client.HTTPConnection("127.0.0.1", self.port,
|
conn = http.client.HTTPConnection("127.0.0.1", self.port,
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# 常驻监听网站闭环:独立架构与代码质量审核报告
|
||||||
|
|
||||||
|
审核员非实现者。审核对象:网站闭环 worktree
|
||||||
|
`~/workspace/codex-app-server-resident-website-loop`,分支
|
||||||
|
`feat-resident-website-loop`,头 `23725f9`(父 `561b389`,基底
|
||||||
|
网站版 `0722ca3`,`origin/main 622c51e`)。本审核在独立 worktree
|
||||||
|
`~/workspace/codex-app-server-resident-audit`(新分支
|
||||||
|
`feat-resident-website-loop-audit`,从 `23725f9` 起)上做,闭环
|
||||||
|
worktree、resident-task worktree、泛化 worktree、主 checkout
|
||||||
|
(feat-repo-picker)一律未动。
|
||||||
|
|
||||||
|
## 0. 评分标准(先定标准,再打分)
|
||||||
|
|
||||||
|
只评架构与代码质量两项,满分各 10 分。控制台 UI、真实外网、
|
||||||
|
生产 PG、双进程抢单实测、其它探针是其它维度/后续项,本轮不做,
|
||||||
|
也不用它们冒充或无故扣这两项的分;但若某项确实卡住架构或代码
|
||||||
|
质量本身,列为 blocker,不擅自定。
|
||||||
|
|
||||||
|
### 架构 10 分标准
|
||||||
|
|
||||||
|
- A1 分层干净:resident 模块自有状态机,与任务状态机、自动化
|
||||||
|
定义层互不干扰;server 只做路由/权限/审计,worker 只做驱动
|
||||||
|
边界调用;无循环依赖。
|
||||||
|
- A2 状态机可推理:running/paused/ended、暂停保现场、恢复续跑、
|
||||||
|
结束终态 409、失败退避、配额暂停不结束的语义在所有路径一致,
|
||||||
|
且并发下不被竞态破坏(认领、改名、暂停/结束中途轮询)。
|
||||||
|
- A3 驱动与认领正确:服务端不起定时器是明说的,实际驱动在
|
||||||
|
worker 轮次边界;claim 条件 UPDATE 是防重复的唯一关口;崩后
|
||||||
|
不锁死。
|
||||||
|
- A4 快照/变化语义干净:轮询廉价、仅变化建任务、首次只建基线、
|
||||||
|
每次轮询可查;变化摘录与游标推进在同一事务边界内。
|
||||||
|
- A5 安全边界在正确层:URL 校验、超时、1MB 截断、SSRF 防护不
|
||||||
|
只在创建时校验一次,抓取路径(包括重定向)也要守住;v1 的
|
||||||
|
DNS 解析后校验缺口必须明说,不得假装已解决。
|
||||||
|
- A6 可扩展结构:网站是本轮唯一用例,但快照/抓取与状态机/认领
|
||||||
|
不应缠成一团;未来加探针时状态机与驱动骨架不应重写(本轮
|
||||||
|
不实现其它探针,只审结构)。
|
||||||
|
|
||||||
|
### 代码质量 10 分标准
|
||||||
|
|
||||||
|
- Q1 命名、函数长度与复杂度可读;长函数(run_due)分支清晰,
|
||||||
|
不靠注释掩盖逻辑。
|
||||||
|
- Q2 无重复、无死代码、无测试值渗入生产代码;复用已有口径
|
||||||
|
(自动化 claim/配额/审计、环境名唯一索引先例)而不是另起一套。
|
||||||
|
- Q3 错误处理诚实:失败、配额耗尽、配额检查失败三条路不混淆;
|
||||||
|
审计与轮询记录不静默丢失;异常不炸驱动循环。
|
||||||
|
- Q4 测试可读、可隔离、可复现:本地 HTTP/DNS/no_proxy 等测试
|
||||||
|
手段干净还原、无环境泄漏;断言指向行为而非实现细节摆设。
|
||||||
|
- Q5 静态检查:居民差分内 ruff E、F 干净(仓库惯用 `%` 格式、
|
||||||
|
宽泛 except 等不在仓库门禁内的规则不计入;基底已有的 E、F
|
||||||
|
不计入本轮)。
|
||||||
|
|
||||||
|
## 1. 改动前分数
|
||||||
|
|
||||||
|
- 架构:9.0 / 10
|
||||||
|
- 代码质量:9.0 / 10
|
||||||
|
|
||||||
|
基底是好的:分层、状态机、claim、配额复用、轮询可查、测试
|
||||||
|
隔离(DNS/no_proxy 在 loop 测试里干净还原)都属实,定向 15 项
|
||||||
|
基线全过(见第 4 节)。扣分只来自下面逐项 findings,不是整体
|
||||||
|
推倒重来。
|
||||||
|
|
||||||
|
## 2. Findings 清单(改动前)
|
||||||
|
|
||||||
|
### 严重
|
||||||
|
|
||||||
|
- F1 名称唯一性是先查后插、无数据库约束:create_watch /
|
||||||
|
update_watch 先 SELECT 查重再 INSERT/UPDATE,两个并发请求
|
||||||
|
可同时通过检查、插出同项目同名 running 监听;server 的
|
||||||
|
environments 已有先例:`CREATE UNIQUE INDEX … WHERE status
|
||||||
|
!= 'archived'`。本轮用同款部分唯一索引
|
||||||
|
`WHERE status != 'ended'`(已结束名可重用)+ 完整性错误转
|
||||||
|
ValueError 修复。
|
||||||
|
- F2 暂停/结束与进行中轮询竞态:run_due 认领后只读一次状态,
|
||||||
|
之后若用户暂停/结束,失败/基线/未变化/配额/变化分支的 UPDATE
|
||||||
|
均不带状态条件,会把 paused/ended 行的 next_poll_at 改回
|
||||||
|
未来值、并可能在用户已结束之后仍建出变化任务。修复:认领后
|
||||||
|
复查状态;全部终局 UPDATE 带 `status='running'` 条件,条件
|
||||||
|
不满足时回滚、不建任务、不记轮询。
|
||||||
|
|
||||||
|
### 一般
|
||||||
|
|
||||||
|
- F3 抓取路径 SSRF 只守了创建时:default_fetch 用默认 opener,
|
||||||
|
重定向目标不校验,公开 URL 重定向到内网地址可绕过创建校验;
|
||||||
|
另 localhost.、单整数/十六进制/前导零等非规范 IP 写法可绕过
|
||||||
|
字面 IP 检查。修复:重定向处理器逐跳校验(相对跳转先 urljoin
|
||||||
|
再校验);校验时归一化主机(去尾点、*.localhost 挡掉)并拒绝
|
||||||
|
非规范数字 IP 写法。DNS 解析后校验仍属 v1 缺口,继续列为已知
|
||||||
|
偏差,不假装已解决。
|
||||||
|
- F4 配额门判定靠子串:`\"quota_check_failed\" not in gate` 把
|
||||||
|
配额门返回文本当协议用,超限文本一旦含该子串即误判。配额门
|
||||||
|
的真实契约是前缀(`quota_check_failed:` / `quota_exceeded:` /
|
||||||
|
None),修复为 `startswith` 判定,并补 changed_quota_check_failed
|
||||||
|
的定向测试(该分支此前无测试)。
|
||||||
|
- F5 row_get 重复造轮:resident 自带与 automations.row_get 逐字
|
||||||
|
同语义的 row_get,却已从 automations 导入另外两个函数。修复:
|
||||||
|
直接导入 automations.row_get,删本地定义。
|
||||||
|
- F6 server 审计动作名嵌套三元:`_resident_transition` 里
|
||||||
|
`\"ended\" if … else action + \"d\" if … else \"resumed\"` 可读性差,
|
||||||
|
且改动时易错。修复为显式动作映射字典,审计字符串不变。
|
||||||
|
|
||||||
|
### 建议
|
||||||
|
|
||||||
|
- S1 test_resident.py 导入了未用的 `time`(ruff F401,居民差分
|
||||||
|
内唯一 E、F 问题)。删除。
|
||||||
|
- S2 退避文档与代码不一致:`_backoff_seconds` 文档写“30、60、
|
||||||
|
120……秒”,代码实际下次轮询是 `now + interval + backoff`。
|
||||||
|
行为本身合理(不比间隔更频繁),文档误导。修复文档,行为不动。
|
||||||
|
- S3 loop 测试 tearDown 不够防御:setUp 中途失败时 tearDown
|
||||||
|
访问未建属性会掩盖原始失败。修复为 getattr 防御。
|
||||||
|
|
||||||
|
### 已核查、不是问题(不扣分)
|
||||||
|
|
||||||
|
- 审计行“写在 commit 之后会丢”:核查 `_auto_audit_row` 本身
|
||||||
|
执行 INSERT 后自己 commit,失败/配额暂停/变化三处审计行在
|
||||||
|
模拟关闭连接后仍可查,不是缺陷。
|
||||||
|
- server/worker 全量 ruff E、F 的其余项(如 worker.py:2781/2787
|
||||||
|
的 F821、server.py:10293 的 E501)经核对均为基底已有或由居民
|
||||||
|
插入导致的行号位移,非居民新增代码,不计入本轮。
|
||||||
|
- 仓库默认 ruff 全规则的 39 项(如 UP031 `%` 格式)与仓库惯用
|
||||||
|
写法冲突,不在仓库门禁内,不计入。
|
||||||
|
|
||||||
|
## 3. 修复(全部关闭,每项有对应测试或定向复核)
|
||||||
|
|
||||||
|
- F1 名称唯一性:`ensure_resident_tables` 新增部分唯一索引
|
||||||
|
`idx_resident_watches_name ON resident_watches(project_id, name)
|
||||||
|
WHERE status != 'ended'`(server environments 同款口径);
|
||||||
|
create/update 的 INSERT/UPDATE 包完整性错误转 ValueError
|
||||||
|
(sqlite3.IntegrityError / PostgresIntegrityError,调用方仍
|
||||||
|
返回 400)。测试:索引存在断言、绕过查重直接插报
|
||||||
|
IntegrityError、改名撞名报 ValueError。
|
||||||
|
- F2 暂停/结束中途轮询:认领后复查状态(非 running 直接返回
|
||||||
|
None,不改行);新增 `_guarded_watch_update`,失败/基线/
|
||||||
|
未变化/配额检查失败/变化推进游标的终局 UPDATE 全部带
|
||||||
|
`status='running'` 条件,条件不满足时回滚、不建任务、不记
|
||||||
|
轮询;变化分支先受保护推进游标、再建任务。测试:抓取回调
|
||||||
|
里经第二连接暂停监听,run_due 返回 None、无任务、游标与
|
||||||
|
next_poll_at 保持暂停态、轮询数不变。
|
||||||
|
- F3 抓取路径 SSRF:新增 `_SafeRedirectHandler`,重定向逐跳
|
||||||
|
urljoin 后复用 `validate_target_url`,不合格抛 ValueError
|
||||||
|
不跟随;`default_fetch` 改走 `build_opener`(默认处理器含
|
||||||
|
ProxyHandler,no_proxy 行为与 urlopen 同款);校验归一化
|
||||||
|
主机(去尾点、挡 *.localhost)并新增 `_is_noncanonical_
|
||||||
|
numeric_host` 拒绝单整数/十六进制/前导零八进制等非规范
|
||||||
|
数字 IP 写法。测试:localhost.、*.localhost、2130706433、
|
||||||
|
0x7f000001、0177.0.0.1、0x7f.0.0.1 均报 ValueError,规范
|
||||||
|
公网 IP 与带尾点域名仍放行;相对重定向跟随、私有绝对重
|
||||||
|
定向报 ValueError(DNS 映射测试主机,同 loop 测试口径)。
|
||||||
|
- F4 配额门判定:`\"quota_check_failed\" not in gate` 改为
|
||||||
|
`gate.startswith(\"quota_check_failed\")`,与配额门前缀契约
|
||||||
|
对齐。测试:注入返回 `quota_check_failed: boom` 的配额门,
|
||||||
|
变化轮询记 changed_quota_check_failed、状态仍 running、
|
||||||
|
游标不动、失败数 1、下次 = now + 间隔 + 30、无任务。
|
||||||
|
- F5 row_get 重复:删 resident 本地定义,直接导入
|
||||||
|
automations.row_get,内部调用点一字未动,模块依赖说明
|
||||||
|
同步更新。
|
||||||
|
- F6 server 审计动作名:嵌套三元改为显式映射字典
|
||||||
|
{pause: paused, resume: resumed, end: ended},审计字符串
|
||||||
|
不变,服务端测试的审计断言仍过。
|
||||||
|
- S1 删 test_resident.py 未用 `time` 导入;S2 `_backoff_seconds`
|
||||||
|
文档改为“退避增量,下次 = now + 间隔 + backoff”,行为不动;
|
||||||
|
S3 loop 测试 tearDown 改为 getattr 防御,setUp 中途失败不
|
||||||
|
再掩盖原始失败。
|
||||||
|
|
||||||
|
## 4. 测试数字(全部本轮实测,无删断言、无 skip、无改默认值)
|
||||||
|
|
||||||
|
- 定向基线(改动前):`cloud.tests.test_resident` +
|
||||||
|
`test_resident_server` + `test_resident_website_loop` = 15 项全过。
|
||||||
|
- 定向改动后:同三文件 = 20 项全过(基线 15 + 新增 5:URL
|
||||||
|
变体、唯一索引、配额检查失败、中途暂停、重定向),4.450 秒;
|
||||||
|
ruff E、F 在居民差分 4 文件上全部通过。
|
||||||
|
- 全量(CI 同款三条 discover,改动后、报告提交前跑出,之后
|
||||||
|
无任何代码或文案改动):顶层 tests 105 项全过;cloud/tests
|
||||||
|
1551 项全过、9 跳过(384.252 秒);deploy/tests 44 项全过
|
||||||
|
(单独重跑确认);合计 1700 项、0 失败、9 跳过。对账:闭环
|
||||||
|
基线 1695(105 + 1546 + 44)+ 新增 5 = 1700,无测试丢失、
|
||||||
|
无新失败。
|
||||||
|
- 覆盖率:本仓库 CI 无覆盖率门禁,不虚构覆盖率变化;新增
|
||||||
|
5 项定向测试即上述全量的一部分。
|
||||||
|
|
||||||
|
## 5. 复评分数
|
||||||
|
|
||||||
|
- 架构:10 / 10
|
||||||
|
- 代码质量:10 / 10
|
||||||
|
|
||||||
|
复评依据:F1/F2 两项严重 findings 已由数据库约束与状态条件
|
||||||
|
UPDATE 从机制上关闭(非仅文档说明),并有定向测试锁定;
|
||||||
|
F3 安全边界已从“创建时一次校验”补到“抓取重定向逐跳校验”
|
||||||
|
与非规范 IP 写法;F4/F5/F6/S1/S2/S3 全部关闭且测试全过。
|
||||||
|
剩余的已知偏差(控制台 UI、全文 hash 抖动、DNS 解析后 SSRF、
|
||||||
|
真实外网/生产 PG/双进程抢单未验)属其它维度/后续项,已在
|
||||||
|
第 6 节单独列出,不计入本轮两项分数,也不被用来冒充已完成。
|
||||||
|
|
||||||
|
## 6. 剩余 blocker 与未做事项
|
||||||
|
|
||||||
|
- 未做:未 push、未合并、未跑 deploy.py、未部署;不做其它探针、
|
||||||
|
不接控制台 UI、不做生产 PG 与双进程抢单实测。
|
||||||
|
- 已知偏差(其它维度,不计入本轮两项分数):控制台 UI 未接;
|
||||||
|
变化判定为全文 hash、无选择器提取,动态页面抖动可误报;
|
||||||
|
SSRF v1 不做 DNS 解析后校验;真实外网、生产 PG、双进程抢单
|
||||||
|
未验。
|
||||||
Reference in new issue
Block a user