@kirrosh/zond 0.22.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 +648 -0
- package/README.md +58 -6
- package/package.json +9 -6
- 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 +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +178 -7
- 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 -46
- package/src/cli/commands/init/bootstrap.ts +30 -1
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +56 -3
- package/src/cli/commands/init/templates/agents.md +65 -61
- 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 +592 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- 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 +842 -53
- 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 +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +198 -635
- 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 +5 -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 +22 -6
- 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 +151 -11
- 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 +42 -16
- 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 +445 -19
- 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 +37 -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 +103 -13
- package/src/core/generator/suite-generator.ts +419 -111
- 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 +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 +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 +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 +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 +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 +58 -1
- 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 +264 -20
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +75 -2
- 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 +89 -17
- 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 +415 -16
- 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/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 +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
package/src/core/setup-api.ts
CHANGED
|
@@ -1,9 +1,171 @@
|
|
|
1
|
-
import { resolve, join } from "path";
|
|
2
|
-
import { mkdirSync, writeFileSync, existsSync, readFileSync } from "fs";
|
|
1
|
+
import { resolve, join, relative } from "path";
|
|
2
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "fs";
|
|
3
3
|
import { getDb } from "../db/schema.ts";
|
|
4
4
|
import { createCollection, deleteCollection, findCollectionByNameOrId, normalizePath } from "../db/queries.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
readOpenApiSpec,
|
|
7
|
+
extractEndpoints,
|
|
8
|
+
extractSecuritySchemes,
|
|
9
|
+
buildCatalog,
|
|
10
|
+
serializeCatalog,
|
|
11
|
+
buildApiResourceMap,
|
|
12
|
+
serializeApiResourceMap,
|
|
13
|
+
buildApiFixtureManifest,
|
|
14
|
+
serializeApiFixtureManifest,
|
|
15
|
+
} from "./generator/index.ts";
|
|
16
|
+
import { decycleSchema } from "./generator/schema-utils.ts";
|
|
17
|
+
import { schemeVarName } from "./generator/suite-generator.ts";
|
|
18
|
+
import type { SecuritySchemeInfo } from "./generator/types.ts";
|
|
19
|
+
import { hashSpec } from "./meta/meta-store.ts";
|
|
6
20
|
import { findWorkspaceRoot } from "./workspace/root.ts";
|
|
21
|
+
import { recordGeneratedFiles, type RecordInput } from "./workspace/manifest.ts";
|
|
22
|
+
import { CANONICAL_IDENTITY_KEYS } from "./identity/identity-file.ts";
|
|
23
|
+
|
|
24
|
+
/** Filename of the dereferenced spec snapshot inside `apis/<name>/`. */
|
|
25
|
+
export const SPEC_SNAPSHOT_FILENAME = "spec.json";
|
|
26
|
+
|
|
27
|
+
interface WriteArtifactsParams {
|
|
28
|
+
/** Dereferenced OpenAPI document. */
|
|
29
|
+
doc: unknown;
|
|
30
|
+
/** Absolute path to apis/<name>/. */
|
|
31
|
+
baseDir: string;
|
|
32
|
+
/** Collection name (goes into the catalog header). */
|
|
33
|
+
apiName: string;
|
|
34
|
+
/** Resolved server URL or "". */
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
/** Absolute workspace root, used to compute the relative specSource. */
|
|
37
|
+
workspaceRoot: string;
|
|
38
|
+
/** Caller label for manifest entries (defaults to "zond add api"). */
|
|
39
|
+
by?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Snapshot the dereferenced spec into `apis/<name>/spec.json` and emit the
|
|
44
|
+
* three derived artifacts (`.api-catalog.yaml`, `.api-resources.yaml`,
|
|
45
|
+
* `.api-fixtures.yaml`). Pure side-effect; safe to call from `setupApi` at
|
|
46
|
+
* register time and from `refreshApi` for re-snapshot.
|
|
47
|
+
*/
|
|
48
|
+
export function writeArtifactsFromDoc(params: WriteArtifactsParams): void {
|
|
49
|
+
const { doc, baseDir, apiName, baseUrl, workspaceRoot, by = "zond add api" } = params;
|
|
50
|
+
const localSpecAbsPath = join(baseDir, SPEC_SNAPSHOT_FILENAME);
|
|
51
|
+
// Pass through decycleSchema first — large specs (Stripe, GitHub) contain
|
|
52
|
+
// mutually-recursive `$ref` chains that resolve to true object cycles after
|
|
53
|
+
// dereference, and raw JSON.stringify crashes on those with "cannot
|
|
54
|
+
// serialize cyclic structures" (ARV-145). decycleSchema collapses the
|
|
55
|
+
// second visit to `{ "x-circular": true }` (vendor-extension sentinel —
|
|
56
|
+
// NOT `$ref`, otherwise the parser tries to resolve "[Circular]" as a
|
|
57
|
+
// file path when re-reading spec.json, ARV-146) so the on-disk snapshot
|
|
58
|
+
// is self-contained, parser-safe JSON.
|
|
59
|
+
let serialized: string;
|
|
60
|
+
try {
|
|
61
|
+
serialized = JSON.stringify(decycleSchema(doc), null, 2);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const m = (err as Error).message;
|
|
64
|
+
throw new Error(
|
|
65
|
+
`spec_serialize_failed: could not serialize dereferenced spec for '${apiName}' — ${m}. ` +
|
|
66
|
+
`This usually means the spec contains a structure decycleSchema could not collapse; please open an issue with the spec URL.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
writeFileSync(localSpecAbsPath, serialized + "\n", "utf-8");
|
|
70
|
+
|
|
71
|
+
const endpoints = extractEndpoints(doc as any);
|
|
72
|
+
const securitySchemes = extractSecuritySchemes(doc as any);
|
|
73
|
+
// Hash the on-disk file bytes — this is what `zond doctor` re-hashes when
|
|
74
|
+
// checking artifact freshness (TASK-215). Both sides now read the decycled
|
|
75
|
+
// form: setup-api writes it here, doctor re-reads the same file.
|
|
76
|
+
const specHash = hashSpec(readFileSync(localSpecAbsPath, "utf-8"));
|
|
77
|
+
const localSpecRelPath = relative(workspaceRoot, localSpecAbsPath).replace(/\\/g, "/");
|
|
78
|
+
|
|
79
|
+
const catalog = buildCatalog({
|
|
80
|
+
endpoints,
|
|
81
|
+
securitySchemes,
|
|
82
|
+
specSource: localSpecRelPath,
|
|
83
|
+
specHash,
|
|
84
|
+
apiName,
|
|
85
|
+
apiVersion: (doc as any).info?.version,
|
|
86
|
+
baseUrl,
|
|
87
|
+
});
|
|
88
|
+
const catalogPath = join(baseDir, ".api-catalog.yaml");
|
|
89
|
+
writeFileSync(catalogPath, serializeCatalog(catalog), "utf-8");
|
|
90
|
+
|
|
91
|
+
const resources = buildApiResourceMap({ endpoints, specHash });
|
|
92
|
+
const resourcesPath = join(baseDir, ".api-resources.yaml");
|
|
93
|
+
writeFileSync(resourcesPath, serializeApiResourceMap(resources), "utf-8");
|
|
94
|
+
|
|
95
|
+
const fixtures = buildApiFixtureManifest({
|
|
96
|
+
endpoints,
|
|
97
|
+
securitySchemes,
|
|
98
|
+
baseUrl: baseUrl || undefined,
|
|
99
|
+
specHash,
|
|
100
|
+
resourceMap: resources,
|
|
101
|
+
});
|
|
102
|
+
const fixturesPath = join(baseDir, ".api-fixtures.yaml");
|
|
103
|
+
writeFileSync(fixturesPath, serializeApiFixtureManifest(fixtures), "utf-8");
|
|
104
|
+
|
|
105
|
+
// Record artifacts in .zond/manifest.json (TASK-156).
|
|
106
|
+
try {
|
|
107
|
+
const entries: RecordInput[] = [
|
|
108
|
+
{ path: localSpecAbsPath, by, api: apiName, category: "spec" },
|
|
109
|
+
{ path: catalogPath, by, api: apiName, category: "catalog" },
|
|
110
|
+
{ path: resourcesPath, by, api: apiName, category: "resources" },
|
|
111
|
+
{ path: fixturesPath, by, api: apiName, category: "fixtures" },
|
|
112
|
+
];
|
|
113
|
+
recordGeneratedFiles(workspaceRoot, entries);
|
|
114
|
+
} catch {
|
|
115
|
+
// best-effort
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a `collections.openapi_spec` value to a concrete file path the
|
|
121
|
+
* caller can read. Throws on a legacy / broken workspace so the user gets
|
|
122
|
+
* a single clear instruction instead of a downstream ENOENT.
|
|
123
|
+
*
|
|
124
|
+
* Resolution order:
|
|
125
|
+
* 1. URL (http/https) — return as-is.
|
|
126
|
+
* 2. Workspace-relative path (e.g. `apis/<name>/spec.json`) that exists.
|
|
127
|
+
* 3. Absolute filesystem path that exists. Treated as legacy: the spec
|
|
128
|
+
* is outside the workspace and not snapshotted into `apis/<name>/`.
|
|
129
|
+
* We let it through, but `assertLocalSpec` (used by run/report/doctor)
|
|
130
|
+
* will reject it.
|
|
131
|
+
* 4. Otherwise — throw a "legacy / stale workspace" error pointing at
|
|
132
|
+
* `zond refresh-api`.
|
|
133
|
+
*/
|
|
134
|
+
export function resolveCollectionSpec(specRef: string): string {
|
|
135
|
+
if (/^https?:\/\//i.test(specRef)) return specRef;
|
|
136
|
+
const root = findWorkspaceRoot().root;
|
|
137
|
+
const local = resolve(root, specRef);
|
|
138
|
+
if (existsSync(local)) return local;
|
|
139
|
+
if (specRef.startsWith("/") && existsSync(specRef)) return specRef;
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Spec for this API is missing at ${local}` +
|
|
142
|
+
(specRef.startsWith("/") ? ` (DB recorded an external path: ${specRef})` : "") +
|
|
143
|
+
`. The workspace looks legacy or stale — run \`zond refresh-api <name> [--spec <path|url>]\` to re-snapshot.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Strict variant for code paths that must read the workspace-local
|
|
149
|
+
* snapshot (run/report/doctor). Returns the local absolute path or
|
|
150
|
+
* throws — never returns an external URL or path.
|
|
151
|
+
*/
|
|
152
|
+
export function assertLocalSpec(specRef: string, apiName: string): string {
|
|
153
|
+
if (/^https?:\/\//i.test(specRef)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`API '${apiName}' has a remote spec recorded (${specRef}) but no local snapshot. ` +
|
|
156
|
+
`Run \`zond refresh-api ${apiName}\` to materialise apis/${apiName}/${SPEC_SNAPSHOT_FILENAME}.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const root = findWorkspaceRoot().root;
|
|
160
|
+
const local = resolve(root, specRef);
|
|
161
|
+
if (!existsSync(local)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Local spec missing for API '${apiName}' (expected ${local}). ` +
|
|
164
|
+
`Run \`zond refresh-api ${apiName}\` to regenerate it.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return local;
|
|
168
|
+
}
|
|
7
169
|
|
|
8
170
|
function toYaml(vars: Record<string, string>): string {
|
|
9
171
|
const lines: string[] = [];
|
|
@@ -32,9 +194,32 @@ export interface SetupApiResult {
|
|
|
32
194
|
baseUrl: string;
|
|
33
195
|
specEndpoints: number;
|
|
34
196
|
pathParams?: Record<string, string>;
|
|
197
|
+
/** Auth-related env-var names auto-seeded as `@secret:<name>` (TASK-209). */
|
|
198
|
+
authVars?: string[];
|
|
35
199
|
warnings?: string[];
|
|
36
200
|
}
|
|
37
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Walk the security schemes and derive the env-var names that
|
|
204
|
+
* `@readme/openapi-parser`-derived suites/probes will reference for auth
|
|
205
|
+
* tokens. Mirrors `getAuthHeaders` in src/core/probe/shared.ts:
|
|
206
|
+
* - HTTP bearer/basic/empty-scheme → schemeVarName(...) (default "auth_token")
|
|
207
|
+
* - apiKey in header named "Authorization" → schemeVarName(...)
|
|
208
|
+
* - apiKey in header (other name) → "api_key"
|
|
209
|
+
*/
|
|
210
|
+
function deriveAuthVarNames(schemes: SecuritySchemeInfo[]): string[] {
|
|
211
|
+
const vars = new Set<string>();
|
|
212
|
+
for (const s of schemes) {
|
|
213
|
+
if (s.type === "http" && (s.scheme === "bearer" || s.scheme === "basic" || !s.scheme)) {
|
|
214
|
+
vars.add(schemeVarName(s, schemes));
|
|
215
|
+
} else if (s.type === "apiKey" && s.in === "header" && s.apiKeyName) {
|
|
216
|
+
if (s.apiKeyName === "Authorization") vars.add(schemeVarName(s, schemes));
|
|
217
|
+
else vars.add("api_key");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return [...vars];
|
|
221
|
+
}
|
|
222
|
+
|
|
38
223
|
export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult> {
|
|
39
224
|
const { spec, dbPath } = options;
|
|
40
225
|
|
|
@@ -47,11 +232,47 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
|
|
|
47
232
|
const pathParams = new Map<string, string>();
|
|
48
233
|
const warnings: string[] = [];
|
|
49
234
|
let specTitle: string | undefined;
|
|
235
|
+
// The dereferenced doc is captured here so we can copy it into the
|
|
236
|
+
// workspace after the target dir is computed (below). We snapshot the
|
|
237
|
+
// *dereferenced* form so all consumers (probe-*, generate, describe) read
|
|
238
|
+
// a self-contained file — no external $ref resolution at runtime.
|
|
239
|
+
let dereferencedDoc: unknown = null;
|
|
240
|
+
let authVarNames: string[] = [];
|
|
50
241
|
if (spec) {
|
|
51
242
|
const doc = await readOpenApiSpec(spec, { insecure: options.insecure });
|
|
243
|
+
// Validate the document looks like OpenAPI/Swagger before we snapshot it.
|
|
244
|
+
// dereference() happily round-trips arbitrary JSON (e.g. a marketing-site
|
|
245
|
+
// landing payload), so without this guard `zond add api foo --spec
|
|
246
|
+
// https://example.com` silently registers a 0-endpoint API.
|
|
247
|
+
const docAny = doc as any;
|
|
248
|
+
const hasOpenApiField = typeof docAny?.openapi === "string";
|
|
249
|
+
const hasSwaggerField = typeof docAny?.swagger === "string";
|
|
250
|
+
if (!hasOpenApiField && !hasSwaggerField) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`Spec at ${spec} is not an OpenAPI/Swagger document — missing top-level 'openapi' (3.x) or 'swagger' (2.x) field. Check the URL points to the JSON spec, not the API root.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
dereferencedDoc = doc;
|
|
52
256
|
openapiSpec = spec;
|
|
53
257
|
if ((doc as any).servers?.[0]?.url) {
|
|
54
258
|
baseUrl = (doc as any).servers[0].url;
|
|
259
|
+
// Resolve OpenAPI server variables (e.g. {region}) using their declared defaults.
|
|
260
|
+
// Without this, the raw placeholder ends up in .env.yaml and causes cryptic TLS
|
|
261
|
+
// errors because the hostname literally contains "{region}".
|
|
262
|
+
const serverVars = (doc as any).servers[0].variables as
|
|
263
|
+
Record<string, { default?: string }> | undefined;
|
|
264
|
+
if (serverVars && baseUrl.includes("{")) {
|
|
265
|
+
baseUrl = baseUrl.replace(/\{([^}]+)\}/g, (_: string, name: string) =>
|
|
266
|
+
serverVars[name]?.default ?? `{${name}}`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
// Warn if any placeholder remains unresolved (spec didn't provide a default).
|
|
270
|
+
const unresolved = [...baseUrl.matchAll(/\{([^}]+)\}/g)].map(m => m[1]);
|
|
271
|
+
if (unresolved.length > 0) {
|
|
272
|
+
warnings.push(
|
|
273
|
+
`base_url contains unresolved server variable${unresolved.length === 1 ? "" : "s"}: ${unresolved.map(v => `{${v}}`).join(", ")}. Edit .env.yaml and replace with a concrete value.`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
55
276
|
}
|
|
56
277
|
if (baseUrl && !baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
|
|
57
278
|
warnings.push(`Spec server URL "${baseUrl}" is relative — requests will fail without a host. Override with envVars: {"base_url": "https://your-host${baseUrl}"}`);
|
|
@@ -59,16 +280,28 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
|
|
|
59
280
|
specTitle = (doc as any).info?.title;
|
|
60
281
|
const endpoints = extractEndpoints(doc);
|
|
61
282
|
endpointCount = endpoints.length;
|
|
283
|
+
authVarNames = deriveAuthVarNames(extractSecuritySchemes(doc));
|
|
62
284
|
|
|
63
|
-
|
|
285
|
+
if (endpointCount === 0) {
|
|
286
|
+
const hasPaths = docAny?.paths && typeof docAny.paths === "object" && Object.keys(docAny.paths).length > 0;
|
|
287
|
+
warnings.push(
|
|
288
|
+
hasPaths
|
|
289
|
+
? `Spec declares paths but no operations were extracted — every method may be filtered out (deprecated, unsupported method, etc.). Verify with \`zond catalog --api <name>\`.`
|
|
290
|
+
: `Spec contains 0 endpoints — 'paths' field is empty or missing. generate/probe/checks will produce nothing until the spec is fixed or replaced.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Collect unique path parameters. The default is empty string so that
|
|
295
|
+
// generated `skip_if: "{{<id>}} =="` checks auto-skip until the user
|
|
296
|
+
// fills the value in .env.yaml (TASK-210). Spec-provided examples are
|
|
297
|
+
// kept verbatim so they are still useful as concrete fixtures.
|
|
64
298
|
for (const ep of endpoints) {
|
|
65
299
|
for (const param of (ep.parameters ?? []).filter(p => p.in === "path")) {
|
|
66
300
|
if (pathParams.has(param.name)) continue;
|
|
67
301
|
const schema = param.schema as any;
|
|
68
302
|
if (param.example !== undefined) pathParams.set(param.name, String(param.example));
|
|
69
303
|
else if (schema?.example !== undefined) pathParams.set(param.name, String(schema.example));
|
|
70
|
-
else
|
|
71
|
-
else pathParams.set(param.name, "example");
|
|
304
|
+
else pathParams.set(param.name, "");
|
|
72
305
|
}
|
|
73
306
|
}
|
|
74
307
|
}
|
|
@@ -96,9 +329,63 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
|
|
|
96
329
|
: resolve(findWorkspaceRoot().root, `apis/${dirName}/`);
|
|
97
330
|
const testPath = join(baseDir, "tests");
|
|
98
331
|
|
|
332
|
+
// Track whether we created baseDir from scratch so we can clean it up on
|
|
333
|
+
// failure — without this, a crash mid-setup (e.g. JSON.stringify on a
|
|
334
|
+
// cyclic spec, ARV-145) leaves apis/<slug>/tests/ behind and confuses the
|
|
335
|
+
// next `zond add api` invocation.
|
|
336
|
+
const baseDirPreExisted = existsSync(baseDir);
|
|
337
|
+
|
|
99
338
|
// Create directories
|
|
100
339
|
mkdirSync(testPath, { recursive: true });
|
|
101
340
|
|
|
341
|
+
try {
|
|
342
|
+
return await finalizeSetup({
|
|
343
|
+
name,
|
|
344
|
+
baseDir,
|
|
345
|
+
testPath,
|
|
346
|
+
baseUrl,
|
|
347
|
+
pathParams,
|
|
348
|
+
authVarNames,
|
|
349
|
+
envVarsOverride: options.envVars,
|
|
350
|
+
spec,
|
|
351
|
+
dereferencedDoc,
|
|
352
|
+
openapiSpec,
|
|
353
|
+
endpointCount,
|
|
354
|
+
warnings,
|
|
355
|
+
});
|
|
356
|
+
} catch (err) {
|
|
357
|
+
// Roll back partial filesystem state (apis/<slug>/tests/, spec.json, etc.)
|
|
358
|
+
// when we created the dir from scratch. Without this, the next
|
|
359
|
+
// `zond add api <same-name>` would still find a stale dir and demand
|
|
360
|
+
// --force, even though no collection was actually registered. ARV-145.
|
|
361
|
+
if (!baseDirPreExisted) {
|
|
362
|
+
try { rmSync(baseDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
363
|
+
}
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
interface FinalizeSetupParams {
|
|
369
|
+
name: string;
|
|
370
|
+
baseDir: string;
|
|
371
|
+
testPath: string;
|
|
372
|
+
baseUrl: string;
|
|
373
|
+
pathParams: Map<string, string>;
|
|
374
|
+
authVarNames: string[];
|
|
375
|
+
envVarsOverride?: Record<string, string>;
|
|
376
|
+
spec?: string;
|
|
377
|
+
dereferencedDoc: unknown;
|
|
378
|
+
openapiSpec: string | null;
|
|
379
|
+
endpointCount: number;
|
|
380
|
+
warnings: string[];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function finalizeSetup(p: FinalizeSetupParams): Promise<SetupApiResult> {
|
|
384
|
+
const {
|
|
385
|
+
name, baseDir, testPath, baseUrl, pathParams, authVarNames,
|
|
386
|
+
envVarsOverride, spec, dereferencedDoc, openapiSpec, endpointCount, warnings,
|
|
387
|
+
} = p;
|
|
388
|
+
|
|
102
389
|
// Build environment variables
|
|
103
390
|
const envVars: Record<string, string> = {};
|
|
104
391
|
if (baseUrl) envVars.base_url = baseUrl;
|
|
@@ -106,8 +393,33 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
|
|
|
106
393
|
for (const [k, v] of pathParams) {
|
|
107
394
|
if (!(k in envVars)) envVars[k] = v;
|
|
108
395
|
}
|
|
109
|
-
|
|
110
|
-
|
|
396
|
+
// Auto-wire auth env-vars to .secrets.yaml so generated suites and probes
|
|
397
|
+
// resolve `{{auth_token}}` (etc.) without manual editing of .env.yaml
|
|
398
|
+
// (TASK-209). The matching `<var>: ""` placeholder is seeded into
|
|
399
|
+
// .secrets.yaml below — the user only fills the secret value.
|
|
400
|
+
for (const v of authVarNames) {
|
|
401
|
+
if (!(v in envVars)) envVars[v] = `@secret:${v}`;
|
|
402
|
+
}
|
|
403
|
+
// ARV-201 (R10/F2): when the spec declares no `components.securitySchemes`
|
|
404
|
+
// (GitHub publishes its OpenAPI this way), `deriveAuthVarNames` returns []
|
|
405
|
+
// and the loop above is a no-op — yet `zond request --api <name>` knows
|
|
406
|
+
// to attach `Authorization: Bearer <auth_token>` if the env carries an
|
|
407
|
+
// `auth_token`. Mirror the `.secrets.yaml` fallback (which already seeds
|
|
408
|
+
// `auth_token: ""` when authVarNames is empty) into `.env.yaml` so users
|
|
409
|
+
// do not need to hand-add `auth_token: "@secret:auth_token"` just to
|
|
410
|
+
// surface the Bearer header on bare specs.
|
|
411
|
+
if (authVarNames.length === 0 && !("auth_token" in envVars)) {
|
|
412
|
+
envVars.auth_token = "@secret:auth_token";
|
|
413
|
+
}
|
|
414
|
+
if (envVarsOverride) {
|
|
415
|
+
Object.assign(envVars, envVarsOverride);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Spec-less registration is allowed, but we need a base_url from somewhere
|
|
419
|
+
// (server URL extracted from the spec, or envVars.base_url passed in by the
|
|
420
|
+
// caller). Without it the API is useless — `zond run` can't resolve {{base_url}}.
|
|
421
|
+
if (!spec && !envVars.base_url) {
|
|
422
|
+
throw new Error("setupApi requires --spec or envVars.base_url to register an API");
|
|
111
423
|
}
|
|
112
424
|
|
|
113
425
|
// Write .env.yaml in base_dir
|
|
@@ -116,26 +428,112 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
|
|
|
116
428
|
writeFileSync(envFilePath, toYaml(envVars) + "\n", "utf-8");
|
|
117
429
|
}
|
|
118
430
|
|
|
119
|
-
// Create/update .gitignore to exclude env files
|
|
431
|
+
// Create/update .gitignore to exclude env / secret files
|
|
120
432
|
const gitignorePath = join(baseDir, ".gitignore");
|
|
121
|
-
|
|
433
|
+
let gitignoreContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
|
|
434
|
+
let gitignoreDirty = false;
|
|
122
435
|
if (!gitignoreContent.includes(".env*.yaml")) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
436
|
+
gitignoreContent +=
|
|
437
|
+
(gitignoreContent.endsWith("\n") || !gitignoreContent ? "" : "\n") + ".env*.yaml\n";
|
|
438
|
+
gitignoreDirty = true;
|
|
439
|
+
}
|
|
440
|
+
// TASK-170 (m-10): keep `.secrets.yaml` git-invisible. Older `.env*.yaml`
|
|
441
|
+
// pattern matched it accidentally; pin it explicitly so a future glob
|
|
442
|
+
// narrowing can't regress.
|
|
443
|
+
if (!gitignoreContent.includes(".secrets.yaml")) {
|
|
444
|
+
gitignoreContent += ".secrets.yaml\n";
|
|
445
|
+
gitignoreDirty = true;
|
|
446
|
+
}
|
|
447
|
+
// TASK-174 (m-10): identity values are not secrets but they identify
|
|
448
|
+
// the user's account; keep them out of git too.
|
|
449
|
+
if (!gitignoreContent.includes(".identity.yaml")) {
|
|
450
|
+
gitignoreContent += ".identity.yaml\n";
|
|
451
|
+
gitignoreDirty = true;
|
|
452
|
+
}
|
|
453
|
+
if (gitignoreDirty) {
|
|
454
|
+
writeFileSync(gitignorePath, gitignoreContent, "utf-8");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Seed `.secrets.yaml` placeholder once. The file lives gitignored
|
|
458
|
+
// alongside `.env.yaml`; values placed here are auto-registered with
|
|
459
|
+
// the SecretRegistry on load and never appear in artifacts.
|
|
460
|
+
const secretsPath = join(baseDir, ".secrets.yaml");
|
|
461
|
+
if (!existsSync(secretsPath)) {
|
|
462
|
+
const seedKeys = authVarNames.length > 0 ? authVarNames : ["auth_token"];
|
|
463
|
+
const lines = [
|
|
464
|
+
"# .secrets.yaml — gitignored, holds raw secret values.",
|
|
465
|
+
"# Reference these in .env.yaml as @secret:<key>.",
|
|
466
|
+
"# Values here are auto-registered for redaction in DB writes,",
|
|
467
|
+
"# HTML/JSON/JUnit reports, case-studies, and probe digests.",
|
|
468
|
+
];
|
|
469
|
+
for (const k of seedKeys) lines.push(`${k}: "" # required for live probes`);
|
|
470
|
+
lines.push("");
|
|
471
|
+
writeFileSync(secretsPath, lines.join("\n"), "utf-8");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// TASK-174 (m-10): seed `.identity.yaml` with placeholders for any
|
|
475
|
+
// canonical identity-keys that appear as path-params in the spec. The
|
|
476
|
+
// file is gitignored — values are visible locally for triage and
|
|
477
|
+
// hidden from outbound shares only when --redact-identity is set.
|
|
478
|
+
const identityKeys = [...pathParams.keys()].filter((k) =>
|
|
479
|
+
CANONICAL_IDENTITY_KEYS.has(k),
|
|
480
|
+
);
|
|
481
|
+
if (identityKeys.length > 0) {
|
|
482
|
+
const identityPath = join(baseDir, ".identity.yaml");
|
|
483
|
+
if (!existsSync(identityPath)) {
|
|
484
|
+
const lines = [
|
|
485
|
+
"# .identity.yaml — gitignored, holds non-secret-but-identifying values.",
|
|
486
|
+
"# Reference these in .env.yaml as @identity:<key>.",
|
|
487
|
+
"# Values are visible locally and in case-study drafts; pass",
|
|
488
|
+
"# --redact-identity (TASK-173) to swap them for placeholders when",
|
|
489
|
+
"# sharing reports outbound.",
|
|
490
|
+
];
|
|
491
|
+
for (const k of identityKeys.sort()) {
|
|
492
|
+
lines.push(`${k}: "" # fill with your ${k}`);
|
|
493
|
+
}
|
|
494
|
+
lines.push("");
|
|
495
|
+
writeFileSync(identityPath, lines.join("\n"), "utf-8");
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const workspaceRoot = findWorkspaceRoot().root;
|
|
500
|
+
|
|
501
|
+
// Snapshot the dereferenced spec into apis/<name>/spec.json so all later
|
|
502
|
+
// commands (catalog, describe, generate, probe-*) read a self-contained
|
|
503
|
+
// local file. The spec lives inside the workspace and is git-trackable;
|
|
504
|
+
// an external --spec path is only consulted at register/refresh time.
|
|
505
|
+
let localSpecAbsPath: string | null = null;
|
|
506
|
+
if (dereferencedDoc) {
|
|
507
|
+
localSpecAbsPath = join(baseDir, SPEC_SNAPSHOT_FILENAME);
|
|
508
|
+
writeArtifactsFromDoc({
|
|
509
|
+
doc: dereferencedDoc,
|
|
510
|
+
baseDir,
|
|
511
|
+
apiName: name,
|
|
512
|
+
baseUrl,
|
|
513
|
+
workspaceRoot,
|
|
514
|
+
});
|
|
128
515
|
}
|
|
129
516
|
|
|
130
517
|
const normalizedTestPath = normalizePath(testPath);
|
|
131
518
|
const normalizedBaseDir = normalizePath(baseDir);
|
|
132
519
|
|
|
520
|
+
// Persist the workspace-relative path to the local snapshot in
|
|
521
|
+
// collections.openapi_spec so we don't rely on the user's external path
|
|
522
|
+
// sticking around. Falls back to the external path only when the snapshot
|
|
523
|
+
// could not be created (no spec given to setupApi).
|
|
524
|
+
// Don't run normalizePath on the relative form — it calls resolve() and
|
|
525
|
+
// would re-absolutize the path. Posix-style separators are enough for
|
|
526
|
+
// SQLite + Windows compat.
|
|
527
|
+
const dbSpecPath = localSpecAbsPath
|
|
528
|
+
? relative(workspaceRoot, localSpecAbsPath).replace(/\\/g, "/")
|
|
529
|
+
: (openapiSpec ?? undefined);
|
|
530
|
+
|
|
133
531
|
// Create collection in DB
|
|
134
532
|
const collectionId = createCollection({
|
|
135
533
|
name,
|
|
136
534
|
base_dir: normalizedBaseDir,
|
|
137
535
|
test_path: normalizedTestPath,
|
|
138
|
-
openapi_spec:
|
|
536
|
+
openapi_spec: dbSpecPath,
|
|
139
537
|
});
|
|
140
538
|
|
|
141
539
|
const pathParamsObj = pathParams.size > 0 ? Object.fromEntries(pathParams) : undefined;
|
|
@@ -148,6 +546,7 @@ export async function setupApi(options: SetupApiOptions): Promise<SetupApiResult
|
|
|
148
546
|
baseUrl,
|
|
149
547
|
specEndpoints: endpointCount,
|
|
150
548
|
...(pathParamsObj ? { pathParams: pathParamsObj } : {}),
|
|
549
|
+
...(authVarNames.length > 0 ? { authVars: authVarNames } : {}),
|
|
151
550
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
152
551
|
};
|
|
153
552
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding category taxonomy (ARV-251, m-21 pivot).
|
|
3
|
+
*
|
|
4
|
+
* Four categories replace the old SARIF-internal trio
|
|
5
|
+
* (conformance/security/data-rejection/other). Each finding belongs to
|
|
6
|
+
* exactly one category. Categories drive the per-section roll-up in
|
|
7
|
+
* reports — a small team sees `0 security, 12 reliability, 40 contract,
|
|
8
|
+
* 200 hygiene` and knows where to start, instead of one flat HIGH/LOW
|
|
9
|
+
* pile.
|
|
10
|
+
*
|
|
11
|
+
* Definitions:
|
|
12
|
+
* - `security`: exploit / auth / data-exposure / injection signals.
|
|
13
|
+
* IDOR, mass-assignment with persistence, missing-auth, open CORS,
|
|
14
|
+
* reflected XSS / CRLF in dangerous context.
|
|
15
|
+
* - `reliability`: server crashes / 5xx on valid input / rate-limit
|
|
16
|
+
* absent / timeouts. Not security per se but production-impact.
|
|
17
|
+
* - `contract`: spec ↔ runtime drift. Schema mismatch, wrong status
|
|
18
|
+
* codes, content-type negotiation failures, data-rejection contract
|
|
19
|
+
* violations, missing required headers.
|
|
20
|
+
* - `hygiene`: static spec-lint, accept-without-impact, framework-level
|
|
21
|
+
* "could be intentional" signals, naming/style. Bulk volume lives here.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type Category = "security" | "reliability" | "contract" | "hygiene";
|
|
25
|
+
|
|
26
|
+
export const CATEGORY_ORDER: readonly Category[] = [
|
|
27
|
+
"security",
|
|
28
|
+
"reliability",
|
|
29
|
+
"contract",
|
|
30
|
+
"hygiene",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Category lookup by check-id / probe-class-id. Adding a new
|
|
35
|
+
* finding-producer requires extending this map — the SARIF + reporter
|
|
36
|
+
* tests assert full coverage so a missing entry fails loudly rather
|
|
37
|
+
* than silently routing to a fallback.
|
|
38
|
+
*/
|
|
39
|
+
export const CATEGORY_BY_ID: Record<string, Category> = {
|
|
40
|
+
// ── reliability ──────────────────────────────────────────────
|
|
41
|
+
// 5xx on valid input is a server crash, not a security issue.
|
|
42
|
+
not_a_server_error: "reliability",
|
|
43
|
+
|
|
44
|
+
// ── contract ─────────────────────────────────────────────────
|
|
45
|
+
// Spec-conformance checks. Server behaviour drifts from declared
|
|
46
|
+
// contract — fix-worthy, but rarely security per se.
|
|
47
|
+
status_code_conformance: "contract",
|
|
48
|
+
content_type_conformance: "contract",
|
|
49
|
+
response_headers_conformance: "contract",
|
|
50
|
+
response_schema_conformance: "contract",
|
|
51
|
+
missing_required_header: "contract",
|
|
52
|
+
unsupported_method: "contract",
|
|
53
|
+
// Data-rejection: server should reject malformed bodies per spec.
|
|
54
|
+
// Falls under contract (spec said "reject", server accepted).
|
|
55
|
+
negative_data_rejection: "contract",
|
|
56
|
+
positive_data_acceptance: "contract",
|
|
57
|
+
// m-20 state-aware probes — cross-resource contract invariants.
|
|
58
|
+
cross_call_references: "contract",
|
|
59
|
+
idempotency_replay: "contract",
|
|
60
|
+
pagination_invariants: "contract",
|
|
61
|
+
lifecycle_transitions: "contract",
|
|
62
|
+
// ARV-256 (m-21) — small-team value-add. Rate-limit absence is a
|
|
63
|
+
// production reliability concern, not a security exploit.
|
|
64
|
+
rate_limit_headers_absent: "reliability",
|
|
65
|
+
open_cors_on_sensitive: "security",
|
|
66
|
+
|
|
67
|
+
// ── security ─────────────────────────────────────────────────
|
|
68
|
+
ignored_auth: "security",
|
|
69
|
+
use_after_free: "security",
|
|
70
|
+
ensure_resource_availability: "security",
|
|
71
|
+
// Probe classes
|
|
72
|
+
"mass-assignment": "security",
|
|
73
|
+
ssrf: "security",
|
|
74
|
+
crlf: "security",
|
|
75
|
+
xss: "security",
|
|
76
|
+
sqli: "security",
|
|
77
|
+
"open-redirect": "security",
|
|
78
|
+
"path-traversal": "security",
|
|
79
|
+
webhooks: "security",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Map check-id / probe-class-id to category. Falls back to "hygiene"
|
|
84
|
+
* for unknown ids — the SARIF reporter & tests assert this fallback
|
|
85
|
+
* never triggers for registered checks/probes.
|
|
86
|
+
*/
|
|
87
|
+
export function categoryFor(id: string): Category {
|
|
88
|
+
return CATEGORY_BY_ID[id] ?? "hygiene";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function emptyCategoryBuckets(): Record<Category, number> {
|
|
92
|
+
return { security: 0, reliability: 0, contract: 0, hygiene: 0 };
|
|
93
|
+
}
|
|
94
|
+
|