feat: add bounded external form inspection
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Failed
Pipelines as Code CI / unidesk-host- Success

This commit is contained in:
pikastech
2026-07-19 13:16:55 +02:00
parent dc2494bff2
commit 7fe743351d
5 changed files with 51 additions and 2 deletions
@@ -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"]
@@ -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 的浏览器资源回收合同。
@@ -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 能被声明式外部表单读取,并能在注入后通过受控命令清除。
@@ -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,
+2 -2
View File
@@ -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<string, unknown>, 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`),