@scanverra/sdk 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,37 @@
1
- # Temporary Holding Version
1
+ # @scanverra/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small, dependency-free client for the [Scanverra API](https://www.scanverra.com/docs). Node 18+, Deno and Bun.
4
+
5
+ ```ts
6
+ import { Scanverra, verifyWebhook } from "@scanverra/sdk";
7
+
8
+ const scanverra = new Scanverra({ apiKey: process.env.SCANVERRA_API_KEY! });
9
+
10
+ // The Website Audit answers with the finished report.
11
+ const audit = await scanverra.scans.audit({ url: "https://example.com" });
12
+
13
+ // Security, Browser and SEO scans answer with an id; wait() polls until the report is ready.
14
+ const { scanId } = await scanverra.scans.security({ url: "https://example.com" });
15
+ const report = await scanverra.wait("security", scanId);
16
+
17
+ // Properties: each product's sites and repos, scored together.
18
+ const { properties } = await scanverra.properties.list();
19
+ const detail = await scanverra.properties.get(properties[0].id);
20
+
21
+ // Subscribe to events (REST hooks). Keep the secret to verify deliveries.
22
+ const hook = await scanverra.hooks.create({ event: "remediation.task_created", url: "https://example.com/hooks/scanverra" });
23
+
24
+ // In your receiver, with the raw request body:
25
+ const ok = await verifyWebhook({
26
+ secret: hook.secret,
27
+ body: rawBody,
28
+ timestamp: req.headers["x-scanverra-timestamp"],
29
+ signature: req.headers["x-scanverra-signature"],
30
+ });
31
+ ```
32
+
33
+ Errors throw `ScanverraError` with `status`, `code` and the response `body`. A scan refused for quota answers 402 with `code` set.
34
+
35
+ Events: `scan.score_dropped`, `scan.policy_failed`, `scan.completed`, `payment_page.scripts_changed`, `attack_surface.changed`, `remediation.task_created`, `remediation.task_resolved`, `property.score_changed`. Payloads are described at https://www.scanverra.com/docs/integrations.
36
+
37
+ Build: `npm run build` (writes `dist/`).
@@ -0,0 +1,186 @@
1
+ export type ScanverraEvent = "scan.score_dropped" | "scan.policy_failed" | "scan.completed" | "payment_page.scripts_changed" | "attack_surface.changed" | "remediation.task_created" | "remediation.task_resolved" | "property.score_changed";
2
+ export interface ScanverraOptions {
3
+ apiKey: string;
4
+ /** Defaults to https://www.scanverra.com */
5
+ baseUrl?: string;
6
+ /** Per-request timeout; scans run synchronously and can take a few minutes. Default 300000. */
7
+ timeoutMs?: number;
8
+ fetch?: typeof fetch;
9
+ }
10
+ export declare class ScanverraError extends Error {
11
+ status: number;
12
+ code: string | undefined;
13
+ body: unknown;
14
+ constructor(status: number, code: string | undefined, message: string, body: unknown);
15
+ }
16
+ /** A scan as the result endpoints return it; fields differ per tool (see /docs/api). */
17
+ export interface ScanResult {
18
+ id?: string;
19
+ status: "pending" | "running" | "done" | "completed" | "failed" | string;
20
+ errorMessage?: string | null;
21
+ [field: string]: unknown;
22
+ }
23
+ export interface PropertyToolScore {
24
+ tool: string;
25
+ score: number;
26
+ change: number | null;
27
+ scanned: number;
28
+ of: number;
29
+ at: string;
30
+ }
31
+ export interface PropertySummary {
32
+ id: string;
33
+ name: string;
34
+ category: string;
35
+ client: string | null;
36
+ score: number | null;
37
+ lastScannedAt: string | null;
38
+ openTasks: number;
39
+ targets: {
40
+ target: string;
41
+ kind: "site" | "repo";
42
+ }[];
43
+ tools: PropertyToolScore[];
44
+ }
45
+ export interface PropertyDetail extends Omit<PropertySummary, "openTasks"> {
46
+ trend: {
47
+ day: string;
48
+ score: number;
49
+ }[];
50
+ issues: {
51
+ title: string;
52
+ severity: string;
53
+ tools: string[];
54
+ targets: string[];
55
+ places: number;
56
+ task: {
57
+ id: string;
58
+ status: string;
59
+ assignee: string | null;
60
+ } | null;
61
+ url: string;
62
+ }[];
63
+ nextSteps: {
64
+ kind: string;
65
+ tool: string;
66
+ target: string | null;
67
+ url: string | null;
68
+ }[];
69
+ }
70
+ export interface Hook {
71
+ id: string;
72
+ event: ScanverraEvent;
73
+ url: string;
74
+ source: string;
75
+ lastDeliveryAt: string | null;
76
+ lastStatus: number | null;
77
+ createdAt: string;
78
+ }
79
+ export interface Delivery<T = Record<string, unknown>> {
80
+ event: ScanverraEvent | "test";
81
+ sentAt: string;
82
+ summary: string;
83
+ data: T;
84
+ hookId?: string;
85
+ }
86
+ export declare class Scanverra {
87
+ private readonly options;
88
+ private readonly baseUrl;
89
+ private readonly fetchImpl;
90
+ constructor(options: ScanverraOptions);
91
+ request<T>(method: "GET" | "POST" | "DELETE", path: string, body?: unknown): Promise<T>;
92
+ /**
93
+ * Start a scan. The Website Audit and Repo Scanner answer with the finished
94
+ * report (pass `async: true` to get an id at once instead). The Security
95
+ * Scan, Browser Audit and SEO Optimizer always answer with an id straight
96
+ * away: read the report with `results.*` or wait for it with `wait()`.
97
+ */
98
+ readonly scans: {
99
+ audit: (body: {
100
+ url: string;
101
+ budget?: Record<string, unknown>;
102
+ async?: boolean;
103
+ }) => Promise<Record<string, unknown>>;
104
+ security: (body: {
105
+ url: string;
106
+ }) => Promise<{
107
+ scanId: string;
108
+ }>;
109
+ browser: (body: {
110
+ url: string;
111
+ credentialId?: string;
112
+ }) => Promise<{
113
+ testId: string;
114
+ }>;
115
+ seo: (body: {
116
+ url: string;
117
+ keyword?: string;
118
+ competitorUrl?: string;
119
+ renderJs?: boolean;
120
+ checkPerformance?: boolean;
121
+ }) => Promise<{
122
+ scanId: string;
123
+ }>;
124
+ repo: (body: {
125
+ owner: string;
126
+ repo: string;
127
+ branch?: string;
128
+ provider?: "github" | "gitlab" | "bitbucket" | "azure";
129
+ async?: boolean;
130
+ }) => Promise<{
131
+ scanId: string;
132
+ }>;
133
+ };
134
+ /** Read a scan by id. `status` is "running" until it is "done"/"completed" or "failed". */
135
+ readonly results: {
136
+ audit: (id: string) => Promise<ScanResult>;
137
+ security: (id: string) => Promise<ScanResult>;
138
+ browser: (id: string) => Promise<ScanResult>;
139
+ seo: (id: string) => Promise<ScanResult>;
140
+ repo: (id: string) => Promise<ScanResult>;
141
+ };
142
+ /**
143
+ * Polls a scan until it finishes and returns the report. Throws a
144
+ * ScanverraError with code "scan_failed" when the scan failed, or
145
+ * "timeout" after `timeoutMs` (default 10 minutes).
146
+ */
147
+ wait(tool: keyof Scanverra["results"], id: string, { intervalMs, timeoutMs }?: {
148
+ intervalMs?: number;
149
+ timeoutMs?: number;
150
+ }): Promise<ScanResult>;
151
+ readonly properties: {
152
+ list: () => Promise<{
153
+ properties: PropertySummary[];
154
+ }>;
155
+ get: (id: string) => Promise<PropertyDetail>;
156
+ };
157
+ readonly hooks: {
158
+ list: () => Promise<{
159
+ hooks: Hook[];
160
+ }>;
161
+ /** The returned secret signs every delivery; it is shown only once. */
162
+ create: (body: {
163
+ event: ScanverraEvent;
164
+ url: string;
165
+ source?: "zapier" | "make" | "n8n" | "api";
166
+ }) => Promise<{
167
+ id: string;
168
+ secret: string;
169
+ }>;
170
+ delete: (id: string) => Promise<{
171
+ ok: true;
172
+ }>;
173
+ sample: (event: ScanverraEvent) => Promise<Delivery<Record<string, unknown>>[]>;
174
+ };
175
+ }
176
+ /**
177
+ * Checks a delivery's X-Scanverra-Signature against the raw body and its
178
+ * X-Scanverra-Timestamp (rejects anything older than `toleranceSeconds`).
179
+ */
180
+ export declare function verifyWebhook(input: {
181
+ secret: string;
182
+ body: string;
183
+ timestamp: string | null;
184
+ signature: string | null;
185
+ toleranceSeconds?: number;
186
+ }): Promise<boolean>;
package/dist/index.js ADDED
@@ -0,0 +1,119 @@
1
+ // @scanverra/sdk: a small typed client for the Scanverra API. No dependencies:
2
+ // it uses the global fetch (Node 18+, Deno, Bun, browsers for public calls)
3
+ // and Web Crypto for webhook signatures.
4
+ export class ScanverraError extends Error {
5
+ status;
6
+ code;
7
+ body;
8
+ constructor(status, code, message, body) {
9
+ super(message);
10
+ this.status = status;
11
+ this.code = code;
12
+ this.body = body;
13
+ this.name = "ScanverraError";
14
+ }
15
+ }
16
+ export class Scanverra {
17
+ options;
18
+ baseUrl;
19
+ fetchImpl;
20
+ constructor(options) {
21
+ this.options = options;
22
+ if (!options.apiKey)
23
+ throw new Error("Scanverra: apiKey is required");
24
+ this.baseUrl = (options.baseUrl ?? "https://www.scanverra.com").replace(/\/+$/, "");
25
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
26
+ }
27
+ async request(method, path, body) {
28
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
29
+ method,
30
+ headers: { Authorization: `Bearer ${this.options.apiKey}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
31
+ body: body === undefined ? undefined : JSON.stringify(body),
32
+ signal: AbortSignal.timeout(this.options.timeoutMs ?? 300_000),
33
+ });
34
+ const text = await res.text();
35
+ let data = null;
36
+ try {
37
+ data = text ? JSON.parse(text) : null;
38
+ }
39
+ catch {
40
+ data = text;
41
+ }
42
+ if (!res.ok) {
43
+ const err = (data ?? {});
44
+ throw new ScanverraError(res.status, err.code, err.error ?? `Scanverra answered ${res.status}`, data);
45
+ }
46
+ return data;
47
+ }
48
+ /**
49
+ * Start a scan. The Website Audit and Repo Scanner answer with the finished
50
+ * report (pass `async: true` to get an id at once instead). The Security
51
+ * Scan, Browser Audit and SEO Optimizer always answer with an id straight
52
+ * away: read the report with `results.*` or wait for it with `wait()`.
53
+ */
54
+ scans = {
55
+ audit: (body) => this.request("POST", "/api/audit", body),
56
+ security: (body) => this.request("POST", "/api/security-scan", body),
57
+ browser: (body) => this.request("POST", "/api/browser-test", body),
58
+ seo: (body) => this.request("POST", "/api/seo-optimizer", body),
59
+ repo: (body) => this.request("POST", "/api/repo/scan", body),
60
+ };
61
+ /** Read a scan by id. `status` is "running" until it is "done"/"completed" or "failed". */
62
+ results = {
63
+ audit: (id) => this.request("GET", `/api/reports/${encodeURIComponent(id)}`),
64
+ security: (id) => this.request("GET", `/api/security-scan?id=${encodeURIComponent(id)}`),
65
+ browser: (id) => this.request("GET", `/api/browser-test?id=${encodeURIComponent(id)}`),
66
+ seo: (id) => this.request("GET", `/api/seo-optimizer?id=${encodeURIComponent(id)}`),
67
+ repo: (id) => this.request("GET", `/api/repo/result?id=${encodeURIComponent(id)}`),
68
+ };
69
+ /**
70
+ * Polls a scan until it finishes and returns the report. Throws a
71
+ * ScanverraError with code "scan_failed" when the scan failed, or
72
+ * "timeout" after `timeoutMs` (default 10 minutes).
73
+ */
74
+ async wait(tool, id, { intervalMs = 4000, timeoutMs = 600_000 } = {}) {
75
+ const deadline = Date.now() + timeoutMs;
76
+ for (;;) {
77
+ const result = await this.results[tool](id);
78
+ if (result.status === "failed")
79
+ throw new ScanverraError(200, "scan_failed", String(result.errorMessage ?? "The scan failed"), result);
80
+ if (result.status !== "running" && result.status !== "pending")
81
+ return result;
82
+ if (Date.now() > deadline)
83
+ throw new ScanverraError(0, "timeout", "The scan didn't finish in time", result);
84
+ await new Promise((r) => setTimeout(r, intervalMs));
85
+ }
86
+ }
87
+ properties = {
88
+ list: () => this.request("GET", "/api/v1/properties"),
89
+ get: (id) => this.request("GET", `/api/v1/properties/${encodeURIComponent(id)}`),
90
+ };
91
+ hooks = {
92
+ list: () => this.request("GET", "/api/hooks"),
93
+ /** The returned secret signs every delivery; it is shown only once. */
94
+ create: (body) => this.request("POST", "/api/hooks", body),
95
+ delete: (id) => this.request("DELETE", `/api/hooks/${encodeURIComponent(id)}`),
96
+ sample: (event) => this.request("GET", `/api/hooks/sample?event=${encodeURIComponent(event)}`),
97
+ };
98
+ }
99
+ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
100
+ /**
101
+ * Checks a delivery's X-Scanverra-Signature against the raw body and its
102
+ * X-Scanverra-Timestamp (rejects anything older than `toleranceSeconds`).
103
+ */
104
+ export async function verifyWebhook(input) {
105
+ const { secret, body, timestamp, signature } = input;
106
+ if (!timestamp || !signature?.startsWith("sha256="))
107
+ return false;
108
+ if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > (input.toleranceSeconds ?? 300))
109
+ return false;
110
+ const enc = new TextEncoder();
111
+ const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
112
+ const expected = `sha256=${hex(await crypto.subtle.sign("HMAC", key, enc.encode(`${timestamp}.${body}`)))}`;
113
+ if (expected.length !== signature.length)
114
+ return false;
115
+ let diff = 0;
116
+ for (let i = 0; i < expected.length; i++)
117
+ diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
118
+ return diff === 0;
119
+ }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@scanverra/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Typed client for the Scanverra API: run scans, read properties and subscribe to events.",
5
+ "license": "UNLICENSED",
6
+ "homepage": "https://www.scanverra.com/docs",
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
11
+ "files": ["dist", "README.md"],
12
+ "engines": { "node": ">=18" },
13
+ "publishConfig": { "access": "public" },
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "devDependencies": {
19
+ "typescript": "^5.6.0"
20
+ }
21
+ }