@kirrosh/zond 0.21.0 → 0.23.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 +758 -3
- package/README.md +78 -15
- package/package.json +17 -10
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -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 +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -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 +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +55 -6
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +192 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +180 -8
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -47
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +108 -0
- package/src/cli/commands/init/index.ts +244 -0
- package/src/cli/commands/init/skills.ts +98 -0
- package/src/cli/commands/init/templates/agents.md +77 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +651 -0
- package/src/cli/commands/init/templates/zond-config.yml +14 -0
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +911 -33
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +74 -0
- package/src/cli/index.ts +36 -607
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +218 -0
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +8 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -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 +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -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 +61 -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 +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +51 -0
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +161 -12
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -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 +53 -15
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +490 -33
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +55 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +114 -15
- package/src/core/generator/suite-generator.ts +484 -77
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -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 +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +132 -5
- package/src/core/parser/types.ts +29 -2
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +108 -13
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -0
- package/src/core/probe/method-probe.ts +164 -0
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +691 -0
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -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 +284 -0
- package/src/core/reporter/console.ts +69 -4
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +15 -2
- 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 +62 -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 +391 -52
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +115 -7
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +203 -0
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -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 +419 -17
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +94 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -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 +128 -0
- package/src/db/queries/runs.ts +235 -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 +72 -802
- package/src/db/schema.ts +179 -48
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init.ts +0 -57
- package/src/cli/commands/serve.ts +0 -81
- package/src/cli/commands/sync.ts +0 -269
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -21
- 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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe contract (m-17 / ARV-49).
|
|
3
|
+
*
|
|
4
|
+
* `zond probe <class>` originally grew as three independent commands —
|
|
5
|
+
* static, mass-assignment, security — that ad-hoc-агree on flags and
|
|
6
|
+
* output shape. The agent-readable contract started drifting (security
|
|
7
|
+
* has --dry-run, mass-assignment doesn't; security --json packages
|
|
8
|
+
* markdown into `data.digest.stdout`, run --report json returns
|
|
9
|
+
* structured per-endpoint findings; ARV-9 AC#6 deferred --include/--exclude
|
|
10
|
+
* for the probe family). m-17 raises this from "convention" to
|
|
11
|
+
* "TS-interface validated at boot".
|
|
12
|
+
*
|
|
13
|
+
* `Probe` is the contract every registered probe class MUST satisfy.
|
|
14
|
+
* `commonFlags` is a declarative slot table — the harness uses it both
|
|
15
|
+
* for boot-validation (registry refuses to start if a slot is missing)
|
|
16
|
+
* and for help/feature-detection. dry-run and run return DIFFERENT
|
|
17
|
+
* shapes on purpose (ARV-50): dry-run answers "what would I attack",
|
|
18
|
+
* run answers "what did I find". Severity is undefined in dry-run.
|
|
19
|
+
*/
|
|
20
|
+
import type { EndpointInfo, SecuritySchemeInfo } from "../generator/types.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Common-flag manifest. Boot-validator checks each registered probe
|
|
24
|
+
* declares every slot — `true` means "this probe's CLI exposes the
|
|
25
|
+
* flag", `false` means "intentionally not supported". We don't allow
|
|
26
|
+
* undefined: forcing a boolean makes "we forgot to wire it" obvious in
|
|
27
|
+
* code review (ARV-9 AC#6, F2-15).
|
|
28
|
+
*/
|
|
29
|
+
export interface ProbeFlags {
|
|
30
|
+
/** `--api <name>` */
|
|
31
|
+
api: boolean;
|
|
32
|
+
/** `--tag <tag>` */
|
|
33
|
+
tag: boolean;
|
|
34
|
+
/** Repeatable `--include <selector:value>` (m-15 ARV-9 grammar). */
|
|
35
|
+
include: boolean;
|
|
36
|
+
/** Repeatable `--exclude <selector:value>`. */
|
|
37
|
+
exclude: boolean;
|
|
38
|
+
/** `--dry-run` — list planned attacks without sending requests. */
|
|
39
|
+
dryRun: boolean;
|
|
40
|
+
/** `--list-tags` — print spec tags and exit. */
|
|
41
|
+
listTags: boolean;
|
|
42
|
+
/** `--json` — emit single JSON envelope on stdout. */
|
|
43
|
+
json: boolean;
|
|
44
|
+
/** `--output <file>` — write markdown / SARIF digest to file. */
|
|
45
|
+
output: boolean;
|
|
46
|
+
/** `--report <markdown|json|sarif>` — choose the structured report
|
|
47
|
+
* format (m-17 ARV-51). */
|
|
48
|
+
report: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Per-endpoint dry-run record. Returned from `Probe.dryRun()`. Severity
|
|
53
|
+
* is intentionally absent: nothing has been classified yet (ARV-50).
|
|
54
|
+
*
|
|
55
|
+
* `planned: true` means the probe would send live traffic at this
|
|
56
|
+
* endpoint; `planned: false` + `skip_reason` means we identified the
|
|
57
|
+
* endpoint but won't probe it (no body, isolated path-param, …).
|
|
58
|
+
*/
|
|
59
|
+
export interface EndpointPlan {
|
|
60
|
+
path: string;
|
|
61
|
+
method: string;
|
|
62
|
+
planned: boolean;
|
|
63
|
+
/** Probe-class IDs we'd run (e.g. ["ssrf","crlf"] or ["mass-assignment"]). */
|
|
64
|
+
classes_planned: string[];
|
|
65
|
+
/** Suspect fields the probe would touch (mass-assignment / security only). */
|
|
66
|
+
fields_planned: string[];
|
|
67
|
+
/** Null when planned, populated when planned:false. Closed string set
|
|
68
|
+
* per-probe (security: 'no-body'|'no-matched-field'|'isolated-protected'|
|
|
69
|
+
* 'unresolved-path'; mass-assignment: 'no-body'|'isolated-protected'). */
|
|
70
|
+
skip_reason: string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Severity classifier outcome for a finding. `inconclusive` covers both
|
|
75
|
+
* baseline-failure and 5xx-on-attack — sub-classes carry the detail in
|
|
76
|
+
* `evidence`. Mirrors the existing union in security-probe.ts so we
|
|
77
|
+
* don't double-up on enums.
|
|
78
|
+
*/
|
|
79
|
+
export type ProbeFindingSeverity =
|
|
80
|
+
| "high"
|
|
81
|
+
| "low"
|
|
82
|
+
| "inconclusive"
|
|
83
|
+
| "ok";
|
|
84
|
+
|
|
85
|
+
export interface ProbeFinding {
|
|
86
|
+
/** Probe-class id (e.g. "ssrf", "open-redirect", "mass-assignment"). */
|
|
87
|
+
class: string;
|
|
88
|
+
severity: ProbeFindingSeverity;
|
|
89
|
+
/** Free-form evidence: request signature, response signature, baseline
|
|
90
|
+
* diff, etc. Schema is per-probe, but stays structured (no markdown). */
|
|
91
|
+
evidence: Record<string, unknown>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type ProbeEndpointStatus = "ok" | "high" | "low" | "inconclusive" | "skipped";
|
|
95
|
+
|
|
96
|
+
export interface ProbeEndpointResult {
|
|
97
|
+
path: string;
|
|
98
|
+
method: string;
|
|
99
|
+
/** Probe classes that actually ran on this endpoint. */
|
|
100
|
+
classes_run: string[];
|
|
101
|
+
findings: ProbeFinding[];
|
|
102
|
+
status: ProbeEndpointStatus;
|
|
103
|
+
skip_reason?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ProbeRunSummary {
|
|
107
|
+
totalEndpoints: number;
|
|
108
|
+
probed: number;
|
|
109
|
+
/** Per-status tally; identical to existing severity buckets, but with
|
|
110
|
+
* closed shape (ARV-51). */
|
|
111
|
+
by_status: Record<ProbeEndpointStatus, number>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Result of a live `Probe.run()`. `endpoints[]` is the agent-routable
|
|
116
|
+
* structure; markdown digest is rendered separately by `Probe.report()`.
|
|
117
|
+
*/
|
|
118
|
+
export interface ProbeResult {
|
|
119
|
+
endpoints: ProbeEndpointResult[];
|
|
120
|
+
summary: ProbeRunSummary;
|
|
121
|
+
warnings: string[];
|
|
122
|
+
/** Optional probe-specific extras (e.g. orphans, emittedTests) that
|
|
123
|
+
* don't fit the per-endpoint shape but agents still need access to. */
|
|
124
|
+
extras?: Record<string, unknown>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type ProbeReportFormat = "markdown" | "json";
|
|
128
|
+
|
|
129
|
+
export interface ProbeContext {
|
|
130
|
+
specPath: string;
|
|
131
|
+
/** Pre-loaded endpoints (probe harness loads spec once and shares). */
|
|
132
|
+
endpoints: EndpointInfo[];
|
|
133
|
+
securitySchemes: SecuritySchemeInfo[];
|
|
134
|
+
/** Resolved env vars (`base_url`, `auth_token`, fixture vars). Empty
|
|
135
|
+
* for dry-run when the env file is absent. */
|
|
136
|
+
vars: Record<string, string>;
|
|
137
|
+
/** Selector strings (m-15 ARV-9 grammar: `path:`, `method:`, `tag:`,
|
|
138
|
+
* `operation-id:`). Pre-applied to `endpoints` before this context
|
|
139
|
+
* reaches the probe. Carried for diagnostics. */
|
|
140
|
+
filter?: { includes: string[]; excludes: string[] };
|
|
141
|
+
/** Probe-class subset (e.g. for security: ["ssrf","crlf"]). */
|
|
142
|
+
classes?: string[];
|
|
143
|
+
/** Probe-specific options bag — kept opaque so the harness doesn't
|
|
144
|
+
* need to know each probe's flag inventory. */
|
|
145
|
+
options: Record<string, unknown>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The contract. Every registered probe MUST implement all four
|
|
150
|
+
* required methods; missing one trips boot-validation in
|
|
151
|
+
* `registry.ts`. listTags is optional — most probes share the same
|
|
152
|
+
* loadSpecForProbe shortcut via the harness, so we don't force it.
|
|
153
|
+
*/
|
|
154
|
+
export interface Probe {
|
|
155
|
+
readonly name: string;
|
|
156
|
+
readonly description: string;
|
|
157
|
+
readonly commonFlags: ProbeFlags;
|
|
158
|
+
/** List endpoints + classes the probe would attack (no live traffic). */
|
|
159
|
+
dryRun(ctx: ProbeContext): Promise<EndpointPlan[]>;
|
|
160
|
+
/** Run the probe live and return structured per-endpoint findings. */
|
|
161
|
+
run(ctx: ProbeContext): Promise<ProbeResult>;
|
|
162
|
+
/** Render a structured (json) or human (markdown) digest. */
|
|
163
|
+
report(format: ProbeReportFormat, result: ProbeResult): string | object;
|
|
164
|
+
}
|
|
165
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tally verdicts by severity into a caller-shaped bucket object and
|
|
3
|
+
* format a one-line summary. Extracted from probe-mass-assignment and
|
|
4
|
+
* probe-security CLI commands which had near-identical countBuckets +
|
|
5
|
+
* Summary printers — they differ only in the severity vocabulary
|
|
6
|
+
* ("medium" vs "inconclusive") and the label/order of the summary line.
|
|
7
|
+
*
|
|
8
|
+
* Generic over the bucket shape so each command keeps its own JSON-
|
|
9
|
+
* envelope keys (camelCase) without rewriting the rest of the pipeline.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function tallyBySeverity<T extends object>(
|
|
13
|
+
verdicts: ReadonlyArray<{ severity: string }>,
|
|
14
|
+
mapping: ReadonlyArray<readonly [severity: string, bucket: keyof T & string]>,
|
|
15
|
+
zero: T,
|
|
16
|
+
): T {
|
|
17
|
+
const out: Record<string, number> = { ...(zero as Record<string, number>) };
|
|
18
|
+
const lookup = new Map<string, string>(mapping);
|
|
19
|
+
for (const v of verdicts) {
|
|
20
|
+
const bucket = lookup.get(v.severity);
|
|
21
|
+
if (bucket !== undefined && bucket in out) out[bucket]! += 1;
|
|
22
|
+
}
|
|
23
|
+
return out as T;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatSummaryLine<T extends object>(
|
|
27
|
+
counts: T,
|
|
28
|
+
pairs: ReadonlyArray<readonly [label: string, bucket: keyof T & string]>,
|
|
29
|
+
): string {
|
|
30
|
+
const indexed = counts as Record<string, number>;
|
|
31
|
+
const parts = pairs.map(([label, key]) => `${label} ${indexed[key] ?? 0}`);
|
|
32
|
+
return `Summary: ${parts.join(" · ")}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `zond probe webhooks` (m-20 ARV-173) — webhook shape-conformance.
|
|
3
|
+
*
|
|
4
|
+
* The probe is **offline**: it reads an ndjson event log captured by
|
|
5
|
+
* the recipe (`docs/recipes/webhook-receiver.md`, e.g. via
|
|
6
|
+
* `stripe listen --print-json`) and validates each event payload
|
|
7
|
+
* against the schema declared in `spec.webhooks.<event>.post.requestBody`.
|
|
8
|
+
*
|
|
9
|
+
* Why offline? m-20 explicitly puts live HTTP infrastructure (tunnels,
|
|
10
|
+
* port binding, receiver servers) in recipes, not core zond:
|
|
11
|
+
*
|
|
12
|
+
* • A live receiver requires a public URL — that's a recipe concern
|
|
13
|
+
* (Stripe CLI, ngrok, smee.io are all out-of-band).
|
|
14
|
+
* • Capture is bursty (events trickle in seconds-to-minutes after a
|
|
15
|
+
* trigger); the probe can't reliably wait for them inside a CLI
|
|
16
|
+
* invocation without nasty timeout knobs.
|
|
17
|
+
* • Decoupling capture from verification lets the same probe run
|
|
18
|
+
* against logs from prod tap, mitm-proxy dumps, CI artifacts, etc.
|
|
19
|
+
*
|
|
20
|
+
* Recipe captures; probe verifies. Same pattern as quicktype (capture
|
|
21
|
+
* → schema infer) and interactsh (capture → OOB-detect) in m-18.
|
|
22
|
+
*
|
|
23
|
+
* Event log format: ndjson, one event per line. Two recognised shapes:
|
|
24
|
+
*
|
|
25
|
+
* • Stripe-style — `{type, data: {object: {...}}}` (the payload is
|
|
26
|
+
* `data.object`; everything else is envelope metadata).
|
|
27
|
+
* • Generic — `{type|event, body|payload, ...}` (the payload is
|
|
28
|
+
* whichever of `body`/`payload` is an object; falls back to the
|
|
29
|
+
* event itself when neither is present).
|
|
30
|
+
*
|
|
31
|
+
* Severity policy: HIGH on shape drift (server announced an event
|
|
32
|
+
* shape via `webhooks:` and is now sending something else). Unknown
|
|
33
|
+
* event types and missing payloads surface at LOW — they're noise
|
|
34
|
+
* categories, not contract bugs the API owner promised against.
|
|
35
|
+
*/
|
|
36
|
+
import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
|
|
37
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
38
|
+
import Ajv from "ajv";
|
|
39
|
+
import addFormats from "ajv-formats";
|
|
40
|
+
import type { ValidateFunction, ErrorObject } from "ajv";
|
|
41
|
+
|
|
42
|
+
interface SingleSchemaValidator {
|
|
43
|
+
validate(value: unknown): boolean;
|
|
44
|
+
errors: ErrorObject[] | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Compile a single JSON-Schema-shaped object into a callable validator.
|
|
48
|
+
* 3.1-flavour by default (`webhooks:` is OpenAPI 3.1) but tolerates
|
|
49
|
+
* pre-3.1 specs using `x-webhooks` — they ship Draft-7-ish schemas. */
|
|
50
|
+
function compileSingleSchema(schema: OpenAPIV3.SchemaObject, isV31: boolean): SingleSchemaValidator {
|
|
51
|
+
const ajv = isV31
|
|
52
|
+
? new (Ajv2020 as unknown as typeof Ajv)({ strict: false, allErrors: true })
|
|
53
|
+
: new Ajv({ strict: false, allErrors: true });
|
|
54
|
+
addFormats(ajv);
|
|
55
|
+
const validate: ValidateFunction = ajv.compile(schema);
|
|
56
|
+
return {
|
|
57
|
+
validate(value) { return validate(value) as boolean; },
|
|
58
|
+
get errors() { return validate.errors ?? null; },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type WebhookFindingKind =
|
|
63
|
+
| "shape_drift"
|
|
64
|
+
| "unknown_event_type"
|
|
65
|
+
| "missing_payload"
|
|
66
|
+
| "malformed_event";
|
|
67
|
+
|
|
68
|
+
export interface WebhookFinding {
|
|
69
|
+
/** Line number in the event log (1-indexed) for traceability. */
|
|
70
|
+
line: number;
|
|
71
|
+
kind: WebhookFindingKind;
|
|
72
|
+
severity: "high" | "low";
|
|
73
|
+
event_type: string | null;
|
|
74
|
+
message: string;
|
|
75
|
+
evidence: Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface WebhookProbeResult {
|
|
79
|
+
total_events: number;
|
|
80
|
+
by_type: Record<string, { ok: number; drift: number; unknown: number }>;
|
|
81
|
+
declared_events: string[];
|
|
82
|
+
findings: WebhookFinding[];
|
|
83
|
+
/** Reason the probe short-circuited without inspecting any event,
|
|
84
|
+
* e.g. spec has no webhooks block. Empty string when normal. */
|
|
85
|
+
skip_reason: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Extract the webhooks block. Tries OpenAPI 3.1 `webhooks:` first
|
|
89
|
+
* (the canonical location), falls back to `x-webhooks` for specs
|
|
90
|
+
* shipped before OpenAPI 3.1 had ratified the field. Returns an
|
|
91
|
+
* empty object when neither exists. */
|
|
92
|
+
export function readWebhooksMap(spec: unknown): Record<string, OpenAPIV3.PathItemObject> {
|
|
93
|
+
if (!spec || typeof spec !== "object") return {};
|
|
94
|
+
const s = spec as Record<string, unknown>;
|
|
95
|
+
const candidate = (s.webhooks ?? s["x-webhooks"]) as Record<string, OpenAPIV3.PathItemObject> | undefined;
|
|
96
|
+
if (!candidate || typeof candidate !== "object") return {};
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Pull the request-body schema for the POST operation under a
|
|
101
|
+
* webhook entry. Returns null when the entry doesn't declare a POST,
|
|
102
|
+
* or when the POST doesn't carry a JSON requestBody schema. */
|
|
103
|
+
function schemaForEvent(item: OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObject): OpenAPIV3.SchemaObject | null {
|
|
104
|
+
const post = item.post;
|
|
105
|
+
if (!post) return null;
|
|
106
|
+
const rb = post.requestBody;
|
|
107
|
+
if (!rb || (rb as OpenAPIV3.ReferenceObject).$ref) return null;
|
|
108
|
+
const content = (rb as OpenAPIV3.RequestBodyObject).content ?? {};
|
|
109
|
+
const json = content["application/json"];
|
|
110
|
+
if (!json?.schema) return null;
|
|
111
|
+
return json.schema as OpenAPIV3.SchemaObject;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Extract `type` from an event in a tolerant way: try `type` first
|
|
115
|
+
* (Stripe / GitHub style), then `event` (legacy / SaaS-style), then
|
|
116
|
+
* give up. Numbers are coerced to strings so an integer-valued
|
|
117
|
+
* `type` field doesn't masquerade as null. */
|
|
118
|
+
function readEventType(event: Record<string, unknown>): string | null {
|
|
119
|
+
for (const k of ["type", "event", "event_type"]) {
|
|
120
|
+
const v = event[k];
|
|
121
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
122
|
+
if (typeof v === "number") return String(v);
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Extract the payload from an event. Recognised envelopes (in
|
|
128
|
+
* priority order): `data.object` (Stripe), `body`, `payload`. Returns
|
|
129
|
+
* null when none of those carry an object — missing_payload then
|
|
130
|
+
* surfaces as a LOW finding so the operator can fix the capture
|
|
131
|
+
* step (and the probe doesn't validate envelope metadata as payload). */
|
|
132
|
+
function readEventPayload(event: Record<string, unknown>): unknown {
|
|
133
|
+
if (event.data && typeof event.data === "object" && !Array.isArray(event.data)) {
|
|
134
|
+
const inner = (event.data as Record<string, unknown>).object;
|
|
135
|
+
if (inner && typeof inner === "object") return inner;
|
|
136
|
+
}
|
|
137
|
+
for (const k of ["body", "payload"]) {
|
|
138
|
+
const v = event[k];
|
|
139
|
+
if (v && typeof v === "object") return v;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface RunOptions {
|
|
145
|
+
/** Pre-parsed events. One Record per line; non-object lines should
|
|
146
|
+
* surface as malformed_event findings before reaching this layer. */
|
|
147
|
+
events: Array<{ line: number; event: Record<string, unknown> }>;
|
|
148
|
+
spec: unknown;
|
|
149
|
+
/** Optional restriction — only validate events whose `type` is in
|
|
150
|
+
* this list. Empty/undefined ⇒ validate everything declared. */
|
|
151
|
+
onlyTypes?: string[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function runWebhooksProbe(opts: RunOptions): WebhookProbeResult {
|
|
155
|
+
const webhooksMap = readWebhooksMap(opts.spec);
|
|
156
|
+
const declared = Object.keys(webhooksMap).sort();
|
|
157
|
+
|
|
158
|
+
const out: WebhookProbeResult = {
|
|
159
|
+
total_events: opts.events.length,
|
|
160
|
+
by_type: {},
|
|
161
|
+
declared_events: declared,
|
|
162
|
+
findings: [],
|
|
163
|
+
skip_reason: "",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (declared.length === 0) {
|
|
167
|
+
out.skip_reason = "spec declares no `webhooks:` (or `x-webhooks`) entries — nothing to validate against";
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const isV31 = typeof (opts.spec as { openapi?: string })?.openapi === "string"
|
|
172
|
+
&& (opts.spec as { openapi: string }).openapi.startsWith("3.1");
|
|
173
|
+
// Compile each schema once; events sharing a type reuse the validator.
|
|
174
|
+
const validators = new Map<string, SingleSchemaValidator | null>();
|
|
175
|
+
for (const [name, item] of Object.entries(webhooksMap)) {
|
|
176
|
+
const schema = schemaForEvent(item);
|
|
177
|
+
if (!schema) { validators.set(name, null); continue; }
|
|
178
|
+
try {
|
|
179
|
+
validators.set(name, compileSingleSchema(schema, isV31));
|
|
180
|
+
} catch {
|
|
181
|
+
validators.set(name, null);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const onlyTypes = opts.onlyTypes && opts.onlyTypes.length > 0 ? new Set(opts.onlyTypes) : null;
|
|
186
|
+
|
|
187
|
+
for (const { line, event } of opts.events) {
|
|
188
|
+
const type = readEventType(event);
|
|
189
|
+
if (!type) {
|
|
190
|
+
out.findings.push({
|
|
191
|
+
line, kind: "malformed_event", severity: "low",
|
|
192
|
+
event_type: null,
|
|
193
|
+
message: `event has no recognisable type field (tried "type", "event", "event_type")`,
|
|
194
|
+
evidence: { event_keys: Object.keys(event) },
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (onlyTypes && !onlyTypes.has(type)) continue;
|
|
199
|
+
const bucket = out.by_type[type] ?? (out.by_type[type] = { ok: 0, drift: 0, unknown: 0 });
|
|
200
|
+
const validator = validators.get(type);
|
|
201
|
+
if (validator === undefined) {
|
|
202
|
+
bucket.unknown += 1;
|
|
203
|
+
out.findings.push({
|
|
204
|
+
line, kind: "unknown_event_type", severity: "low",
|
|
205
|
+
event_type: type,
|
|
206
|
+
message: `event type "${type}" is not declared in spec.webhooks (${declared.length} declared)`,
|
|
207
|
+
evidence: { declared_sample: declared.slice(0, 5) },
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (validator === null) {
|
|
212
|
+
// Declared but no schema to validate against → silent pass for
|
|
213
|
+
// that event type; surfacing every such case would be noise.
|
|
214
|
+
bucket.ok += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const payload = readEventPayload(event);
|
|
218
|
+
if (payload == null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
219
|
+
out.findings.push({
|
|
220
|
+
line, kind: "missing_payload", severity: "low",
|
|
221
|
+
event_type: type,
|
|
222
|
+
message: `event "${type}" carries no object payload (data.object / body / payload)`,
|
|
223
|
+
evidence: { event_keys: Object.keys(event) },
|
|
224
|
+
});
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const valid = validator.validate(payload);
|
|
228
|
+
if (valid) { bucket.ok += 1; continue; }
|
|
229
|
+
bucket.drift += 1;
|
|
230
|
+
const errs = validator.errors ?? [];
|
|
231
|
+
out.findings.push({
|
|
232
|
+
line, kind: "shape_drift", severity: "high",
|
|
233
|
+
event_type: type,
|
|
234
|
+
message: `event "${type}" does not conform to declared schema (${errs.length} error(s))`,
|
|
235
|
+
evidence: {
|
|
236
|
+
errors: errs.slice(0, 5).map((e) => ({
|
|
237
|
+
path: e.instancePath ?? "",
|
|
238
|
+
keyword: e.keyword ?? "",
|
|
239
|
+
message: e.message ?? "",
|
|
240
|
+
params: e.params ?? {},
|
|
241
|
+
})),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Parse an ndjson event log. Each line that is non-empty and parses
|
|
249
|
+
* to an object yields one `{line, event}`. Bad lines surface as
|
|
250
|
+
* malformed_event findings in the result so the operator gets
|
|
251
|
+
* pinpointed feedback. */
|
|
252
|
+
export function parseEventLog(text: string): {
|
|
253
|
+
events: Array<{ line: number; event: Record<string, unknown> }>;
|
|
254
|
+
malformed: WebhookFinding[];
|
|
255
|
+
} {
|
|
256
|
+
const events: Array<{ line: number; event: Record<string, unknown> }> = [];
|
|
257
|
+
const malformed: WebhookFinding[] = [];
|
|
258
|
+
const lines = text.split(/\r?\n/);
|
|
259
|
+
for (let i = 0; i < lines.length; i++) {
|
|
260
|
+
const raw = lines[i]!.trim();
|
|
261
|
+
if (raw.length === 0) continue;
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(raw);
|
|
264
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
265
|
+
events.push({ line: i + 1, event: parsed as Record<string, unknown> });
|
|
266
|
+
} else {
|
|
267
|
+
malformed.push({
|
|
268
|
+
line: i + 1, kind: "malformed_event", severity: "low",
|
|
269
|
+
event_type: null,
|
|
270
|
+
message: `event line is not a JSON object`,
|
|
271
|
+
evidence: { sample: raw.slice(0, 60) },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
} catch (e) {
|
|
275
|
+
malformed.push({
|
|
276
|
+
line: i + 1, kind: "malformed_event", severity: "low",
|
|
277
|
+
event_type: null,
|
|
278
|
+
message: `ndjson parse failed: ${(e as Error).message}`,
|
|
279
|
+
evidence: { sample: raw.slice(0, 60) },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { events, malformed };
|
|
284
|
+
}
|
|
@@ -8,11 +8,25 @@ const DIM = "\x1b[2m";
|
|
|
8
8
|
const GREEN = "\x1b[32m";
|
|
9
9
|
const RED = "\x1b[31m";
|
|
10
10
|
const GRAY = "\x1b[90m";
|
|
11
|
+
const YELLOW = "\x1b[33m";
|
|
11
12
|
|
|
12
13
|
const PASS_ICON = "\u2713"; // ✓
|
|
13
14
|
const FAIL_ICON = "\u2717"; // ✗
|
|
14
15
|
const SKIP_ICON = "\u25CB"; // ○
|
|
15
16
|
|
|
17
|
+
export function is5xx(step: StepResult): boolean {
|
|
18
|
+
const status = step.response?.status;
|
|
19
|
+
return typeof status === "number" && status >= 500 && status < 600;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function count5xx(steps: StepResult[]): number {
|
|
23
|
+
let n = 0;
|
|
24
|
+
for (const s of steps) {
|
|
25
|
+
if ((s.status === "fail" || s.status === "error") && is5xx(s)) n++;
|
|
26
|
+
}
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export function formatDuration(ms: number): string {
|
|
17
31
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
18
32
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -33,11 +47,13 @@ export function formatStep(step: StepResult, color: boolean): string {
|
|
|
33
47
|
case "fail": {
|
|
34
48
|
const icon = color ? `${RED}${FAIL_ICON}${RESET}` : FAIL_ICON;
|
|
35
49
|
const dim = color ? `${DIM}(${duration})${RESET}` : `(${duration})`;
|
|
36
|
-
|
|
50
|
+
const tag = is5xx(step) ? (color ? ` ${BOLD}${YELLOW}[5xx ${step.response?.status}]${RESET}` : ` [5xx ${step.response?.status}]`) : "";
|
|
51
|
+
return ` ${icon} ${step.name}${tag} ${dim}`;
|
|
37
52
|
}
|
|
38
53
|
case "skip": {
|
|
39
54
|
const icon = color ? `${GRAY}${SKIP_ICON}${RESET}` : SKIP_ICON;
|
|
40
|
-
const
|
|
55
|
+
const reason = step.error ? `skipped: ${step.error}` : "skipped";
|
|
56
|
+
const label = color ? `${GRAY}(${reason})${RESET}` : `(${reason})`;
|
|
41
57
|
return ` ${icon} ${step.name} ${label}`;
|
|
42
58
|
}
|
|
43
59
|
case "error": {
|
|
@@ -59,16 +75,39 @@ export function formatFailures(step: StepResult, color: boolean): string {
|
|
|
59
75
|
|
|
60
76
|
const failed = step.assertions.filter((a) => !a.passed);
|
|
61
77
|
for (const a of failed) {
|
|
62
|
-
const msg =
|
|
78
|
+
const msg = formatAssertion(a);
|
|
63
79
|
lines.push(color ? ` ${RED}${msg}${RESET}` : ` ${msg}`);
|
|
64
80
|
}
|
|
65
81
|
return lines.join("\n");
|
|
66
82
|
}
|
|
67
83
|
|
|
84
|
+
function formatAssertion(a: { field: string; rule: string; actual: unknown; expected: unknown; kind?: string }): string {
|
|
85
|
+
// Schema assertions already carry a humanised `expected` string ("missing
|
|
86
|
+
// required field …", "type integer", …). Use it directly — interpolating
|
|
87
|
+
// the actual subtree via String() turns into "[object Object]" and buries
|
|
88
|
+
// the actionable detail (TASK-277).
|
|
89
|
+
//
|
|
90
|
+
// ARV-27: when `actual` is a primitive (string/number/bool/null), append it
|
|
91
|
+
// to the message so format/type/enum/const failures show the offending value
|
|
92
|
+
// — same shape as runtime asserts ("expected equals 200 but got 422").
|
|
93
|
+
// Skipped for objects/arrays to avoid burying the message under JSON dumps
|
|
94
|
+
// (the original TASK-277 concern).
|
|
95
|
+
if (a.kind === "schema" && typeof a.expected === "string") {
|
|
96
|
+
if (isPrimitive(a.actual)) return `${a.field}: ${a.expected} (got ${formatValue(a.actual)})`;
|
|
97
|
+
return `${a.field}: ${a.expected}`;
|
|
98
|
+
}
|
|
99
|
+
return `${a.field}: expected ${a.rule} but got ${formatValue(a.actual)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isPrimitive(v: unknown): boolean {
|
|
103
|
+
return v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
function formatValue(value: unknown): string {
|
|
69
107
|
if (value === undefined) return "undefined";
|
|
70
108
|
if (value === null) return "null";
|
|
71
109
|
if (typeof value === "string") return `"${value}"`;
|
|
110
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
72
111
|
return String(value);
|
|
73
112
|
}
|
|
74
113
|
|
|
@@ -105,6 +144,15 @@ export function formatSuiteResult(result: TestRunResult, color: boolean): string
|
|
|
105
144
|
if (result.failed > 0) {
|
|
106
145
|
parts.push(color ? `${RED}${result.failed} failed${RESET}` : `${result.failed} failed`);
|
|
107
146
|
}
|
|
147
|
+
const fiveXx = count5xx(result.steps);
|
|
148
|
+
if (fiveXx > 0) {
|
|
149
|
+
const label = `${fiveXx} 5xx`;
|
|
150
|
+
parts.push(color ? `${BOLD}${YELLOW}${label}${RESET}` : label);
|
|
151
|
+
}
|
|
152
|
+
const errored = result.steps.filter(s => s.status === "error").length;
|
|
153
|
+
if (errored > 0) {
|
|
154
|
+
parts.push(color ? `${RED}${errored} errored${RESET}` : `${errored} errored`);
|
|
155
|
+
}
|
|
108
156
|
if (result.skipped > 0) {
|
|
109
157
|
parts.push(color ? `${GRAY}${result.skipped} skipped${RESET}` : `${result.skipped} skipped`);
|
|
110
158
|
}
|
|
@@ -119,7 +167,7 @@ export function formatSuiteResult(result: TestRunResult, color: boolean): string
|
|
|
119
167
|
}
|
|
120
168
|
|
|
121
169
|
export function formatGrandTotal(results: TestRunResult[], color: boolean): string {
|
|
122
|
-
const totals = { passed: 0, failed: 0, skipped: 0, total: 0 };
|
|
170
|
+
const totals = { passed: 0, failed: 0, skipped: 0, errored: 0, total: 0, fiveXx: 0 };
|
|
123
171
|
let minStart = Infinity;
|
|
124
172
|
let maxEnd = -Infinity;
|
|
125
173
|
|
|
@@ -127,7 +175,9 @@ export function formatGrandTotal(results: TestRunResult[], color: boolean): stri
|
|
|
127
175
|
totals.passed += r.passed;
|
|
128
176
|
totals.failed += r.failed;
|
|
129
177
|
totals.skipped += r.skipped;
|
|
178
|
+
totals.errored += r.steps.filter(s => s.status === "error").length;
|
|
130
179
|
totals.total += r.total;
|
|
180
|
+
totals.fiveXx += count5xx(r.steps);
|
|
131
181
|
const start = Date.parse(r.started_at);
|
|
132
182
|
const end = Date.parse(r.finished_at);
|
|
133
183
|
if (start < minStart) minStart = start;
|
|
@@ -144,6 +194,13 @@ export function formatGrandTotal(results: TestRunResult[], color: boolean): stri
|
|
|
144
194
|
if (totals.failed > 0) {
|
|
145
195
|
parts.push(color ? `${RED}${totals.failed} failed${RESET}` : `${totals.failed} failed`);
|
|
146
196
|
}
|
|
197
|
+
if (totals.fiveXx > 0) {
|
|
198
|
+
const label = `${totals.fiveXx} 5xx`;
|
|
199
|
+
parts.push(color ? `${BOLD}${YELLOW}${label}${RESET}` : label);
|
|
200
|
+
}
|
|
201
|
+
if (totals.errored > 0) {
|
|
202
|
+
parts.push(color ? `${RED}${totals.errored} errored${RESET}` : `${totals.errored} errored`);
|
|
203
|
+
}
|
|
147
204
|
if (totals.skipped > 0) {
|
|
148
205
|
parts.push(color ? `${GRAY}${totals.skipped} skipped${RESET}` : `${totals.skipped} skipped`);
|
|
149
206
|
}
|
|
@@ -161,6 +218,14 @@ export const consoleReporter: Reporter = {
|
|
|
161
218
|
return;
|
|
162
219
|
}
|
|
163
220
|
|
|
221
|
+
// TASK-265: --quiet collapses output to one summary line. Exit code
|
|
222
|
+
// still differentiates pass/fail; this is for CI logs and `run --watch`
|
|
223
|
+
// where per-test detail is noise between iterations.
|
|
224
|
+
if (options?.quiet) {
|
|
225
|
+
console.log(formatGrandTotal(results, color));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
164
229
|
const blocks: string[] = [];
|
|
165
230
|
for (const result of results) {
|
|
166
231
|
blocks.push(formatSuiteResult(result, color));
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export type { Reporter, ReporterOptions, ReporterName } from "./types.ts";
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
4
|
-
export { junitReporter } from "./junit.ts";
|
|
2
|
+
export { generateJsonReport } from "./json.ts";
|
|
3
|
+
export { generateJunitXml } from "./junit.ts";
|
|
5
4
|
|
|
6
5
|
import type { Reporter, ReporterName } from "./types.ts";
|
|
7
6
|
import { consoleReporter } from "./console.ts";
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import type { TestRunResult } from "../runner/types.ts";
|
|
2
2
|
import type { Reporter, ReporterOptions } from "./types.ts";
|
|
3
|
+
import { type Exporter, runExporter } from "../exporter/exporter.ts";
|
|
4
|
+
|
|
5
|
+
const jsonExporter: Exporter<TestRunResult[]> = {
|
|
6
|
+
name: "json",
|
|
7
|
+
mime: "application/json",
|
|
8
|
+
render(results: TestRunResult[]): string {
|
|
9
|
+
return JSON.stringify(results, null, 2);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** TASK-186: pure render → sanitizer pipeline; redaction lives in runExporter. */
|
|
14
|
+
export function generateJsonReport(results: TestRunResult[]): string {
|
|
15
|
+
return runExporter(jsonExporter, results);
|
|
16
|
+
}
|
|
3
17
|
|
|
4
18
|
export const jsonReporter: Reporter = {
|
|
5
19
|
report(results: TestRunResult[], _options?: ReporterOptions): void {
|
|
6
|
-
|
|
7
|
-
console.log(json);
|
|
20
|
+
console.log(generateJsonReport(results));
|
|
8
21
|
},
|
|
9
22
|
};
|