diff --git a/.agents/ROLE.md b/.agents/ROLE.md index 5ca310ac..10c31a00 100644 --- a/.agents/ROLE.md +++ b/.agents/ROLE.md @@ -31,7 +31,9 @@ Still run Phase 0 bootstrap (`target_id`, dirs, scope/auth, manifest). RoE Q&A m ### Step 6 — Danger operations Changes on target / destructive exploits / unbounded scans → pause for approval unless user declared **bypass**. -MCP destructive tools → confirm first. +MCP destructive tools → confirm first. +Pre-flight offensive commands through the runtime guard (enforces scope / window / banned + writes `activity-log.md`): +`node .agents/schema/guard.js ${ID} --cmd ""` — exit 1 means out of scope/window; do not run it. ## 3. Environment @@ -52,7 +54,7 @@ Follow `.agents/schema/env.md` exclusively: ## 5. Knowledge & MCP (pointers only) - Knowledge: `.agents/knowledge/LOOKUP.md` (tier-1 then tier-2) -- Bulk import: `knowledge-import` skill (not during phases) +- Bulk import: `pentester-knowledge-import` skill (not during phases) - MCP: only if `.agents/mcp/INDEX.md` has tools ## 6. Orchestration (pointers only) diff --git a/.agents/bin/lookup.js b/.agents/bin/lookup.js index 3cf30252..8ca70190 100644 --- a/.agents/bin/lookup.js +++ b/.agents/bin/lookup.js @@ -270,6 +270,12 @@ function runSelftest() { r3.results.some((x) => /sql-injection/i.test(x.path)), `expected sql-injection in top paths, got ${r3.results.map((x) => x.path).join(', ')}` ); + // phase 3 + cloud keyword → cloud posture topic (s3 alias-expands to cloud) + const rc = lookup('3', ['s3'], 8); + assert.ok( + rc.results.some((x) => /cloud/i.test(x.path)), + `expected cloud topic for s3, got ${rc.results.map((x) => x.path).join(', ')}` + ); // phase 1 + nmap const r1 = lookup('1', ['nmap'], 5); assert.ok( diff --git a/.agents/knowledge/LOOKUP.md b/.agents/knowledge/LOOKUP.md index daabf3ca..78c9bff5 100644 --- a/.agents/knowledge/LOOKUP.md +++ b/.agents/knowledge/LOOKUP.md @@ -9,9 +9,9 @@ node .agents/bin/lookup.js --check-index Executable interface: `.agents/bin/lookup.js`. **Hard bags**: `--phase N` only ranks links in the matching `PTES-INDEX.md` `##` section. Shared signal aliases (with playbook): `signal-aliases.json`. -Phase **1b** bag may be empty — use `src-hunter/playbook.js`. +Phase **1b** bag may be empty — use `pentester-src-hunter/playbook.js`. This file is the **policy**; the CLI is the **implementation**. -Skill `knowledge-base` is a thin **adapter** (when to call / how to write tier-1) — not a second procedure. +Skill `pentester-knowledge-base` is a thin **adapter** (when to call / how to write tier-1) — not a second procedure. **Do not** browse `INDEX.md` or `DIRECTORY.md` as the phase knowledge interface. Tier-2 roots live in `TIER2-CATALOG.md` (miss only). @@ -31,7 +31,7 @@ lookup(phase, keywords) → ordered tier-1 paths | `PTES-INDEX.md` | Phase → curated paths only | | `recon/` `vuln-analysis/` `exploitation/` `post-exploitation/` `tooling/` `reporting/` `defensive/` | Structured entries | | `vuln-analysis/topics/` | Vuln-type topics | -| `.agents/skills/src-hunter/references/playbooks/` | Web attack playbooks (via phase bag / skill, not PTES-INDEX bulk) | +| `.agents/skills/pentester-src-hunter/references/playbooks/` | Web attack playbooks (via phase bag / skill, not PTES-INDEX bulk) | | Essence files linked from INDEX top | Prefer first | **Never** put `github-references/`, `refined-corpus/`, `deep-refined-corpus/` in phase knowledge bags or PTES-INDEX. @@ -50,11 +50,11 @@ lookup(phase, keywords) → ordered tier-1 paths 2. Open 3–10 returned tier-1 files 3. Extract commands/judgment 4. Miss → tier-2 search or `web_search` -5. New reusable method → `knowledge-base` skill → tier-1 + PTES-INDEX +5. New reusable method → `pentester-knowledge-base` skill → tier-1 + PTES-INDEX ## Skills | Skill | Role | |-------|------| -| `knowledge-base` | Runtime: call LOOKUP, write tier-1, index hygiene | -| `knowledge-import` | Offline GitHub / bulk import only | +| `pentester-knowledge-base` | Runtime: call LOOKUP, write tier-1, index hygiene | +| `pentester-knowledge-import` | Offline GitHub / bulk import only | diff --git a/.agents/knowledge/PTES-INDEX.md b/.agents/knowledge/PTES-INDEX.md index 4cc9a786..849f1d2b 100644 --- a/.agents/knowledge/PTES-INDEX.md +++ b/.agents/knowledge/PTES-INDEX.md @@ -2,7 +2,7 @@ > 仅列 **策展路径**。归档(`deep-refined-corpus/`、`github-references/`、`refined-corpus/`)**不得**出现在本文件。 > miss → `.agents/knowledge/LOOKUP.md` tier-2。 -> 维护:`knowledge-base` skill 追加时只写 tier-1 类别。 +> 维护:`pentester-knowledge-base` skill 追加时只写 tier-1 类别。 ## Pre-engagement @@ -32,6 +32,7 @@ - `vuln-analysis/topics/file-inclusion-topic.md` — 包含 - `vuln-analysis/topics/command-execution-topic.md` — 命令执行 - `vuln-analysis/mssql-attack.md` — MSSQL +- `vuln-analysis/cloud/cloud-posture-essence.md` — 云安全 posture (AWS/Azure/GCP/S3/IAM/K8s) - `exploitation/cve-poc-verification-essence.md` — CVE/PoC 验证流程 ## Exploitation @@ -48,6 +49,7 @@ - `post-exploitation/credential-access/credential-dumping.md` — 凭据 - `post-exploitation/lateral-movement/lateral-movement-cheatsheet.md` — 横向 - `post-exploitation/privilege-escalation/windows-privesc-compendium.md` — Windows 提权 +- `post-exploitation/cloud/cloud-privesc-lateral.md` — 云上提权/横向 (IAM/STS/K8s RBAC) - `post-exploitation/c2/c2-frameworks.md` — C2 ## Reporting diff --git a/.agents/knowledge/README.md b/.agents/knowledge/README.md index 46c1b261..088cbc01 100644 --- a/.agents/knowledge/README.md +++ b/.agents/knowledge/README.md @@ -8,7 +8,7 @@ | `target/${ID}/solutions.md` | 单次 engagement 笔记(可选) | | `(removed) .agents/solutions.md` | **已废弃**,禁止 phase 预加载 | -Tier-2 大归档(`github-references/` 等)宜外置;见 `knowledge-import` + `TIER2-CATALOG.md`。 +Tier-2 大归档(`github-references/` 等)宜外置;见 `pentester-knowledge-import` + `TIER2-CATALOG.md`。 ## 目录结构 @@ -88,4 +88,4 @@ reporting/sql-injection-remediation.md ## 使用方式 -当需要查询、补充、整理或复用知识时,调用 `/pentester:knowledge-base` skill。 +当需要查询、补充、整理或复用知识时,调用 `pentester-knowledge-base` skill。 diff --git a/.agents/knowledge/README_EN.md b/.agents/knowledge/README_EN.md index d2a1f46c..ff453dea 100644 --- a/.agents/knowledge/README_EN.md +++ b/.agents/knowledge/README_EN.md @@ -83,4 +83,4 @@ Use `.agents/knowledge/TEMPLATE.md` as a base when creating new entries. ## Usage -When you need to query, supplement, organize, or reuse knowledge, invoke the `/pentester:knowledge-base` skill. +When you need to query, supplement, organize, or reuse knowledge, invoke the `pentester-knowledge-base` skill. diff --git a/.agents/knowledge/_import-checkpoint.md b/.agents/knowledge/_import-checkpoint.md index c32f2c36..db363a8a 100644 --- a/.agents/knowledge/_import-checkpoint.md +++ b/.agents/knowledge/_import-checkpoint.md @@ -1,9 +1,9 @@ # Import checkpoint -- updated: 2026-07-23T08:56:18.018Z +- updated: 2026-07-23T12:36:57.494Z - archive_root: /Users/fb0sh/Projects/pentester/.agents/knowledge/github-references - external: false - stage: status - note: Archive is in-repo — prefer KNOWLEDGE_ARCHIVE_ROOT external volume (locality) -Next: follow knowledge-import/SKILL.md stages; never preload archives into phase bags. +Next: follow pentester-knowledge-import/SKILL.md stages; never preload archives into phase bags. diff --git a/.agents/knowledge/post-exploitation/cloud/cloud-privesc-lateral.md b/.agents/knowledge/post-exploitation/cloud/cloud-privesc-lateral.md new file mode 100644 index 00000000..3848f1a6 --- /dev/null +++ b/.agents/knowledge/post-exploitation/cloud/cloud-privesc-lateral.md @@ -0,0 +1,36 @@ +# 云上提权 / 横向精华 — IAM / STS / Managed Identity / K8s RBAC + +> Tier-1 方法论精华。手把手命令见 pentester-src-hunter `playbooks/cloud/`。**横向到其它账号/订阅前先过 Scope(`scope-check.js` / `guard.js`)。** + +## 提权原语速查(有其一常能到 admin) + +**AWS** +- `iam:CreateAccessKey`(对高权用户)· `iam:AttachUserPolicy`/`PutUserPolicy`(自附 `AdministratorAccess`) +- `iam:PassRole` + `lambda:CreateFunction` / `ec2:RunInstances` / `glue`/`cloudformation`(用高权角色执行) +- `sts:AssumeRole` 到 trust policy 过宽的角色 + +**Azure** +- `Owner`/`Contributor` → VM `run-command`、部署模板执行 +- Key Vault 访问策略过宽 → 取密钥/证书横向 +- App client secret / Managed Identity 权限过大 → ARM/Graph + +**GCP** +- `iam.serviceAccountKeys.create` · `iam.serviceAccounts.actAs` + 计算/函数创建 +- `iam.roles.update` / `setIamPolicy`(自赋 `roles/owner`)· `getAccessToken`(模拟高权 SA) + +**Kubernetes** +- `pods/exec` / `create pods`(特权 Pod)· `secrets get`(读全量密钥) +- 绑定到 `cluster-admin` 的 SA · 可创建 `privileged`/`hostPath:/` Pod → 逃逸到 node + +## 横向路径 + +- 云凭据 → 云 API → 其它服务(数据库、队列、存储)。 +- Managed Identity / SA token → 元数据服务 → 更多资源。 +- K8s SA token → API server → 其它命名空间 / node → 底层云凭据(node role)。 +- 混合云:云凭据 → 读到 AD/域凭据 → 内网横向(转 `../ad-redteam-essence.md`)。 + +## OPSEC 与合规 + +- 优先只读枚举;改配置/建资源属危险操作,需用户确认(ROLE danger-ops)。 +- 记录所有临时凭据来源与到期;报告脱敏(不落客户密钥)。 +- 云操作常留 CloudTrail/Activity Log 痕迹——按约定说明测试活动,用本框架 `activity-log.md` 自证范围。 diff --git a/.agents/knowledge/signal-aliases.json b/.agents/knowledge/signal-aliases.json index 64697bc8..c97f14e7 100644 --- a/.agents/knowledge/signal-aliases.json +++ b/.agents/knowledge/signal-aliases.json @@ -63,6 +63,17 @@ "id": "nmap", "lookup": ["nmap", "recon", "scan"], "playbook": [] - } + }, + "cloud": { + "id": "cloud", + "lookup": ["cloud", "aws", "azure", "gcp", "iam", "s3", "k8s", "kubernetes", "metadata"], + "playbook": ["cloud"] + }, + "aws": { "id": "cloud", "lookup": ["aws", "s3", "iam", "sts", "cloud"], "playbook": ["cloud"] }, + "azure": { "id": "cloud", "lookup": ["azure", "entra", "blob", "cloud"], "playbook": ["cloud"] }, + "gcp": { "id": "cloud", "lookup": ["gcp", "gcs", "cloud"], "playbook": ["cloud"] }, + "s3": { "id": "cloud", "lookup": ["s3", "bucket", "aws", "cloud"], "playbook": ["cloud"] }, + "iam": { "id": "cloud", "lookup": ["iam", "cloud", "privilege"], "playbook": ["cloud"] }, + "k8s": { "id": "cloud", "lookup": ["k8s", "kubernetes", "rbac", "cloud"], "playbook": ["cloud"] } } } diff --git a/.agents/knowledge/tooling/knowledge-base-maintenance-standard.md b/.agents/knowledge/tooling/knowledge-base-maintenance-standard.md index bc66bc5e..9379bb48 100644 --- a/.agents/knowledge/tooling/knowledge-base-maintenance-standard.md +++ b/.agents/knowledge/tooling/knowledge-base-maintenance-standard.md @@ -197,5 +197,5 @@ PY - 本次实践:`.agents/knowledge/github-references/` 从 1.9GB 优化至 101MB - Git 清理:`git gc --aggressive --prune=now` 将 .git 从 566MB 压缩至 936KB -- 知识库 skill:`.agents/skills/knowledge-base/SKILL.md` -- 报告生成 skill:`.agents/skills/gen-report/SKILL.md` +- 知识库 skill:`.agents/skills/pentester-knowledge-base/SKILL.md` +- 报告生成 skill:`.agents/skills/pentester-gen-report/SKILL.md` diff --git a/.agents/knowledge/vuln-analysis/cloud/cloud-posture-essence.md b/.agents/knowledge/vuln-analysis/cloud/cloud-posture-essence.md new file mode 100644 index 00000000..8067377c --- /dev/null +++ b/.agents/knowledge/vuln-analysis/cloud/cloud-posture-essence.md @@ -0,0 +1,41 @@ +# 云安全 posture 精华 — AWS / Azure / GCP / S3 / IAM / K8s + +> Tier-1 方法论精华。手把手 payload 见 pentester-src-hunter `playbooks/cloud/`。元数据 SSRF 见 `playbooks/ssrf-cache-host/11-cloud.md`。 + +## 识别与分层 + +1. **厂商识别**:DNS 后缀(`amazonaws.com` / `blob.core.windows.net` / `googleapis.com`)、header、错误页、证书 SAN。 +2. **面分层**:匿名面(公开存储桶、暴露 API、metadata)→ 单凭据面(枚举权限)→ 提权/横向面(IAM/RBAC misconfig)。 + +## 未授权面(不需要凭据) + +- **对象存储公开**:S3 `--no-sign-request` 可列/可读/可写;GCS `allUsers`;Azure Blob `?comp=list` 匿名。可写 = 高危(供应链/篡改)。 +- **公开快照/镜像**:EBS 快照、AMI、GCP 镜像、容器镜像仓库公开。 +- **暴露元数据/管理端点**:IMDSv1、K8s 匿名 kubelet `:10250`、etcd `:2379`、Dashboard skip-login。 +- **密钥泄露**:前端 JS / Git 历史 / CI 变量 / 镜像层里的 access key、SAS、SA json、connection string。 + +## 单凭据面(拿到任意 key/token 后先做的事) + +| 云 | 我是谁 | 枚举权限 | +|----|--------|----------| +| AWS | `aws sts get-caller-identity` | `iam get-account-authorization-details` / `enumerate-iam` | +| Azure | `az account show` | `az role assignment list --all` | +| GCP | `gcloud auth list` | `gcloud projects get-iam-policy` | +| K8s | SA token | `kubectl auth can-i --list` | + +## 高危配置错误(判定为 Verified Vulnerability 的核心类) + +- 过宽 IAM:`*:*`、`iam:PassRole` + 服务角色、可自附管理策略、可建 access key。 +- 元数据可达且角色权限过大(SSRF → 临时凭据 → 云 API)。 +- 存储桶/密钥保管库访问控制缺失。 +- K8s:默认 SA 自动挂载 + 过宽 ClusterRoleBinding;可创建特权 Pod。 + +## 取证要点(Evidence) + +- 记录 `get-caller-identity` 等身份输出、可执行动作证明(只读枚举,不改动配置除非授权)。 +- 公开桶:截图/列举响应 + 一个非敏感对象读取证明;**不下载客户敏感数据**。 +- 提权链:写清 "起始权限 → 使用的原语 → 目标权限" 的可复现步骤。 + +## 审计/枚举工具(只读优先) + +`ScoutSuite` · `prowler` · `pacu`(AWS) · `ROADtools`/`AzureHound`(Azure) · `gcp_scanner`(GCP) · `kube-hunter`/`kubeaudit`(K8s)。 diff --git a/.agents/mcp/INDEX.md b/.agents/mcp/INDEX.md index d26c46ed..e110a416 100644 --- a/.agents/mcp/INDEX.md +++ b/.agents/mcp/INDEX.md @@ -29,4 +29,4 @@ ## 相关 Skill -- [mcp-manager](../skills/mcp-manager/SKILL.md) — MCP 工具发现与调用 skill,通过 `/mcp` 触发 +- [pentester-mcp-manager](../skills/pentester-mcp-manager/SKILL.md) — MCP 工具发现与调用 skill,通过 `/mcp` 触发 diff --git a/.agents/phases/00-pre-engagement.md b/.agents/phases/00-pre-engagement.md index 9d5f7f12..732a8a15 100644 --- a/.agents/phases/00-pre-engagement.md +++ b/.agents/phases/00-pre-engagement.md @@ -3,7 +3,7 @@ | | | |--|--| | **Manifest** | `manifest-phase-0.md` (`summary_fields` in `schema.json`) | -| **Skill** | `init-target` | +| **Skill** | `pentester-init-target` | | **Env** | `.agents/schema/env.md` · `env-render.js` · `materialize.js` | ## Session packet diff --git a/.agents/phases/01-intelligence.md b/.agents/phases/01-intelligence.md index 9a59dfef..24dcae1d 100644 --- a/.agents/phases/01-intelligence.md +++ b/.agents/phases/01-intelligence.md @@ -3,7 +3,7 @@ | | | |--|--| | **Manifest** | `manifest-phase-1.md` (keys: see `schema.json`) | -| **Skill** | `enum-services` | +| **Skill** | `pentester-enum-services` | | **Contract** | `.agents/schema/schema.json` · `env.md` | ## Session packet @@ -39,6 +39,7 @@ node .agents/schema/scope-check.js ${ID} --host ${SCAN_HOST} # refuses deny_ho - Full TCP + version/OS → `ports/nmap_full_tcp.*` - UDP top 20 → `ports/nmap_udp.*` - Use **env-render** skeletons; parallel OK +- Also keep machine-readable output (`nmap -oX`) — Phase 3 `pentester-scan-ingest` reuses it to seed the Triage ledger. **Forbidden paths**: see `schema.json` `forbidden_paths` (`tcp_full.txt`, `scans/`, …). @@ -52,13 +53,15 @@ node .agents/schema/scope-check.js ${ID} --host ${SCAN_HOST} # refuses deny_ho Paths under `path_root` from env-render. +**Accelerators** via `pentester-toolkit` (`.agents/tools/TOOLS.md` — all active, `scope-check.js` first): `subfinder` (passive subdomains) · `naabu` (fast port sweep → hand open ports to nmap) · `httpx -json` (live-web probe → `vulns/scanner_raw/` → `pentester-scan-ingest`) · `EHole` / `dismap` (product fingerprint) · `dddd` (noisy all-in-one — needs `authorization: confirmed`). + ### 4. Passive OSINT (≥3 sources) → `services/osint-results.md` ### 5. Fingerprint → dictionary (optional) -OA / CN products → `skills/src-hunter/references/dictionaries/` → `services/product-attack-surface.md` +OA / CN products → `skills/pentester-src-hunter/references/dictionaries/` → `services/product-attack-surface.md` ### 6. recon-summary.md diff --git a/.agents/phases/01b-web-pretest.md b/.agents/phases/01b-web-pretest.md index 5835f97b..6e7d69a6 100644 --- a/.agents/phases/01b-web-pretest.md +++ b/.agents/phases/01b-web-pretest.md @@ -3,7 +3,7 @@ | | | |--|--| | **Manifest** | `manifest-phase-1-web.md` | -| **Skills** | `playwright-skill` + `src-hunter` (playbook only) | +| **Skills** | `pentester-playwright` + `pentester-src-hunter` (playbook only) | | **Evidence** | `.agents/schema/evidence-land.md` | ## Session packet @@ -26,15 +26,23 @@ Phase 1 `web_ports` empty → **do not** create 1b manifest; stop. # Host Evidence roots ONLY via env-render (Schema artifact_patterns; dual-track) eval "$(node .agents/schema/env-render.js --scan-env ${scan_env:-docker} --id ${ID} --host ${SCAN_HOST} --export)" mkdir -p "$EVIDENCE_DIR" "$SCREENSHOT_DIR" -cd .agents/skills/playwright-skill && node run.js /tmp/playwright-web-explore.js +cd .agents/skills/pentester-playwright && node scripts/poc.js /tmp/playwright-web-explore.js ``` Landing rules: `evidence-land.md`. Function map → Schema path under services (function-map). +For a **structured endpoint/route/API catalog** (feeds `pages`/`forms`/`apis`), drive `pentester-playwright` in recon mode: + +```bash +cd .agents/skills/pentester-playwright && node scripts/recon.js --config config/scope..yaml --url "$URL" --roles admin,user,anon +``` + +Web fingerprint accelerators via **`pentester-toolkit`** (`.agents/tools/TOOLS.md`, scope-gate first): `httpx -json` (→ `pentester-scan-ingest`) · `EHole` / `dismap` (product/CMS fingerprint). + ### 2. Pretest (playbooks) ```bash -node .agents/skills/src-hunter/playbook.js --signal "unauth,info,idor" +node .agents/skills/pentester-src-hunter/playbook.js --signal "unauth,info,idor" # Read top playbook path; do not invent payloads; do not browse h1-reports/raw ``` diff --git a/.agents/phases/02-threat-modeling.md b/.agents/phases/02-threat-modeling.md index 9f4fc15c..fa00c041 100644 --- a/.agents/phases/02-threat-modeling.md +++ b/.agents/phases/02-threat-modeling.md @@ -3,6 +3,7 @@ | | | |--|--| | **Manifest** | `manifest-phase-2.md` | +| **Skill** | `pentester-threat-model` | | **Contract** | `schema.json` phase `2` | ## Session packet @@ -19,7 +20,16 @@ Do not re-list allowed_reads / forbidden / writes / knowledge_bag here. 2. Classify assets: network gear / servers / apps / services 3. STRIDE per asset class 4. Rank attack surface Critical → Low -5. Write **Threat model** (`threat-model.md`) with priority table + assumptions +5. Write **Threat model** (`threat-model.md`) with a priority table + assumptions. + Each row maps a threat to a **Signal** keyword (from `knowledge/signal-aliases.json`) so Phase 3 can LOOKUP it: + +``` +| Surface | Threat (STRIDE) | Signal | Phase-3 test | +|---------|-----------------|--------|--------------| +| http | Reflected XSS on `q` (Tampering) | xss | src-hunter --signal xss | +``` + +**Gate**: every `critical_surfaces` and `priority_threats` value in the handoff summary **must appear** in `threat-model.md` (validator fails strict otherwise) — don't declare a surface/threat you didn't model. ## Heuristics diff --git a/.agents/phases/03-vulnerability-analysis.md b/.agents/phases/03-vulnerability-analysis.md index 619d4f95..b64756e4 100644 --- a/.agents/phases/03-vulnerability-analysis.md +++ b/.agents/phases/03-vulnerability-analysis.md @@ -3,7 +3,7 @@ | | | |--|--| | **Manifest** | `manifest-phase-3.md` | -| **Skills** | `src-hunter` playbooks · `playwright-skill` | +| **Skills** | `pentester-src-hunter` playbooks · `pentester-playwright` · `pentester-scan-ingest` | | **Contract** | `schema.json` artifact_patterns · `evidence-land.md` | ## Session packet @@ -21,12 +21,20 @@ Do not re-list allowed_reads / forbidden / writes / knowledge_bag here. ```bash node .agents/schema/scope-check.js ${ID} --host ${SCAN_HOST} # scope gate before active scanning node .agents/schema/env-render.js --scan-env ${scan_env} --id ${ID} --host ${SCAN_HOST} -# intent: nikto / nuclei / nmap --script=vuln → vulns/scanner_raw/ under path_root +# intent: nikto / nuclei -jsonl / nmap -oX / httpx -json / ffuf -o → vulns/scanner_raw/ under path_root ``` -### 2. Triage every Raw scan result → `vulns/triage.md` +**Scanners** via `pentester-toolkit` (`.agents/tools/TOOLS.md` — active + noisy, require `authorization: confirmed` + scope-gate): `nuclei -jsonl` (→ `pentester-scan-ingest`) · `afrog -json` · `xray webscan --json-output` · `DefaultHound` (default creds) · `dddd` (poc). Write raw output to `vulns/scanner_raw/`, then ingest. -Record each scanner hit's disposition so coverage is auditable — nothing is dropped silently: +### 2. Ingest → triage every Raw scan result → `vulns/triage.md` + +Normalize the raw output into the ledger (seeds `verifying` rows; never overwrites a human disposition): + +```bash +node .agents/schema/scan-ingest.js ${ID} # parse vulns/scanner_raw/ → merge rows into vulns/triage.md +``` + +Then set each row's disposition so coverage is auditable — nothing is dropped silently: ``` | Raw scan result | Source | Disposition | Note | @@ -34,11 +42,12 @@ Record each scanner hit's disposition so coverage is auditable — nothing is dr ``` Disposition ∈ `dismissed(reason)` | `verifying` | `VULN-NNN`. Handoff `triaged_count` = rows recorded. +Findings scan-ingest can't parse (manual/other tools) are still added by hand. ### 3. Manual verify Critical/High (Threat model first) ```bash -node .agents/skills/src-hunter/playbook.js --signal "" +node .agents/skills/pentester-src-hunter/playbook.js --signal "" # Read top playbook path; no memorized payloads ``` @@ -48,7 +57,7 @@ Web PoC: eval "$(node .agents/schema/env-render.js --scan-env ${scan_env:-docker} --id ${ID} --host ${SCAN_HOST} --export)" # For a Verified Vulnerability, bind Evidence to it: EVIDENCE_DIR="${EVIDENCE_VULN_DIR/NNN/$vuln_num}" (vuln_num = this VULN's number — not a fixed sample id) mkdir -p "$EVIDENCE_DIR" -cd .agents/skills/playwright-skill && node run.js /tmp/playwright-vuln-proof.js +cd .agents/skills/pentester-playwright && node scripts/poc.js /tmp/playwright-vuln-proof.js ``` **Verified Vulnerability** id `VULN-NNN` → dir + Evidence per `schema.json` `artifact_patterns` only. diff --git a/.agents/phases/04-exploitation.md b/.agents/phases/04-exploitation.md index 44a818c0..37a3c6ac 100644 --- a/.agents/phases/04-exploitation.md +++ b/.agents/phases/04-exploitation.md @@ -3,7 +3,7 @@ | | | |--|--| | **Manifest** | `manifest-phase-4.md` | -| **Skills** | playwright · src-hunter playbooks | +| **Skills** | `pentester-playwright` · `pentester-src-hunter` playbooks | | **Evidence** | `evidence-land.md` | ## Session packet @@ -18,18 +18,24 @@ Do not re-list allowed_reads / forbidden / writes / knowledge_bag here. 1. Order by CVSS + Threat model 2. Confirm with user before destructive exploit (unless bypass) -3. Web: Playwright PoC + screenshots → Evidence under `artifact_patterns` for this `VULN-NNN` +3. Pre-flight each offensive command through the runtime guard (scope / window / banned + audit trail): + +```bash +node .agents/schema/guard.js ${ID} --cmd "" # exit 1 → do NOT run it +``` + +4. Web: Playwright PoC + screenshots → Evidence under `artifact_patterns` for this `VULN-NNN` ```bash eval "$(node .agents/schema/env-render.js --scan-env ${scan_env:-docker} --id ${ID} --host ${SCAN_HOST} --export)" # Override EVIDENCE_DIR with evidence_vuln for this VULN-NNN if needed — never a fixed sample id mkdir -p "$EVIDENCE_DIR" -cd .agents/skills/playwright-skill && node run.js /tmp/playwright-poc-execute.js +cd .agents/skills/pentester-playwright && node scripts/poc.js /tmp/playwright-poc-execute.js ``` -4. On success record identity / host facts -5. Write `access.md` (Access foothold, how to reproduce) -6. Exploit log narrative → `exploit-log.md` (not the Report) +5. On success record identity / host facts +6. Write `access.md` (Access foothold, how to reproduce) +7. Exploit log narrative → `exploit-log.md` (not the Report) ## Manifest diff --git a/.agents/phases/05-post-exploitation.md b/.agents/phases/05-post-exploitation.md index c8c1894a..6965195a 100644 --- a/.agents/phases/05-post-exploitation.md +++ b/.agents/phases/05-post-exploitation.md @@ -3,6 +3,7 @@ | | | |--|--| | **Manifest** | `manifest-phase-5.md` | +| **Skill** | `pentester-post-exploit` | | **Contract** | `schema.json` phase `5` | ## Session packet @@ -26,7 +27,18 @@ uname -a; id; cat /etc/os-release; ss -tulnp; sudo -l 3. Privilege escalation enum → `privilege-escalation.md` 4. Credentials → `credentials.md` -5. Lateral (if Scope allows) → `lateral-movement.md` +5. Lateral (if Scope allows) → `lateral-movement.md`. Gate each destination first: + +```bash +node .agents/schema/scope-check.js ${ID} --host ${LATERAL_HOST} # refuses deny_hosts +``` + +Intranet sweep via `pentester-toolkit`: `fscan -h -o loot/fscan.txt` (port/service/poc/brute — **noisy, scope-gate every destination**; see `.agents/tools/TOOLS.md`). + +**Gates** (validator, when Phase 4 `access_level != none`): +- `situational-awareness.md` must be substantive (not a stub). +- `credentials_found: yes` ⇒ `credentials.md` present; `lateral_hosts` set ⇒ `lateral-movement.md` present. +- Any `lateral_hosts` entry that `scope.md` denies → **fail** (out-of-scope lateral movement). ## End diff --git a/.agents/phases/06-reporting.md b/.agents/phases/06-reporting.md index e9b63c39..f4cfe93e 100644 --- a/.agents/phases/06-reporting.md +++ b/.agents/phases/06-reporting.md @@ -3,7 +3,7 @@ | | | |--|--| | **Manifest** | `manifest-phase-6.md` | -| **Skill** | `gen-report` | +| **Skill** | `pentester-gen-report` | | **Fill interface** | `.agents/REPORT-SKELETON.md` | | **Optional depth** | `.agents/knowledge/reporting/report-wording-depth.md` (not fill authority) | diff --git a/.agents/phases/README.md b/.agents/phases/README.md index ad4aab91..389f7763 100644 --- a/.agents/phases/README.md +++ b/.agents/phases/README.md @@ -35,4 +35,4 @@ Env: `../schema/env.md` + `env-render.js` + `materialize.js`. 3. Update `chain-status.md` (asserted under `--strict-summary`) 4. `node .agents/schema/validate-target-schema.js ${ID} --strict-summary --stamp-chain` 5. Phase 6: `node .agents/schema/check-report.js ${ID}` -6. Playbooks: `node .agents/skills/src-hunter/playbook.js --signal "…"` +6. Playbooks: `node .agents/skills/pentester-src-hunter/playbook.js --signal "…"` diff --git a/.agents/schema/env-render.js b/.agents/schema/env-render.js index f93eef14..a9879e7d 100644 --- a/.agents/schema/env-render.js +++ b/.agents/schema/env-render.js @@ -74,7 +74,7 @@ function renderEnv(opts) { path_root: pathRoot, exec_prefix: execPrefix, container, - playwright_cwd: '.agents/skills/playwright-skill', + playwright_cwd: '.agents/skills/pentester-playwright', // dual-track Evidence (host) evidence_1b, evidence_vuln, diff --git a/.agents/schema/evidence-land.md b/.agents/schema/evidence-land.md index afdd9188..e0588d65 100644 --- a/.agents/schema/evidence-land.md +++ b/.agents/schema/evidence-land.md @@ -1,7 +1,7 @@ # Evidence landing adapter **Seam**: Phase procedures (1b / 3 / 4) need Schema-bound **Evidence**. -**Low-level executor**: `.agents/skills/playwright-skill/run.js`. +**Low-level executor**: `.agents/skills/pentester-playwright/scripts/poc.js`. **Path authority**: `schema.json` → `artifact_patterns` (projected by `env-render.js`). **This file**: engagement rules only — **do not** re-list path tables here. @@ -27,7 +27,7 @@ Dual-track: `path_root` is for tools inside docker (`/target`) or host-kali; Pla ```bash eval "$(node .agents/schema/env-render.js --scan-env docker --id ${ID} --host ${SCAN_HOST} --export)" mkdir -p "$EVIDENCE_DIR" "$SCREENSHOT_DIR" -cd .agents/skills/playwright-skill && node run.js /tmp/playwright-web-explore.js +cd .agents/skills/pentester-playwright && node scripts/poc.js /tmp/playwright-web-explore.js ``` For a Verified Vulnerability, bind Evidence to it: `EVIDENCE_DIR="${EVIDENCE_VULN_DIR/NNN/$vuln_num}"` (the exported `EVIDENCE_VULN_DIR` is a `vuln-NNN` template — replace `NNN` with the VULN number). @@ -38,7 +38,7 @@ Prefer `helpers.takeScreenshot` so engagement mode fail-closed applies. | Module | Owns | |--------|------| -| `playwright-skill` | Browser automation depth | +| `pentester-playwright` | Browser automation depth | | `env-render.js` | Path projection from Schema | | `evidence-land.md` (this file) | Landing rules / fail-closed intent | | Phase procedure | When to capture; Manifest listing | diff --git a/.agents/schema/guard.js b/.agents/schema/guard.js new file mode 100644 index 00000000..26bb2151 --- /dev/null +++ b/.agents/schema/guard.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Runtime guard CLI — pre-flight a proposed offensive command against the + * engagement's scope.md (deny_hosts / window / banned), append an audit row to + * activity-log.md, and exit non-zero if it must not run. Route offensive + * commands through this so scope is enforced at execution time, not just before + * the first scan — and so the Report has a real activity log. + * + * node .agents/schema/guard.js --cmd "nmap -sV 10.0.0.5" + * node .agents/schema/guard.js --cmd "…" --dry-run # decide, don't log + * node .agents/schema/guard.js --fixture --cmd "…" # never writes + * + * Honest boundary: opt-in interceptor + audit, not a sandbox. Exit 0 allow, 1 deny, 2 usage. + */ + +const fs = require('fs'); +const path = require('path'); +const { resolveWorkspace } = require('./lib/workspace'); +const { parseScope } = require('./lib/scope'); +const { evaluateCommand, auditLine, ACTIVITY_HEADER } = require('./lib/guard'); + +const USAGE = 'Usage: node .agents/schema/guard.js --cmd "" [--dry-run] | --fixture --cmd "…"'; +const { dir, useFixture, flags } = resolveWorkspace(process.argv.slice(2), { + usage: USAGE, + exitCode: 2, + valueFlags: ['cmd'], +}); + +const command = flags.cmd; +if (!command || command === true) { + console.error(USAGE); + process.exit(2); +} + +const scopePath = path.join(dir, 'scope.md'); +if (!fs.existsSync(scopePath)) { + console.error('scope.md missing — run Phase 0 first'); + process.exit(2); +} + +const scope = parseScope(fs.readFileSync(scopePath, 'utf8')); +const now = new Date(); +const nowMinutes = now.getHours() * 60 + now.getMinutes(); +const decision = evaluateCommand(scope, command, { nowMinutes }); + +const nowIso = now.toISOString(); +const dryRun = flags['dry-run'] || useFixture; // never mutate the golden fixture + +if (!dryRun) { + const logPath = path.join(dir, 'activity-log.md'); + const head = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8').replace(/\s*$/, '') : ACTIVITY_HEADER; + fs.writeFileSync(logPath, head + '\n' + auditLine({ ...decision, command, nowIso }) + '\n'); +} + +if (scope.window) console.log('⏱ window: ' + scope.window + (nowMinutes != null ? ' (now ' + now.toTimeString().slice(0, 5) + ')' : '')); +if (scope.banned.length) console.log('⛔ banned: ' + scope.banned.join(', ')); + +if (!decision.allow) { + console.error('❌ guard DENY — ' + decision.reasons.join('; ') + '\n command: ' + command); + console.error(' (edit scope.md if this boundary is wrong; do NOT run out of scope/window)'); + process.exit(1); +} +console.log('✅ guard allow — in scope' + (decision.hosts.length ? ' (' + decision.hosts.join(', ') + ')' : '') + (dryRun ? ' [dry-run, not logged]' : ' [logged to activity-log.md]')); diff --git a/.agents/schema/guard.selftest.js b/.agents/schema/guard.selftest.js new file mode 100644 index 00000000..8cc5180a --- /dev/null +++ b/.agents/schema/guard.selftest.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Runtime guard selftest — pure, no FS, no clock (time injected). Guards host + * extraction, window math (incl. overnight), banned-technique matching, and the + * allow/deny decision the CLI enforces before an offensive command runs. + * Run: node .agents/schema/guard.selftest.js + */ + +const assert = require('assert'); +const { + extractHosts, + parseWindow, + inWindow, + matchBanned, + evaluateCommand, + auditLine, +} = require('./lib/guard'); + +// ── host extraction ── +const hosts = extractHosts('nmap -sV 10.0.0.5 http://example.com/app --url https://x.internal:8080/y -oX out.nmap.xml'); +assert.ok(hosts.includes('10.0.0.5'), 'IPv4 extracted'); +assert.ok(hosts.includes('example.com'), 'URL host extracted'); +assert.ok(hosts.includes('x.internal'), 'flag-value host extracted (port stripped)'); +assert.ok(!hosts.some((h) => /\.xml$/i.test(h)), 'output filename is not a host'); + +// ── window math ── +assert.deepStrictEqual(parseWindow('09:00-18:00'), { start: 540, end: 1080 }); +assert.ok(inWindow('09:00-18:00', 600), '10:00 inside day window'); +assert.ok(!inWindow('09:00-18:00', 1300), '21:40 outside day window'); +assert.ok(inWindow('22:00-06:00', 60), '01:00 inside overnight window'); +assert.ok(!inWindow('22:00-06:00', 720), '12:00 outside overnight window'); +assert.ok(inWindow('', 720), 'absent window never blocks'); + +// ── banned techniques (label + tool alias) ── +assert.deepStrictEqual(matchBanned('hping3 --flood 10.0.0.5', ['DoS']), ['DoS'], 'DoS tool matches banned label'); +assert.deepStrictEqual(matchBanned('hydra -l admin -P rock.txt ssh://x', ['brute-force']), ['brute-force'], 'hydra matches brute-force'); +assert.deepStrictEqual(matchBanned('nmap -sV 10.0.0.5', ['DoS']), [], 'benign command hits nothing'); + +// ── decision ── +const denyScope = { deny: ['10.0.0.99', '10.9.9.0/24'], banned: ['DoS'], window: '09:00-18:00' }; +const dHost = evaluateCommand(denyScope, 'nmap 10.0.0.99', { nowMinutes: 600 }); +assert.strictEqual(dHost.allow, false); +assert.ok(dHost.deniedHosts.includes('10.0.0.99')); +assert.ok(evaluateCommand(denyScope, 'nmap 10.9.9.5', { nowMinutes: 600 }).deniedHosts.length === 1, 'CIDR deny match'); + +const dBanned = evaluateCommand(denyScope, 'hping3 --flood 10.0.0.5', { nowMinutes: 600 }); +assert.strictEqual(dBanned.allow, false); +assert.deepStrictEqual(dBanned.bannedHits, ['DoS']); + +const dWindow = evaluateCommand(denyScope, 'nmap 10.0.0.5', { nowMinutes: 1300 }); +assert.strictEqual(dWindow.allow, false, 'out-of-window blocks'); +assert.ok(dWindow.outOfWindow); + +const allow = evaluateCommand({ deny: [], banned: [], window: '' }, 'nmap -sV 10.0.0.5', { nowMinutes: 720 }); +assert.strictEqual(allow.allow, true); +assert.ok(allow.hosts.includes('10.0.0.5')); + +// ── audit line ── +const line = auditLine({ ...dHost, command: 'nmap 10.0.0.99 | tee out', nowIso: '2020-01-01T10:00:00.000Z' }); +assert.ok(/\| DENY \|/.test(line), 'DENY decision rendered'); +assert.ok(!/\| tee out/.test(line) && /\/ tee out/.test(line), 'pipe in command is neutralized'); + +console.log('✅ guard selftest passed (hosts, window incl. overnight, banned, decision, audit line)'); diff --git a/.agents/schema/lib/guard.js b/.agents/schema/lib/guard.js new file mode 100644 index 00000000..6ad60ff4 --- /dev/null +++ b/.agents/schema/lib/guard.js @@ -0,0 +1,122 @@ +/** + * Runtime guard — a command pre-flight over the engagement's own scope.md, so a + * denied host / banned technique / out-of-window run is caught at execution time, + * not just at the pre-scan gate. Pure (no FS, no clock): the CLI injects the + * parsed scope, the command string, and the current time. It also produces the + * audit line for activity-log.md so every offensive command leaves a trail. + * + * Honest boundary: without a real exec sandbox this is opt-in — the agent routes + * commands through it. It is an interceptor + audit layer, not strong isolation. + */ + +const { hostMatches } = require('./scope'); + +const FILE_EXT = /\.(md|js|json|xml|txt|py|sh|html?|php|log|conf|ya?ml|csv|nmap|gnmap)$/i; + +/** Extract candidate target hosts (IPv4, URL hosts, flag values, bare domains) from a command. */ +function extractHosts(command) { + const cmd = String(command || ''); + const set = new Set(); + // IPv4 + for (const m of cmd.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g) || []) set.add(m); + // URL hosts + for (const m of cmd.matchAll(/https?:\/\/([^/\s:'"]+)/gi)) set.add(m[1]); + // Explicit target flags: -u/--url/--host/--target/-H value + for (const m of cmd.matchAll(/(?:-u|--url|--host|--target|-t|-H)\s+([^\s'"]+)/gi)) { + const v = m[1].replace(/^https?:\/\//i, '').replace(/[/:].*$/, ''); + if (v) set.add(v); + } + // Bare domains (has a dotted TLD), excluding file/code names + for (const m of cmd.match(/\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}\b/gi) || []) { + if (!FILE_EXT.test(m)) set.add(m); + } + return [...set]; +} + +/** Parse an "HH:MM-HH:MM" window (day prefixes ignored) → {start, end} minutes, or null. */ +function parseWindow(win) { + const m = String(win || '').match(/(\d{1,2}):(\d{2})\s*[-–—to]+\s*(\d{1,2}):(\d{2})/i); + if (!m) return null; + const start = parseInt(m[1], 10) * 60 + parseInt(m[2], 10); + const end = parseInt(m[3], 10) * 60 + parseInt(m[4], 10); + return { start, end }; +} + +/** Is nowMinutes within the window? Supports overnight windows (start > end). */ +function inWindow(win, nowMinutes) { + const w = parseWindow(win); + if (!w) return true; // unparseable/absent window is not a hard block + if (w.start === w.end) return true; + if (w.start < w.end) return nowMinutes >= w.start && nowMinutes <= w.end; + return nowMinutes >= w.start || nowMinutes <= w.end; // overnight +} + +// Technique aliases: a banned label also matches the tools that implement it. +const BANNED_ALIASES = [ + { rx: /^(dos|ddos|denial[- ]?of[- ]?service)$/i, tool: /hping3|slowloris|--flood|\bflood\b|torshammer/i }, + { rx: /^(brute[- ]?force|bruteforce|password[- ]?spray)$/i, tool: /\bhydra\b|medusa|patator|ncrack|--brute|crackmapexec .*(-p |--pass)/i }, + { rx: /^phishing$/i, tool: /gophish|evilginx|setoolkit/i }, + { rx: /^(social[- ]?engineering)$/i, tool: /setoolkit|gophish/i }, +]; + +/** Banned techniques hit by this command: the label text itself, or a known tool for it. */ +function matchBanned(command, banned) { + const cmd = String(command || ''); + const hits = []; + for (const term of banned || []) { + const t = String(term || '').trim(); + if (!t) continue; + const wordRx = new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i'); + let hit = wordRx.test(cmd); + if (!hit) { + const alias = BANNED_ALIASES.find((a) => a.rx.test(t)); + if (alias && alias.tool.test(cmd)) hit = true; + } + if (hit) hits.push(t); + } + return hits; +} + +/** + * Evaluate a proposed command against Scope. + * @param {{deny:string[], banned:string[], window:string}} scope parsed scope.md + * @param {string} command + * @param {{nowMinutes?:number}} opts + * @returns {{allow:boolean, hosts:string[], deniedHosts:string[], bannedHits:string[], outOfWindow:boolean, reasons:string[]}} + */ +function evaluateCommand(scope, command, opts = {}) { + const s = scope || { deny: [], banned: [], window: '' }; + const hosts = extractHosts(command); + const deniedHosts = hosts.filter((h) => (s.deny || []).some((d) => hostMatches(d, h))); + const bannedHits = matchBanned(command, s.banned || []); + let outOfWindow = false; + if (s.window && typeof opts.nowMinutes === 'number') outOfWindow = !inWindow(s.window, opts.nowMinutes); + const reasons = []; + for (const h of deniedHosts) reasons.push('host ' + h + ' is out of scope (deny_hosts)'); + for (const b of bannedHits) reasons.push('banned technique: ' + b); + if (outOfWindow) reasons.push('outside test window ' + s.window); + const allow = deniedHosts.length === 0 && bannedHits.length === 0 && !outOfWindow; + return { allow, hosts, deniedHosts, bannedHits, outOfWindow, reasons }; +} + +const ACTIVITY_HEADER = '# Activity log\n\nEvery offensive command routed through the guard, with its scope decision.\n\n| Time | Decision | Hosts | Reason | Command |\n|------|----------|-------|--------|---------|'; + +/** One activity-log.md table row (pipes/newlines in the command are neutralized). */ +function auditLine(entry) { + const decision = entry.allow ? 'allow' : 'DENY'; + const hosts = (entry.hosts || []).join(' ') || '-'; + const reason = (entry.reasons && entry.reasons.length ? entry.reasons.join('; ') : entry.allow ? 'in scope' : '-') + .replace(/\|/g, '/'); + const cmd = String(entry.command || '').replace(/\|/g, '/').replace(/[\r\n]+/g, ' '); + return '| ' + (entry.nowIso || '') + ' | ' + decision + ' | ' + hosts + ' | ' + reason + ' | `' + cmd + '` |'; +} + +module.exports = { + extractHosts, + parseWindow, + inWindow, + matchBanned, + evaluateCommand, + auditLine, + ACTIVITY_HEADER, +}; diff --git a/.agents/schema/lib/scan-ingest.js b/.agents/schema/lib/scan-ingest.js new file mode 100644 index 00000000..210c73d1 --- /dev/null +++ b/.agents/schema/lib/scan-ingest.js @@ -0,0 +1,375 @@ +/** + * Scan ingest — normalize raw scanner output into Raw scan result records and + * merge them into the Triage ledger, so the "eyeball the tool output, hand-copy + * hits into triage.md" step becomes a checkable, idempotent one. Pure (no FS): + * callers inject file {name, content}; the CLI seam (scan-ingest.js) does I/O. + * + * A record is one Raw scan result: { tool, name, severity, location, sources }. + * It is NOT a Verified Vulnerability — every ingested row lands as `verifying` + * and a human triages it. The framework runs the tools freehand; this only + * regularizes their output into the schema's triage table (artifact_patterns. + * triage_ledger), never invents findings. + */ + +const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info']; + +function normalizeSeverity(s) { + const v = String(s || '').trim().toLowerCase(); + if (SEVERITIES.includes(v)) return v; + if (v === 'informational' || v === 'information' || v === 'none' || v === 'unknown' || v === '') { + return 'info'; + } + if (v === 'warning' || v === 'warn') return 'low'; + return 'info'; +} + +/** Collapse whitespace and cap length so the Raw-scan-result cell stays a cell. */ +function clean(s, max = 120) { + let t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); + // Table cells are pipe-delimited; a literal pipe would split the row. + t = t.replace(/\|/g, '/'); + if (t.length > max) t = t.slice(0, max - 1).trimEnd() + '…'; + return t; +} + +function unescapeXml(s) { + return String(s || '') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/ /gi, ' ') + .replace(/ /gi, ' ') + .replace(/ /gi, ' ') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +function attr(tag, name) { + const m = String(tag).match(new RegExp(name + '="([^"]*)"')); + return m ? m[1] : ''; +} + +// ── Per-tool parsers. Each returns [{tool, name, severity, location}]. ──────── + +/** + * nmap XML (`-oX`). Regex-based to keep the zero-dependency discipline. Emits + * one record per open port (service/version, info) and one per NSE script hit + * (the actual "findings" — vuln scripts get a heuristic severity bump). + */ +function parseNmapXml(text) { + const out = []; + const hosts = String(text).match(//g) || []; + for (const host of hosts) { + const addr = + (host.match(/]*addrtype="ipv4"[^>]*/) || [])[0] && + attr((host.match(/]*addrtype="ipv4"[^>]*/) || [''])[0], 'addr'); + const ip = addr || attr((host.match(/]*/) || [''])[0], 'addr') || 'host'; + const ports = host.match(//g) || []; + for (const port of ports) { + const portid = attr(port, 'portid'); + const proto = attr(port, 'protocol') || 'tcp'; + const state = attr((port.match(/]*/) || [''])[0], 'state'); + if (state && state !== 'open') continue; + const loc = ip + ':' + portid + '/' + proto; + const svcTag = (port.match(/]*/) || [''])[0]; + if (svcTag) { + const name = svcTag ? attr(svcTag, 'name') : ''; + const product = attr(svcTag, 'product'); + const version = attr(svcTag, 'version'); + const desc = [name, product, version].filter(Boolean).join(' ') || name || 'open port'; + out.push({ tool: 'nmap', name: clean('open ' + portid + '/' + proto + ' ' + desc), severity: 'info', location: loc }); + } + const scripts = port.match(/]*(?:\/>|>[\s\S]*?<\/script>)/g) || []; + for (const sc of scripts) { + const id = attr(sc, 'id'); + const output = unescapeXml(attr(sc, 'output')); + const first = output.split(/[\r\n]/).map((l) => l.trim()).filter(Boolean)[0] || ''; + const sev = /vuln|cve|exploit|VULNERABLE/i.test(id + ' ' + output) ? 'medium' : 'info'; + out.push({ tool: 'nmap', name: clean(id + (first ? ': ' + first : '')), severity: sev, location: loc }); + } + } + } + return out; +} + +/** nuclei JSONL (`-jsonl`). One JSON object per line. */ +function parseNucleiJsonl(text) { + const out = []; + for (const line of String(text).split(/\r?\n/)) { + const s = line.trim(); + if (!s || s[0] !== '{') continue; + let obj; + try { + obj = JSON.parse(s); + } catch (_) { + continue; + } + const info = obj.info || {}; + const id = obj['template-id'] || obj.templateID || obj.template_id || ''; + const name = info.name || id || 'nuclei match'; + const loc = obj['matched-at'] || obj.matched_at || obj.host || obj.url || ''; + out.push({ tool: 'nuclei', name: clean(name), severity: normalizeSeverity(info.severity), location: clean(loc, 80) }); + } + return out; +} + +/** httpx JSON/JSONL (`-json`). Recon-grade info records (status + tech). */ +function parseHttpxJson(text) { + const out = []; + for (const obj of jsonObjects(text)) { + const url = obj.url || obj.input || obj.host || ''; + if (!url) continue; + const status = obj.status_code || obj['status-code'] || ''; + const title = obj.title || ''; + const tech = Array.isArray(obj.tech || obj.technologies) ? (obj.tech || obj.technologies).join('/') : ''; + const bits = [status ? '[' + status + ']' : '', title, tech].filter(Boolean).join(' '); + out.push({ tool: 'httpx', name: clean(url + (bits ? ' ' + bits : '')), severity: 'info', location: clean(url, 80) }); + } + return out; +} + +/** ffuf JSON (`-o out.json`). Content-discovery hits from the `results` array. */ +function parseFfufJson(text) { + const out = []; + let obj; + try { + obj = JSON.parse(text); + } catch (_) { + return out; + } + const results = Array.isArray(obj.results) ? obj.results : []; + for (const r of results) { + const url = r.url || ''; + const input = + r.input && typeof r.input === 'object' + ? r.input.FUZZ || Object.values(r.input)[0] || '' + : r.input || ''; + const status = r.status != null ? r.status : ''; + const length = r.length != null ? r.length : ''; + const name = 'path ' + (input || url) + ' [' + status + (length !== '' ? ', ' + length + 'b' : '') + ']'; + out.push({ tool: 'ffuf', name: clean(name), severity: 'info', location: clean(url || input, 80) }); + } + return out; +} + +/** gobuster / dirb / feroxbuster plain text: lines like `/admin (Status: 200) [Size: 1234]`. */ +function parseGobusterText(text) { + const out = []; + for (const raw of String(text).split(/\r?\n/)) { + const line = raw.trim(); + if (!line) continue; + // gobuster dir: `/admin (Status: 200) [Size: 12]`; feroxbuster: `200 GET ... /admin`. + let m = line.match(/^(\/\S*)\s*\(Status:\s*(\d+)\)(?:\s*\[Size:\s*(\d+)\])?/i); + if (m) { + out.push({ tool: 'gobuster', name: clean('path ' + m[1] + ' [' + m[2] + (m[3] ? ', ' + m[3] + 'b' : '') + ']'), severity: 'info', location: clean(m[1], 80) }); + continue; + } + m = line.match(/^(\d{3})\s+\w+\s+.*?(\/\S+)\s*$/); + if (m && /^[123]/.test(m[1])) { + out.push({ tool: 'gobuster', name: clean('path ' + m[2] + ' [' + m[1] + ']'), severity: 'info', location: clean(m[2], 80) }); + } + } + return out; +} + +/** nikto plain text (`-o out.txt`): findings start with `+ `. */ +function parseNiktoText(text) { + const out = []; + for (const raw of String(text).split(/\r?\n/)) { + const line = raw.trim(); + const m = line.match(/^\+\s+(.*)$/); + if (!m) continue; + const body = m[1].trim(); + // Skip nikto's banner/progress `+` lines (target IP, start time, host count). + if (/^(Target|Start Time|End Time|Server:|SSL Info|\d+ host)/i.test(body)) continue; + if (!body) continue; + const sev = /OSVDB|CVE|inject|traversal|disclosure|default (account|credential)/i.test(body) ? 'medium' : 'info'; + out.push({ tool: 'nikto', name: clean(body), severity: sev, location: '' }); + } + return out; +} + +/** JSON that may be a single object, an array, or JSONL. Yields objects. */ +function jsonObjects(text) { + const t = String(text).trim(); + const out = []; + if (!t) return out; + try { + const parsed = JSON.parse(t); + if (Array.isArray(parsed)) return parsed.filter((x) => x && typeof x === 'object'); + if (parsed && typeof parsed === 'object') return [parsed]; + } catch (_) { + // fall through to JSONL + } + for (const line of t.split(/\r?\n/)) { + const s = line.trim(); + if (!s || s[0] !== '{') continue; + try { + out.push(JSON.parse(s)); + } catch (_) { + /* skip */ + } + } + return out; +} + +// ── Dispatcher ─────────────────────────────────────────────────────────────── + +/** Pick a tool by filename hint first, then sniff content. Returns a tool key or null. */ +function detectTool(name, content) { + const n = String(name || '').toLowerCase(); + const c = String(content || ''); + if (/nmap/.test(n) || / r && r.name); +} + +/** Dedup key: same finding text at the same location, tool-agnostic. */ +function dedupKey(rec) { + return (String(rec.name).toLowerCase().trim() + '@@' + String(rec.location || '').toLowerCase().trim()); +} + +/** + * Parse many files and dedup. Two tools reporting the same thing merge into one + * record whose `sources` lists both. Highest severity wins. + * @param {{name:string, content:string}[]} files + */ +function ingestFiles(files) { + const byKey = new Map(); + for (const f of files || []) { + for (const rec of parseFile(f.name, f.content)) { + const key = dedupKey(rec); + const prev = byKey.get(key); + if (!prev) { + byKey.set(key, { name: rec.name, severity: rec.severity, location: rec.location || '', sources: [rec.tool] }); + } else { + if (!prev.sources.includes(rec.tool)) prev.sources.push(rec.tool); + if (SEVERITIES.indexOf(rec.severity) < SEVERITIES.indexOf(prev.severity)) prev.severity = rec.severity; + } + } + } + return [...byKey.values()]; +} + +// ── Triage ledger merge ────────────────────────────────────────────────────── + +const TRIAGE_HEADER = '| Raw scan result | Source | Disposition | Note |'; +const TRIAGE_SEP = '|-----------------|--------|-------------|------|'; + +/** Parse an existing triage.md table → row objects (preserving human dispositions). */ +function parseTriage(text) { + const rows = []; + for (const line of String(text || '').split(/\r?\n/)) { + if (!/^\s*\|/.test(line)) continue; + if (/Raw scan result/i.test(line)) continue; // header + const cells = line.split('|').slice(1, -1).map((c) => c.trim()); + if (cells.length < 3) continue; + if (cells.every((c) => c === '' || /^:?-+:?$/.test(c))) continue; // separator row + if (!cells[0]) continue; + rows.push({ raw: cells[0], source: cells[1] || '', disposition: cells[2] || '', note: cells[3] || '' }); + } + return rows; +} + +/** Normalized raw-result text, for matching a new record against an existing row. */ +function rowKey(raw) { + return String(raw).replace(/`/g, '').toLowerCase().replace(/\s+/g, ' ').trim(); +} + +function countDispositions(rows) { + let verified = 0; + let dismissed = 0; + let verifying = 0; + for (const r of rows) { + const d = String(r.disposition).toLowerCase(); + if (/vuln-\d+/.test(d)) verified++; + else if (/dismiss/.test(d)) dismissed++; + else verifying++; + } + return { total: rows.length, verified, dismissed, verifying }; +} + +/** + * Merge new records into an existing triage ledger, idempotently: + * - a record whose raw-text already has a row is skipped (human disposition + * preserved) — re-running ingest never duplicates or downgrades; + * - a new record is appended as `verifying` with a ` · ` note. + * @returns {{ text: string, added: number, kept: number }} + */ +function mergeIntoTriage(existingText, records) { + const existingRows = parseTriage(existingText); + const seen = new Set(existingRows.map((r) => rowKey(r.raw))); + const rows = existingRows.slice(); + let added = 0; + for (const rec of records) { + const rawCell = '`' + rec.name + '`'; + const key = rowKey(rawCell); + if (seen.has(key)) continue; + seen.add(key); + const noteBits = [rec.severity, rec.location].filter(Boolean).join(' · '); + rows.push({ raw: rawCell, source: rec.sources.join('+'), disposition: 'verifying', note: noteBits }); + added++; + } + const counts = countDispositions(rows); + const lines = []; + lines.push('# Triage ledger'); + lines.push(''); + lines.push('Raw scan result → disposition. Every scanner hit gets one row so coverage is auditable.'); + lines.push(''); + lines.push(TRIAGE_HEADER); + lines.push(TRIAGE_SEP); + for (const r of rows) { + lines.push('| ' + r.raw + ' | ' + (r.source || '') + ' | ' + (r.disposition || 'verifying') + ' | ' + (r.note || '') + ' |'); + } + lines.push(''); + lines.push( + 'triaged: ' + counts.total + ' (verified ' + counts.verified + ' · dismissed ' + counts.dismissed + ' · verifying ' + counts.verifying + ')' + ); + lines.push(''); + return { text: lines.join('\n'), added, kept: existingRows.length }; +} + +module.exports = { + normalizeSeverity, + parseNmapXml, + parseNucleiJsonl, + parseHttpxJson, + parseFfufJson, + parseGobusterText, + parseNiktoText, + detectTool, + parseFile, + ingestFiles, + parseTriage, + countDispositions, + mergeIntoTriage, +}; diff --git a/.agents/schema/lib/threat-model.js b/.agents/schema/lib/threat-model.js new file mode 100644 index 00000000..911a1043 --- /dev/null +++ b/.agents/schema/lib/threat-model.js @@ -0,0 +1,37 @@ +/** + * Threat model rigor — make Phase 2 a checkable gate instead of free prose. + * Pure (no FS): the caller injects threat-model.md text + the Phase-2 handoff + * summary. Two properties keep the model honest and useful downstream: + * 1. coverage — every critical_surface the handoff declares is actually + * modeled (you can't claim a surface then leave it unexamined); + * 2. consistency — every priority_threat the handoff declares appears in the + * model body (the summary can't invent threats the model never states); + * 3. actionability (soft) — the model carries a `signal` mapping so Phase 3 + * LOOKUP / src-hunter has a machine keyword per prioritized threat. + */ + +function toList(v) { + return String(v || '') + .split(/[,;]/) + .map((s) => s.trim()) + .filter((s) => s && !/^(none|n\/a|-|tbd|无)$/i.test(s)); +} + +/** + * @param {string} body threat-model.md content + * @param {{critical_surfaces?:string, priority_threats?:string}} summary Phase-2 handoff + * @returns {{ surfaces:string[], threats:string[], uncoveredSurfaces:string[], unmodeledThreats:string[], hasSignal:boolean }} + */ +function checkThreatModel(body, summary) { + const text = String(body || ''); + const low = text.toLowerCase(); + const surfaces = toList(summary && summary.critical_surfaces); + const threats = toList(summary && summary.priority_threats); + const uncoveredSurfaces = surfaces.filter((s) => !low.includes(s.toLowerCase())); + const unmodeledThreats = threats.filter((t) => !low.includes(t.toLowerCase())); + // A `signal` column / annotation is how a threat hands a keyword to Phase 3. + const hasSignal = /\bsignal\b/i.test(text); + return { surfaces, threats, uncoveredSurfaces, unmodeledThreats, hasSignal }; +} + +module.exports = { checkThreatModel, toList }; diff --git a/.agents/schema/lib/validate.js b/.agents/schema/lib/validate.js index 91fd36d2..b9ef3116 100644 --- a/.agents/schema/lib/validate.js +++ b/.agents/schema/lib/validate.js @@ -20,6 +20,7 @@ const { chainStatusFile } = require('./workspace'); const { validateAllPackets } = require('./session-packet'); const { parseVerifiedVulns } = require('./report-assemble'); const { parseScope, evaluateHost } = require('./scope'); +const { checkThreatModel, toList: toScopeList } = require('./threat-model'); /** * Machine Authorization dialect. @@ -81,6 +82,18 @@ function validateWorkspace(schema, fs, opts = {}) { return false; } + // Resolve a phase's declared output path from schema (module of record) rather + // than hardcoding the filename — a rename in schema.json moves the gate with it. + function requiredOutput(phaseId, rx) { + const ph = (schema.phases || []).find((p) => p.id === phaseId); + const o = ((ph && ph.outputs) || []).find((out) => rx.test(out.path)); + return o ? o.path : null; + } + + function nonEmpty(rel) { + return pathExists(rel) && String(fs.read(rel) || '').trim() !== ''; + } + // Phase 1 web_ports for 1b rule let phase1Web = null; const m1 = 'manifest-phase-1.md'; @@ -289,6 +302,84 @@ function validateWorkspace(schema, fs, opts = {}) { } } + // Phase 2 rigor: the Threat model must actually contain the surfaces and + // threats its own handoff declares (coverage + consistency), so priority_threats + // is a checkable gate, not free prose. `signal` mapping is a soft nudge for the + // Phase-3 LOOKUP handoff. + if (phase2Summary) { + const tmPath = requiredOutput('2', /threat-model/) || 'threat-model.md'; + if (pathExists(tmPath)) { + const tm = checkThreatModel(fs.read(tmPath) || '', phase2Summary); + for (const s of tm.uncoveredSurfaces) { + const msg = `Phase 2: critical_surface "${s}" declared in handoff but not modeled in ${tmPath}`; + if (strictSummary) fail(msg); + else warn(msg); + } + for (const t of tm.unmodeledThreats) { + const msg = `Phase 2: priority_threat "${t}" declared in handoff but absent from ${tmPath}`; + if (strictSummary) fail(msg); + else warn(msg); + } + if (tm.surfaces.length && !tm.hasSignal) { + warn(`Phase 2: ${tmPath} has no \`signal\` mapping — add a Signal column so Phase 3 LOOKUP can resolve each threat`); + } + } + } + + // Phase 4→5 consistency: if a foothold was gained, Phase 5 must be substantive + // (a stub situational-awareness after access!=none means post-exploitation was + // skipped without saying so). Mirrors the Phase-3/4 artifact gates. + let phase5Summary = null; + if (fs.exists('manifest-phase-5.md')) { + phase5Summary = parseSummaryMap(fs.read('manifest-phase-5.md') || ''); + } + if (phase4Summary && phase5Summary) { + const level = String(phase4Summary.access_level || '').trim().toLowerCase(); + const accessGained = level && !/^(none|n\/a|-|无|no|failed|fail|0)$/.test(level); + if (accessGained) { + const saPath = requiredOutput('5', /situational-awareness/) || 'post-exploitation/situational-awareness.md'; + const body = pathExists(saPath) ? String(fs.read(saPath) || '') : ''; + const lines = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + if (lines.length < 3) { + const msg = `Phase 4 access_level=${level} but ${saPath} is a stub (<3 lines) — record situational awareness for the foothold`; + if (strictSummary) fail(msg); + else warn(msg); + } + const creds = String(phase5Summary.credentials_found || '').trim().toLowerCase(); + if (creds && !/^(no|none|n\/a|-|无|0|false)$/.test(creds)) { + const cp = requiredOutput('5', /credentials/) || 'post-exploitation/credentials.md'; + if (!nonEmpty(cp)) { + const msg = `Phase 5 credentials_found=${creds} but ${cp} missing/empty`; + if (strictSummary) fail(msg); + else warn(msg); + } + } + if (toScopeList(phase5Summary.lateral_hosts).length) { + const lp = requiredOutput('5', /lateral-movement/) || 'post-exploitation/lateral-movement.md'; + if (!nonEmpty(lp)) { + const msg = `Phase 5 lateral_hosts set but ${lp} missing/empty`; + if (strictSummary) fail(msg); + else warn(msg); + } + } + } + } + + // Lateral movement must stay in scope: a lateral_hosts entry the workspace's own + // scope.md denies is out-of-scope movement, regardless of access level. + if (phase5Summary && fs.exists('scope.md')) { + const scope = parseScope(fs.read('scope.md') || ''); + if (scope.deny.length) { + for (const h of toScopeList(phase5Summary.lateral_hosts)) { + if (evaluateHost(scope, h).denied) { + const msg = `Phase 5 lateral_hosts includes ${h} which scope.md denies (out-of-scope lateral movement)`; + if (strictSummary) fail(msg); + else warn(msg); + } + } + } + } + // Phase 4: if access not none and exploits claimed → exploit-log if (phase4Summary && fs.exists('manifest-phase-4.md')) { const level = String(phase4Summary.access_level || '').toLowerCase(); @@ -327,6 +418,18 @@ function validateWorkspace(schema, fs, opts = {}) { } } + // Activity log: surface commands the runtime guard blocked (out-of-scope / + // banned / out-of-window), so a review can confirm none were run out-of-band. + if (art.activity_log) { + const logPath = String(art.activity_log).replace(/\/$/, ''); + if (pathExists(logPath)) { + const denied = (String(fs.read(logPath) || '').match(/^\|[^\n]*\|\s*DENY\s*\|/gim) || []).length; + if (denied > 0) { + warn(`activity-log.md records ${denied} guard-DENY command(s) — confirm none were run out of scope/window`); + } + } + } + // Manifest-listed evidence paths should sit under known artifact roots (from schema). // Roots keep a trailing slash so startsWith can't over-match siblings (vulns-backup/…). const evidenceRoots = [ diff --git a/.agents/schema/lint-hotpaths.js b/.agents/schema/lint-hotpaths.js index c19be285..74db4134 100644 --- a/.agents/schema/lint-hotpaths.js +++ b/.agents/schema/lint-hotpaths.js @@ -46,10 +46,10 @@ for (const f of walk(phasesDir, (p) => p.endsWith('.md'))) { } } -// 2) playwright engagement docs +// 2) playwright engagement docs + PoC executor (no hardcoded evidence paths) for (const f of [ - path.join(repoRoot, '.agents/skills/playwright-skill/SKILL.md'), - path.join(repoRoot, '.agents/skills/playwright-skill/PATTERNS.md'), + path.join(repoRoot, '.agents/skills/pentester-playwright/SKILL.md'), + path.join(repoRoot, '.agents/skills/pentester-playwright/scripts/poc.js'), ]) { if (!fs.existsSync(f)) continue; const t = read(f); @@ -74,8 +74,8 @@ for (const e of validateAllPackets(schema).errors) { // 4) no resurrected thin skill CLIs for (const ghost of [ - '.agents/skills/gen-report/gen-report.js', - '.agents/skills/enum-services/enum-services.js', + '.agents/skills/pentester-gen-report/gen-report.js', + '.agents/skills/pentester-enum-services/enum-services.js', ]) { if (fs.existsSync(path.join(repoRoot, ghost))) { errors.push(`${ghost}: thin skill CLI must stay deleted (use Schema CLI)`); diff --git a/.agents/schema/scan-ingest.js b/.agents/schema/scan-ingest.js new file mode 100644 index 00000000..20c5b41d --- /dev/null +++ b/.agents/schema/scan-ingest.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Scan ingest CLI — read raw scanner output from vulns/scanner_raw/, normalize + * it into Raw scan result rows, and merge them idempotently into vulns/triage.md + * (new rows land as `verifying`; existing human dispositions are preserved). + * The engagement still runs the tools freehand; this only regularizes their + * output into the Triage ledger so nothing is dropped and re-runs are safe. + * + * node .agents/schema/scan-ingest.js # write triage.md + * node .agents/schema/scan-ingest.js --dry-run # preview only + * node .agents/schema/scan-ingest.js --fixture # golden fixture (dry-run unless --write) + */ + +const fs = require('fs'); +const path = require('path'); +const { resolveWorkspace } = require('./lib/workspace'); +const { ingestFiles, mergeIntoTriage } = require('./lib/scan-ingest'); + +const USAGE = 'Usage: node .agents/schema/scan-ingest.js [--dry-run] | --fixture [--write]'; +const { dir, useFixture, flags } = resolveWorkspace(process.argv.slice(2), { + usage: USAGE, + exitCode: 2, +}); + +// Never mutate the committed golden fixture unless explicitly told to. +const dryRun = flags['dry-run'] || (useFixture && !flags.write); + +const rawDir = path.join(dir, 'vulns', 'scanner_raw'); +if (!fs.existsSync(rawDir)) { + console.error('vulns/scanner_raw/ missing — run Phase 1/3 scans first (nothing to ingest)'); + process.exit(2); +} + +const files = []; +for (const name of fs.readdirSync(rawDir)) { + if (name === '.gitkeep' || name === '.DS_Store') continue; + const abs = path.join(rawDir, name); + if (!fs.statSync(abs).isFile()) continue; + files.push({ name, content: fs.readFileSync(abs, 'utf8') }); +} + +if (!files.length) { + console.log('No raw scan files under vulns/scanner_raw/ — nothing to ingest.'); + process.exit(0); +} + +const records = ingestFiles(files); +const triagePath = path.join(dir, 'vulns', 'triage.md'); +const existing = fs.existsSync(triagePath) ? fs.readFileSync(triagePath, 'utf8') : ''; +const { text, added, kept } = mergeIntoTriage(existing, records); + +console.log( + 'scan-ingest: ' + files.length + ' raw file(s) → ' + records.length + ' Raw scan result(s); ' + added + ' new row(s), ' + kept + ' existing row(s) preserved.' +); + +if (dryRun) { + console.log('(dry-run — vulns/triage.md not written; pass --write on --fixture, or drop --dry-run)'); + if (added) console.log('\n--- would write vulns/triage.md ---\n' + text); + process.exit(0); +} + +fs.writeFileSync(triagePath, text); +console.log('✅ wrote ' + path.relative(process.cwd(), triagePath) + ' — review new `verifying` rows and set dispositions.'); diff --git a/.agents/schema/scan-ingest.selftest.js b/.agents/schema/scan-ingest.selftest.js new file mode 100644 index 00000000..ac4fe753 --- /dev/null +++ b/.agents/schema/scan-ingest.selftest.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * scan-ingest selftest — pure, no FS writes. Guards the per-tool parsers, the + * dedup/severity merge, and (the load-bearing property) idempotent merge that + * preserves human dispositions. Runs against the committed golden fixture raw + * files so "works on toy, breaks on real" regressions surface in CI. + * Run: node .agents/schema/scan-ingest.selftest.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { + normalizeSeverity, + parseNmapXml, + parseNucleiJsonl, + parseHttpxJson, + parseFfufJson, + parseGobusterText, + parseNiktoText, + detectTool, + ingestFiles, + parseTriage, + countDispositions, + mergeIntoTriage, +} = require('./lib/scan-ingest'); + +// ── severity normalization ── +assert.strictEqual(normalizeSeverity('HIGH'), 'high'); +assert.strictEqual(normalizeSeverity('informational'), 'info'); +assert.strictEqual(normalizeSeverity(''), 'info'); +assert.strictEqual(normalizeSeverity('bogus'), 'info'); + +// ── nmap XML ── +const nmap = parseNmapXml( + '
' + + '' + + '' + + '' + + '