From 7fe743351dbf54239b79aa5642e2121178c944d3 Mon Sep 17 00:00:00 2001 From: pikastech Date: Sun, 19 Jul 2026 13:16:55 +0200 Subject: [PATCH] feat: add bounded external form inspection --- config/web-probe-external-form/fixture.yaml | 2 + .../PJ2026-01060508-web-probe-sentinel.md | 5 +++ .../specs/PJ2026-04-findjob-workbench.md | 5 +++ .../web-probe-external-form-runner-source.ts | 37 +++++++++++++++++++ scripts/src/web-probe-external-form.ts | 4 +- 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/config/web-probe-external-form/fixture.yaml b/config/web-probe-external-form/fixture.yaml index 2cecc105..733d2546 100644 --- a/config/web-probe-external-form/fixture.yaml +++ b/config/web-probe-external-form/fixture.yaml @@ -46,6 +46,8 @@ spec: type: upload selector: input[name="resume"] path: config/web-probe-external-form/fixture.html + - id: inspect-controls + type: inspect - id: captcha type: pause selector: input[name="custtel"] diff --git a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md index c663af98..62dcc489 100644 --- a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md +++ b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md @@ -809,6 +809,10 @@ CI/CD rollout 门禁仍只验证配置声明的 `/health` endpoint。web-probe q - `stop` 只终止该 workflow 的精确进程及浏览器后代。 - 动作合同: - 通用动作至少覆盖导航、文本填写、选择、勾选、点击、文件上传、等待、截图、暂停和保存草稿; + - 工作流可以声明有界只读控件检查动作,用于获取最多 40 个控件的标签、ID、名称、类型、占位符、 + 截短可见文字、类名和内联点击属性; + - 控件检查默认 selector 为 `input, button, a, select, textarea`,不得读取或输出 `value`、Cookie、 + 页面存储、输入控件文字或其他表单值; - selector、目标值引用和完成条件必须显式声明; - 本地上传文件必须按 YAML 大小上限进入 workflow 私有目录,并在 runner 终态删除; - 普通 `click` 不得操作 submit 控件,提交控件必须使用显式 `submit` 动作; @@ -819,6 +823,7 @@ CI/CD rollout 门禁仍只验证配置声明的 `/health` endpoint。web-probe q - 默认输出只披露字段标识、presence、fingerprint、完成度和 `valuesRedacted=true`。 - 证据和恢复: - 状态必须有界披露 phase、当前动作、最终 URL、DOM/网络/console/pageerror 摘要、截图引用和字段完成度; + - 控件检查结果最多保留 40 条,单项文字和属性必须限长并经过现有 Secret 脱敏; - artifact、状态和 continuation 文件必须使用受限权限; - timeout、失败、人工停止和正常结束都必须复用 OPS-SENTINEL-REQ-018 的浏览器资源回收合同。 diff --git a/project-management/PJ2026-04/specs/PJ2026-04-findjob-workbench.md b/project-management/PJ2026-04/specs/PJ2026-04-findjob-workbench.md index b9acee75..a4ebc156 100644 --- a/project-management/PJ2026-04/specs/PJ2026-04-findjob-workbench.md +++ b/project-management/PJ2026-04/specs/PJ2026-04-findjob-workbench.md @@ -122,6 +122,10 @@ native 开发应同时保持前端 HMR 和 API 文件监听热重载。服务端 - 前端不得把全部密码预加载到页面状态。 - API、CLI、日志、TaskTree、Git 和错误响应不得打印密码明文。 - 更新密码时应覆盖该单位的当前明文记录,不保留可由普通接口读取的历史密码。 +- 外部招聘表单需要密码时,应按单位把当前账号和密码物化到 `.state/` 下权限为 + `0600` 的临时 Secret 文件;表单完成 Secret 注入后应立即清除该文件。 +- 临时 Secret 物化和清除命令只返回单位 ID、文件 presence 与路径,不返回账号或 + 密码值;PostgreSQL 仍为招聘密码的唯一长期事实源。 ## 7. 验收契约 @@ -134,3 +138,4 @@ native 开发应同时保持前端 HMR 和 API 文件监听热重载。服务端 - 正确登录后能为单个单位生成、查看和复制招聘密码。 - 未登录请求、单位列表请求和密码库摘要请求均不能取得密码明文。 - Git diff、CLI、日志和 TaskTree 中不出现招聘密码明文。 +- 按单位物化的临时 Secret 能被声明式外部表单读取,并能在注入后通过受控命令清除。 diff --git a/scripts/src/web-probe-external-form-runner-source.ts b/scripts/src/web-probe-external-form-runner-source.ts index 91b44b9a..15b8dab0 100644 --- a/scripts/src/web-probe-external-form-runner-source.ts +++ b/scripts/src/web-probe-external-form-runner-source.ts @@ -23,6 +23,7 @@ const secretValues = Object.values(secrets).filter((value) => typeof value === " const completed = []; const network = { responses: 0, failed: 0, status4xx: 0, status5xx: 0 }; const browserSignals = { consoleErrors: 0, pageErrors: 0, lastPageErrorHash: null }; +let inspection = null; let browser; let context; let page; @@ -152,6 +153,11 @@ async function executeAction(action) { await captureScreenshot(action.id); return; } + if (action.type === "inspect") { + inspection = await inspectControls(action); + await writeStatus("running"); + return; + } if (action.type === "pause") { const screenshot = await captureScreenshot("pause-" + action.id); await writeStatus("paused", { pause: { id: action.id, reason: action.reason, screenshot } }); @@ -202,6 +208,36 @@ async function captureScreenshot(name) { return { path: file, bytes: bytes.byteLength, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex"), valuesRedacted: true }; } +async function inspectControls(action) { + const selector = action.selector || "input, button, a, select, textarea"; + const controls = await page.locator(selector).evaluateAll((elements) => elements.slice(0, 40).map((element) => { + const tag = element.tagName.toLowerCase(); + const visibleText = tag === "button" || tag === "a" + ? String(element.innerText || "") + : ""; + return { + tag, + id: element.getAttribute("id") || "", + name: element.getAttribute("name") || "", + type: element.getAttribute("type") || "", + placeholder: element.getAttribute("placeholder") || "", + text: visibleText, + className: element.getAttribute("class") || "", + onclick: element.getAttribute("onclick") || "", + }; + })); + return { + actionId: action.id, + selector, + count: controls.length, + truncated: await page.locator(selector).count() > controls.length, + controls: controls.map((control) => Object.fromEntries( + Object.entries(control).map(([key, value]) => [key, redact(String(value)).replace(/\s+/g, " ").trim().slice(0, 160)]) + )), + valuesRedacted: true, + }; +} + async function writeStatus(phase, extra = {}) { const finalUrl = page && !page.isClosed() ? safeUrl(page.url()) : null; const payload = { @@ -217,6 +253,7 @@ async function writeStatus(phase, extra = {}) { finalUrl, network, browserSignals, + inspection, pause: null, valuesRedacted: true, ...extra, diff --git a/scripts/src/web-probe-external-form.ts b/scripts/src/web-probe-external-form.ts index 2c2d4cc2..8822e058 100644 --- a/scripts/src/web-probe-external-form.ts +++ b/scripts/src/web-probe-external-form.ts @@ -15,7 +15,7 @@ import { webProbeExternalFormRunnerSource } from "./web-probe-external-form-runn type ExternalFormAction = { readonly id: string; - readonly type: "goto" | "fill" | "select" | "check" | "upload" | "click" | "submit" | "waitFor" | "screenshot" | "pause"; + readonly type: "goto" | "fill" | "select" | "check" | "upload" | "click" | "submit" | "waitFor" | "screenshot" | "inspect" | "pause"; readonly selector?: string; readonly url?: string; readonly value?: string; @@ -420,7 +420,7 @@ function stop(workflow: ExternalFormWorkflow, workspace: string, workflowId: str function parseAction(value: Record, index: number): ExternalFormAction { const label = `spec.actions[${index}]`; - const type = optionalEnum(value.type, ["goto", "fill", "select", "check", "upload", "click", "submit", "waitFor", "screenshot", "pause"], `${label}.type`); + const type = optionalEnum(value.type, ["goto", "fill", "select", "check", "upload", "click", "submit", "waitFor", "screenshot", "inspect", "pause"], `${label}.type`); if (type === null) throw new Error(`${label}.type 缺失`); const action: ExternalFormAction = { id: simpleId(text(value.id, `${label}.id`), `${label}.id`),