@kirrosh/zond 0.22.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +811 -0
- package/README.md +59 -6
- package/package.json +9 -7
- package/src/CLAUDE.md +112 -0
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +146 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +880 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +64 -0
- package/src/cli/commands/api/annotate/prompts.ts +220 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +786 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +361 -0
- package/src/cli/commands/checks.ts +1072 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +236 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +823 -132
- package/src/cli/commands/db.ts +486 -12
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1356 -0
- package/src/cli/commands/doctor.ts +661 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +438 -47
- package/src/cli/commands/init/bootstrap.ts +34 -2
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +99 -3
- package/src/cli/commands/init/templates/agents.md +77 -64
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
- package/src/cli/commands/init/templates/skills/zond.md +802 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +97 -0
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +594 -0
- package/src/cli/commands/probe/security.ts +537 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +163 -0
- package/src/cli/commands/probe.ts +535 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +227 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +310 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +495 -4
- package/src/cli/commands/run.ts +870 -53
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +18 -1
- package/src/cli/index.ts +20 -3
- package/src/cli/json-envelope.ts +92 -3
- package/src/cli/json-schemas.ts +314 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +199 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +114 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +147 -0
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +242 -0
- package/src/core/checks/checks/ignored_auth.ts +254 -0
- package/src/core/checks/checks/index.ts +68 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +148 -0
- package/src/core/checks/checks/not_a_server_error.ts +35 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
- package/src/core/checks/checks/pagination_invariants.ts +419 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +33 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +132 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +82 -0
- package/src/core/checks/recommended-action.ts +68 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +1461 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +305 -0
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +251 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +216 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +293 -59
- package/src/core/diagnostics/failure-class.ts +140 -0
- package/src/core/diagnostics/failure-hints.ts +88 -89
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +155 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +38 -19
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/data-factory.ts +586 -22
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +332 -0
- package/src/core/generator/index.ts +5 -5
- package/src/core/generator/openapi-reader.ts +135 -7
- package/src/core/generator/path-param-disambig.ts +140 -0
- package/src/core/generator/resources-builder.ts +898 -0
- package/src/core/generator/schema-utils.ts +33 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +583 -122
- package/src/core/generator/types.ts +14 -0
- package/src/core/identity/identity-file.ts +0 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +73 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +91 -0
- package/src/core/output/types.ts +122 -0
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +129 -4
- package/src/core/parser/types.ts +19 -1
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +58 -12
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +61 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +27 -0
- package/src/core/probe/mass-assignment-template.ts +240 -0
- package/src/core/probe/method-probe.ts +43 -76
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +183 -149
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +439 -0
- package/src/core/probe/probe-harness.ts +119 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +207 -0
- package/src/core/probe/security-probe.ts +32 -0
- package/src/core/probe/shared.ts +531 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +282 -0
- package/src/core/reporter/console.ts +41 -2
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +11 -1
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +59 -2
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +265 -36
- package/src/core/runner/form-encode.ts +41 -0
- package/src/core/runner/http-client.ts +112 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +153 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +87 -33
- package/src/core/runner/run-kind.ts +45 -0
- package/src/core/runner/schema-validator.ts +308 -0
- package/src/core/runner/send-request.ts +158 -20
- package/src/core/runner/types.ts +44 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +457 -20
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +58 -0
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +128 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +216 -0
- package/src/db/queries/runs.ts +289 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +75 -802
- package/src/db/schema.ts +178 -50
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
- package/src/cli/commands/probe-methods.ts +0 -108
- package/src/cli/commands/probe-validation.ts +0 -124
- package/src/cli/commands/serve.ts +0 -114
- package/src/cli/commands/sync.ts +0 -268
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/diagnostics/render-md.ts +0 -112
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -19
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- package/src/web/views/suites-tab.ts +0 -181
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live probe-runtime primitives shared by mass-assignment and security
|
|
3
|
+
* probes. TASK-185 (m-11) extracted the static spec/scaffold half into
|
|
4
|
+
* `runner.ts`; this module covers the per-endpoint primitives that
|
|
5
|
+
* were still duplicated between the two probe entry points.
|
|
6
|
+
*
|
|
7
|
+
* Scope: small, pure helpers — URL building, baseline body generation,
|
|
8
|
+
* JSON+auth header construction. Cleanup logic is intentionally NOT
|
|
9
|
+
* unified: mass-assignment uses fire-and-forget DELETE before attacks,
|
|
10
|
+
* security-probe uses snapshot/restore + retry-aware DELETE after
|
|
11
|
+
* attacks. Different invariants, different shapes — see TASK-189 notes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
|
|
15
|
+
import { generateFromSchema } from "../generator/data-factory.ts";
|
|
16
|
+
import { substituteDeep, substituteString } from "../parser/variables.ts";
|
|
17
|
+
import { convertPath, liveAuthHeaders } from "./shared.ts";
|
|
18
|
+
import { joinBaseAndPath } from "../util/url.ts";
|
|
19
|
+
import { encodeFormBody } from "../runner/form-encode.ts";
|
|
20
|
+
import type { SeedBodyConfig } from "../generator/resources-builder.ts";
|
|
21
|
+
|
|
22
|
+
/** ARV-150: form-encoded mutating endpoint (Stripe v1 pattern).
|
|
23
|
+
* Stripe and other Rails/PHP APIs declare requestBody.content with ONLY
|
|
24
|
+
* application/x-www-form-urlencoded — the probes previously skipped
|
|
25
|
+
* every such endpoint, masking real mass-assignment vectors. */
|
|
26
|
+
export function isFormBody(ep: EndpointInfo): boolean {
|
|
27
|
+
return (
|
|
28
|
+
ep.requestBodyContentType === "application/x-www-form-urlencoded"
|
|
29
|
+
&& ep.requestBodySchema !== undefined
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Probes can drive either application/json or application/x-www-form-urlencoded
|
|
34
|
+
* endpoints. Anything else (multipart, octet-stream, …) still gets skipped —
|
|
35
|
+
* no general way to construct attack payloads without a body schema. */
|
|
36
|
+
export function hasProbeBody(ep: EndpointInfo): boolean {
|
|
37
|
+
if (ep.method === "GET" || ep.method === "DELETE") return false;
|
|
38
|
+
if (!ep.requestBodySchema) return false;
|
|
39
|
+
return (
|
|
40
|
+
ep.requestBodyContentType === "application/json"
|
|
41
|
+
|| ep.requestBodyContentType === "application/x-www-form-urlencoded"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Serialise an attack body using whichever content type the endpoint
|
|
46
|
+
* declares. Returns the wire-format string + the Content-Type to set. */
|
|
47
|
+
export function serializeProbeBody(
|
|
48
|
+
ep: EndpointInfo,
|
|
49
|
+
body: Record<string, unknown>,
|
|
50
|
+
): { content: string; contentType: string } {
|
|
51
|
+
if (isFormBody(ep)) {
|
|
52
|
+
return { content: encodeFormBody(body), contentType: "application/x-www-form-urlencoded" };
|
|
53
|
+
}
|
|
54
|
+
return { content: JSON.stringify(body), contentType: "application/json" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve an endpoint's URL against the live `base_url` + path-param
|
|
59
|
+
* substitutions. Returns the resolved URL and any leftover `{{var}}`
|
|
60
|
+
* markers the caller couldn't fill — use those to skip the endpoint
|
|
61
|
+
* with a meaningful reason.
|
|
62
|
+
*/
|
|
63
|
+
export function buildProbeUrl(
|
|
64
|
+
ep: EndpointInfo,
|
|
65
|
+
vars: Record<string, string>,
|
|
66
|
+
): { url: string; unresolved: string[] } {
|
|
67
|
+
const templated = joinBaseAndPath(vars["base_url"], convertPath(ep.path));
|
|
68
|
+
const url = String(substituteString(templated, vars));
|
|
69
|
+
const unresolved = Array.from(url.matchAll(/\{\{([^}]+)\}\}/g)).map(m => m[1]!);
|
|
70
|
+
return { url, unresolved };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Standard probe headers: content-type from the endpoint's spec
|
|
75
|
+
* (form-urlencoded for Stripe v1, JSON otherwise) plus the resolved
|
|
76
|
+
* auth header for the endpoint. Accept stays JSON — the server still
|
|
77
|
+
* answers in JSON even when the body is form-encoded. Empty
|
|
78
|
+
* `liveAuthHeaders` is fine — the spread is a no-op for unauthenticated
|
|
79
|
+
* endpoints.
|
|
80
|
+
*/
|
|
81
|
+
export function buildBodyAuthHeaders(
|
|
82
|
+
ep: EndpointInfo,
|
|
83
|
+
schemes: SecuritySchemeInfo[],
|
|
84
|
+
vars: Record<string, string>,
|
|
85
|
+
): Record<string, string> {
|
|
86
|
+
const ct = isFormBody(ep) ? "application/x-www-form-urlencoded" : "application/json";
|
|
87
|
+
return {
|
|
88
|
+
"content-type": ct,
|
|
89
|
+
accept: "application/json",
|
|
90
|
+
...liveAuthHeaders(ep, schemes, vars),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Synthesize a baseline body from the endpoint's request schema and
|
|
96
|
+
* substitute live vars. Returns null when the result isn't a JSON
|
|
97
|
+
* object (array / scalar / null) — both probes treat that as a skip
|
|
98
|
+
* reason ("request body not a JSON object").
|
|
99
|
+
*
|
|
100
|
+
* ARV-269: when `seedBody` is provided (agent-authored overlay from
|
|
101
|
+
* `.api-resources.local.yaml`), it wins over `generateFromSchema`.
|
|
102
|
+
* Strict-validating APIs (Stripe required-field XORs, `expand[]` arrays)
|
|
103
|
+
* reject random scalars from the generator and the whole verdict becomes
|
|
104
|
+
* INCONCLUSIVE-baseline; the overlay carries a payload the API actually
|
|
105
|
+
* accepts. Mirrors the path stateful checks took via `resolveCreateBody`
|
|
106
|
+
* (`core/checks/checks/_crud-helpers.ts`).
|
|
107
|
+
*/
|
|
108
|
+
export function buildBaselineFromSpec(
|
|
109
|
+
ep: EndpointInfo,
|
|
110
|
+
vars: Record<string, string>,
|
|
111
|
+
seedBody?: SeedBodyConfig,
|
|
112
|
+
): Record<string, unknown> | null {
|
|
113
|
+
const raw = seedBody && seedBody.body && typeof seedBody.body === "object"
|
|
114
|
+
? seedBody.body
|
|
115
|
+
: (ep.requestBodySchema ? generateFromSchema(ep.requestBodySchema) : {});
|
|
116
|
+
const sub = substituteDeep(raw, vars);
|
|
117
|
+
if (typeof sub !== "object" || sub === null || Array.isArray(sub)) return null;
|
|
118
|
+
return sub as Record<string, unknown>;
|
|
119
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe registry + boot-time validator (m-17 / ARV-49).
|
|
3
|
+
*
|
|
4
|
+
* The CLI bootstrap calls `bootstrapProbes()` exactly once — that
|
|
5
|
+
* function imports each registered probe class and pushes it through
|
|
6
|
+
* `registerProbe`, which throws if the contract from `types.ts` is
|
|
7
|
+
* not fully implemented. Boot fails loud with a list of missing slots,
|
|
8
|
+
* so adding a new probe class without --dry-run / --report support is
|
|
9
|
+
* impossible (replaces the conventions-then-drift status quo that
|
|
10
|
+
* produced F1-15 / F2-15 / F3-15 in feedback round 15).
|
|
11
|
+
*/
|
|
12
|
+
import type { Probe, ProbeFlags } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
const REQUIRED_METHODS: Array<keyof Probe> = ["dryRun", "run", "report"];
|
|
15
|
+
const REQUIRED_FLAGS: Array<keyof ProbeFlags> = [
|
|
16
|
+
"api",
|
|
17
|
+
"tag",
|
|
18
|
+
"include",
|
|
19
|
+
"exclude",
|
|
20
|
+
"dryRun",
|
|
21
|
+
"listTags",
|
|
22
|
+
"json",
|
|
23
|
+
"output",
|
|
24
|
+
"report",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export interface ValidationResult {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
errors: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Pure validator — used by both `registerProbe` and the contract test
|
|
33
|
+
* in `tests/contracts/probe-interface.test.ts`. */
|
|
34
|
+
export function validateProbe(p: unknown): ValidationResult {
|
|
35
|
+
const errors: string[] = [];
|
|
36
|
+
if (p === null || typeof p !== "object") {
|
|
37
|
+
return { ok: false, errors: ["Probe is not an object"] };
|
|
38
|
+
}
|
|
39
|
+
const probe = p as Partial<Probe>;
|
|
40
|
+
const label = probe.name ?? "<anonymous>";
|
|
41
|
+
if (typeof probe.name !== "string" || probe.name.length === 0) {
|
|
42
|
+
errors.push("Probe is missing required field name");
|
|
43
|
+
}
|
|
44
|
+
if (typeof probe.description !== "string" || probe.description.length === 0) {
|
|
45
|
+
errors.push(`Probe "${label}" is missing required field description`);
|
|
46
|
+
}
|
|
47
|
+
for (const m of REQUIRED_METHODS) {
|
|
48
|
+
if (typeof (probe as Record<string, unknown>)[m] !== "function") {
|
|
49
|
+
errors.push(`Probe "${label}" is missing required method ${m}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (probe.commonFlags === undefined || probe.commonFlags === null || typeof probe.commonFlags !== "object") {
|
|
53
|
+
errors.push(`Probe "${label}" is missing required field commonFlags`);
|
|
54
|
+
} else {
|
|
55
|
+
const flags = probe.commonFlags as unknown as Record<string, unknown>;
|
|
56
|
+
for (const f of REQUIRED_FLAGS) {
|
|
57
|
+
const v = flags[f as string];
|
|
58
|
+
if (typeof v !== "boolean") {
|
|
59
|
+
errors.push(`Probe "${label}" commonFlags is missing slot ${f} (must be boolean)`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { ok: errors.length === 0, errors };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PROBES = new Map<string, Probe>();
|
|
67
|
+
|
|
68
|
+
export function registerProbe(probe: Probe): void {
|
|
69
|
+
const r = validateProbe(probe);
|
|
70
|
+
if (!r.ok) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Invalid probe registration:\n - ${r.errors.join("\n - ")}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (PROBES.has(probe.name)) {
|
|
76
|
+
throw new Error(`Probe "${probe.name}" is already registered`);
|
|
77
|
+
}
|
|
78
|
+
PROBES.set(probe.name, probe);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function listProbes(): readonly Probe[] {
|
|
82
|
+
return Array.from(PROBES.values());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Test helper — wipes the registry between unit tests. NOT exported
|
|
86
|
+
* through `index.ts`; tests import this module directly. */
|
|
87
|
+
export function clearProbes(): void {
|
|
88
|
+
PROBES.clear();
|
|
89
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared scaffolding for the four probe commands (`probe validation`,
|
|
3
|
+
* `probe methods`, `probe mass-assignment`, `probe security`).
|
|
4
|
+
*
|
|
5
|
+
* Each command historically duplicated the same boilerplate: read the
|
|
6
|
+
* spec, optionally filter by tag, mkdir the output dir, write the
|
|
7
|
+
* generated suites with an autogen header, and record them in the
|
|
8
|
+
* workspace manifest. This module collapses that scaffolding into two
|
|
9
|
+
* helpers (`loadSpecForProbe` / `writeProbeSuites`) so the cli layer
|
|
10
|
+
* stays thin.
|
|
11
|
+
*
|
|
12
|
+
* Live-runner commands (`mass-assignment`, `security`) reuse the
|
|
13
|
+
* write-suites half for their `--emit-tests` flag; the actual HTTP
|
|
14
|
+
* orchestration lives in `mass-assignment-probe.ts` /
|
|
15
|
+
* `security-probe.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Goals are documented in TASK-185 (m-11).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdir } from "node:fs/promises";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import {
|
|
23
|
+
readOpenApiSpec,
|
|
24
|
+
extractEndpoints,
|
|
25
|
+
extractSecuritySchemes,
|
|
26
|
+
serializeSuite,
|
|
27
|
+
} from "../generator/index.ts";
|
|
28
|
+
import type { EndpointInfo, SecuritySchemeInfo, RawSuite } from "../generator/index.ts";
|
|
29
|
+
import { collectTags, filterByTag } from "../generator/chunker.ts";
|
|
30
|
+
import {
|
|
31
|
+
recordGeneratedFiles,
|
|
32
|
+
inferApiName,
|
|
33
|
+
autoGenHeader,
|
|
34
|
+
type RecordInput,
|
|
35
|
+
} from "../workspace/manifest.ts";
|
|
36
|
+
import { findWorkspaceRoot } from "../workspace/root.ts";
|
|
37
|
+
|
|
38
|
+
export interface LoadSpecForProbeOptions {
|
|
39
|
+
specPath: string;
|
|
40
|
+
tag?: string;
|
|
41
|
+
/** When true, the caller wants the available-tags list, not endpoints. */
|
|
42
|
+
listTags?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type LoadSpecResult =
|
|
46
|
+
| { kind: "endpoints"; endpoints: EndpointInfo[]; securitySchemes: SecuritySchemeInfo[] }
|
|
47
|
+
| { kind: "tags"; tags: string[] }
|
|
48
|
+
| { kind: "tag-not-found"; tag: string; available: string[] };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read the spec, optionally filter by tag, and surface either the
|
|
52
|
+
* filtered endpoints or the list of available tags. Centralises the
|
|
53
|
+
* tag-not-found error path so probe commands all produce identical
|
|
54
|
+
* messaging.
|
|
55
|
+
*/
|
|
56
|
+
export async function loadSpecForProbe(opts: LoadSpecForProbeOptions): Promise<LoadSpecResult> {
|
|
57
|
+
const doc = await readOpenApiSpec(opts.specPath);
|
|
58
|
+
const allEndpoints = extractEndpoints(doc);
|
|
59
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
60
|
+
|
|
61
|
+
if (opts.listTags) {
|
|
62
|
+
return { kind: "tags", tags: collectTags(allEndpoints) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (opts.tag) {
|
|
66
|
+
const filtered = filterByTag(allEndpoints, opts.tag);
|
|
67
|
+
if (filtered.length === 0) {
|
|
68
|
+
return { kind: "tag-not-found", tag: opts.tag, available: collectTags(allEndpoints) };
|
|
69
|
+
}
|
|
70
|
+
return { kind: "endpoints", endpoints: filtered, securitySchemes };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { kind: "endpoints", endpoints: allEndpoints, securitySchemes };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface WriteProbeSuitesOptions {
|
|
77
|
+
/** Directory to write suites into. Created recursively if missing. */
|
|
78
|
+
output: string;
|
|
79
|
+
/** Suites produced by the underlying probe generator. */
|
|
80
|
+
suites: RawSuite[];
|
|
81
|
+
/** Manifest `by` field — e.g. `"zond probe-methods --emit"`. */
|
|
82
|
+
command: string;
|
|
83
|
+
/** First arg of `autoGenHeader` (label). Defaults to `command`. */
|
|
84
|
+
headerLabel?: string;
|
|
85
|
+
/** Concrete repro command shown in the autogen header. */
|
|
86
|
+
headerExample?: string;
|
|
87
|
+
/** Manifest category (defaults to `"probes"`). */
|
|
88
|
+
category?: RecordInput["category"];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface WroteProbeSuites {
|
|
92
|
+
files: Array<{ file: string; suite: string; tests: number }>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Materialise generator output to disk and register the files in the
|
|
97
|
+
* workspace manifest. Safe on empty input — returns an empty result and
|
|
98
|
+
* avoids creating an empty directory (m-9 P5).
|
|
99
|
+
*/
|
|
100
|
+
export async function writeProbeSuites(
|
|
101
|
+
opts: WriteProbeSuitesOptions,
|
|
102
|
+
): Promise<WroteProbeSuites> {
|
|
103
|
+
if (opts.suites.length === 0) return { files: [] };
|
|
104
|
+
|
|
105
|
+
await mkdir(opts.output, { recursive: true });
|
|
106
|
+
|
|
107
|
+
const files: WroteProbeSuites["files"] = [];
|
|
108
|
+
const manifestEntries: RecordInput[] = [];
|
|
109
|
+
const inferredApi = inferApiName(opts.output);
|
|
110
|
+
const headerLabel = opts.headerLabel ?? opts.command;
|
|
111
|
+
const headerExample = opts.headerExample ?? opts.command;
|
|
112
|
+
|
|
113
|
+
for (const suite of opts.suites) {
|
|
114
|
+
const fileName = `${suite.fileStem ?? suite.name}.yaml`;
|
|
115
|
+
const filePath = join(opts.output, fileName);
|
|
116
|
+
await Bun.write(filePath, autoGenHeader(headerLabel, headerExample) + serializeSuite(suite));
|
|
117
|
+
files.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
|
|
118
|
+
manifestEntries.push({
|
|
119
|
+
path: filePath,
|
|
120
|
+
by: opts.command,
|
|
121
|
+
api: inferredApi,
|
|
122
|
+
category: opts.category ?? "probes",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const ws = findWorkspaceRoot();
|
|
128
|
+
if (!ws.fromFallback && manifestEntries.length > 0) {
|
|
129
|
+
recordGeneratedFiles(ws.root, manifestEntries);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
/* best-effort: manifest is observability, never fail probe emit on it */
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { files };
|
|
136
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../../generator/types.ts";
|
|
2
|
+
import { executeRequest } from "../../runner/http-client.ts";
|
|
3
|
+
import { findGetByIdCounterpart, liveAuthHeaders } from "../shared.ts";
|
|
4
|
+
import { buildProbeUrl, serializeProbeBody } from "../probe-harness.ts";
|
|
5
|
+
import type { ProbeStepOpts, SecurityVerdict } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export interface Snapshot {
|
|
8
|
+
/** Original GET-response body, used to restore state via PUT/PATCH. */
|
|
9
|
+
body: Record<string, unknown>;
|
|
10
|
+
/** ETag (if API uses optimistic locking) — sent back as `If-Match` on restore. */
|
|
11
|
+
etag?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type BaselineResult =
|
|
15
|
+
| { kind: "ok"; status: number; body: unknown; headers: Record<string, string> }
|
|
16
|
+
| { kind: "network"; reason: string };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Baseline send — wraps executeRequest with shape that distinguishes a real
|
|
20
|
+
* HTTP response from a network error (so the caller can decide whether to
|
|
21
|
+
* retry partial-body / mark the endpoint unreachable).
|
|
22
|
+
*/
|
|
23
|
+
export async function sendBaseline(
|
|
24
|
+
ep: EndpointInfo,
|
|
25
|
+
method: string,
|
|
26
|
+
url: string,
|
|
27
|
+
headers: Record<string, string>,
|
|
28
|
+
body: unknown,
|
|
29
|
+
opts: ProbeStepOpts,
|
|
30
|
+
): Promise<BaselineResult> {
|
|
31
|
+
try {
|
|
32
|
+
// ARV-161: serialize via serializeProbeBody so form-encoded endpoints
|
|
33
|
+
// get x-www-form-urlencoded payload matching Content-Type.
|
|
34
|
+
const wire = body && typeof body === "object" && !Array.isArray(body)
|
|
35
|
+
? serializeProbeBody(ep, body as Record<string, unknown>).content
|
|
36
|
+
: JSON.stringify(body);
|
|
37
|
+
const resp = await executeRequest(
|
|
38
|
+
{ method, url, headers, body: wire },
|
|
39
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
40
|
+
);
|
|
41
|
+
return {
|
|
42
|
+
kind: "ok",
|
|
43
|
+
status: resp.status,
|
|
44
|
+
body: resp.body_parsed ?? resp.body,
|
|
45
|
+
headers: resp.headers ?? {},
|
|
46
|
+
};
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return {
|
|
49
|
+
kind: "network",
|
|
50
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* TASK-151: snapshot original state via GET-by-id counterpart so PUT/PATCH
|
|
57
|
+
* attacks can be undone. Returns null when there's no usable counterpart
|
|
58
|
+
* or the response isn't a JSON object.
|
|
59
|
+
*/
|
|
60
|
+
export async function snapshotOriginal(
|
|
61
|
+
ep: EndpointInfo,
|
|
62
|
+
allEndpoints: EndpointInfo[],
|
|
63
|
+
schemes: SecuritySchemeInfo[],
|
|
64
|
+
vars: Record<string, string>,
|
|
65
|
+
opts: ProbeStepOpts,
|
|
66
|
+
): Promise<Snapshot | null> {
|
|
67
|
+
const getEp = findGetByIdCounterpart(ep, allEndpoints);
|
|
68
|
+
if (!getEp) return null;
|
|
69
|
+
const { url, unresolved } = buildProbeUrl(getEp, vars);
|
|
70
|
+
if (unresolved.length > 0) return null;
|
|
71
|
+
const reqHeaders: Record<string, string> = {
|
|
72
|
+
accept: "application/json",
|
|
73
|
+
...liveAuthHeaders(getEp, schemes, vars),
|
|
74
|
+
};
|
|
75
|
+
let resp;
|
|
76
|
+
try {
|
|
77
|
+
resp = await executeRequest(
|
|
78
|
+
{ method: "GET", url, headers: reqHeaders },
|
|
79
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
80
|
+
);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (resp.status < 200 || resp.status >= 300) return null;
|
|
85
|
+
const body = resp.body_parsed ?? resp.body;
|
|
86
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
87
|
+
|
|
88
|
+
const respHeaders = resp.headers ?? {};
|
|
89
|
+
const etag =
|
|
90
|
+
respHeaders["etag"] ??
|
|
91
|
+
respHeaders["ETag"] ??
|
|
92
|
+
respHeaders["Etag"];
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
body: body as Record<string, unknown>,
|
|
96
|
+
etag: typeof etag === "string" ? etag : undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Restore the original state captured by `snapshotOriginal`. Sends a
|
|
102
|
+
* minimal PUT/PATCH containing only the fields the probe mutated —
|
|
103
|
+
* sending the full snapshot body trips `422 use partial PUT` on
|
|
104
|
+
* SaaS-shaped APIs (round-4 regression), so we replay each
|
|
105
|
+
* dirty field as its own single-key request.
|
|
106
|
+
*
|
|
107
|
+
* `verdict.cleanup.error` is **accumulated** across calls (not
|
|
108
|
+
* overwritten) so a single restore failure during the run is still
|
|
109
|
+
* visible in the digest.
|
|
110
|
+
*/
|
|
111
|
+
export async function restoreOriginal(
|
|
112
|
+
ep: EndpointInfo,
|
|
113
|
+
snapshot: Snapshot,
|
|
114
|
+
baseHeaders: Record<string, string>,
|
|
115
|
+
_schemes: SecuritySchemeInfo[],
|
|
116
|
+
vars: Record<string, string>,
|
|
117
|
+
opts: ProbeStepOpts,
|
|
118
|
+
verdict: SecurityVerdict,
|
|
119
|
+
dirtyFields: Iterable<string>,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const m = ep.method.toUpperCase();
|
|
122
|
+
const { url, unresolved } = buildProbeUrl(ep, vars);
|
|
123
|
+
if (unresolved.length > 0) return;
|
|
124
|
+
const headers: Record<string, string> = { ...baseHeaders };
|
|
125
|
+
if (snapshot.etag && ep.requiresEtag) {
|
|
126
|
+
headers["If-Match"] = snapshot.etag;
|
|
127
|
+
}
|
|
128
|
+
// Filter out fields the API will reject as read-only.
|
|
129
|
+
const READ_ONLY = new Set([
|
|
130
|
+
"id", "created_at", "createdAt", "updated_at", "updatedAt",
|
|
131
|
+
]);
|
|
132
|
+
const fields = Array.from(new Set(Array.from(dirtyFields))).filter(
|
|
133
|
+
f => !READ_ONLY.has(f) && f in snapshot.body,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Per-field PUT — works for both partial-PUT APIs and
|
|
137
|
+
// full-PUT APIs (the body just carries one of the legal keys).
|
|
138
|
+
const failures: string[] = [];
|
|
139
|
+
let lastSuccessStatus = 0;
|
|
140
|
+
let attempted = false;
|
|
141
|
+
for (const field of fields) {
|
|
142
|
+
attempted = true;
|
|
143
|
+
const body: Record<string, unknown> = { [field]: snapshot.body[field] };
|
|
144
|
+
let resp;
|
|
145
|
+
try {
|
|
146
|
+
resp = await executeRequest(
|
|
147
|
+
{ method: m, url, headers, body: serializeProbeBody(ep, body).content },
|
|
148
|
+
{ timeout: opts.timeoutMs ?? 30000, retries: 0 },
|
|
149
|
+
);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
failures.push(
|
|
152
|
+
`restore.${field} network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
153
|
+
);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
157
|
+
failures.push(`restore.${field} failed: ${resp.status}`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
lastSuccessStatus = resp.status;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Merge with any prior cleanup state on this verdict.
|
|
164
|
+
const prior = verdict.cleanup ?? { attempted: false };
|
|
165
|
+
const allErrors = [
|
|
166
|
+
...(prior.error ? [prior.error] : []),
|
|
167
|
+
...failures,
|
|
168
|
+
];
|
|
169
|
+
verdict.cleanup = {
|
|
170
|
+
attempted: attempted || prior.attempted,
|
|
171
|
+
...(lastSuccessStatus ? { status: lastSuccessStatus } : prior.status ? { status: prior.status } : {}),
|
|
172
|
+
...(allErrors.length > 0 ? { error: allErrors.join(" | ") } : {}),
|
|
173
|
+
};
|
|
174
|
+
}
|