@testsmith/api-spector 0.5.5 → 0.5.6

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.
@@ -1432,6 +1432,37 @@ const ADVERSARIAL_STRINGS = [
1432
1432
  ["unicode", "𝔘🙈\0�"],
1433
1433
  ["whitespace", " "]
1434
1434
  ];
1435
+ const INJECTION_PAYLOADS = [
1436
+ ["sql", "or-1-1", "' OR '1'='1"],
1437
+ ["sql", "comment", "'--"],
1438
+ ["nosql", "ne-null", '{"$ne": null}'],
1439
+ ["nosql", "gt-empty", '{"$gt": ""}'],
1440
+ ["command", "semicolon", "; id"],
1441
+ ["command", "subshell", "$(id)"],
1442
+ ["xss", "script", "<script>alert(1)<\/script>"],
1443
+ ["xss", "img-onerror", '"><img src=x onerror=alert(1)>'],
1444
+ ["traversal", "etc-passwd", "../../../../etc/passwd"],
1445
+ ["template", "mustache", "{{7*7}}"],
1446
+ ["template", "dollar", "${7*7}"],
1447
+ ["ldap", "wildcard", "*)(uid=*"],
1448
+ ["crlf", "header", "x\r\nX-Injected: 1"],
1449
+ ["xxe", "entity", '<!DOCTYPE t [<!ENTITY e SYSTEM "file:///etc/passwd">]><t>&e;</t>']
1450
+ ];
1451
+ const NAUGHTY_STRINGS = [
1452
+ ["emoji", "😍👩🏽‍💻🏳️‍🌈"],
1453
+ ["rtl-override", "‮txet desrever"],
1454
+ ["zalgo", "Z͢͡a̴͈l͖g̴o"],
1455
+ ["zero-width", "a​b‌c‍d\uFEFF"],
1456
+ ["format-string", "%s%s%s%n%x%x"],
1457
+ ["fullwidth", "12345"],
1458
+ ["reserved-word", "NaN"],
1459
+ ["bignum-string", "9".repeat(300)],
1460
+ ["null-char", "a\0b"],
1461
+ ["mixed-newlines", "a\r\nb\nc\rd"],
1462
+ ["quotes", "'\"`"],
1463
+ ["brace-soup", "${{{}}}[]()"],
1464
+ ["huge", "A".repeat(5e4)]
1465
+ ];
1435
1466
  function clone(v) {
1436
1467
  return JSON.parse(JSON.stringify(v));
1437
1468
  }
@@ -1450,7 +1481,7 @@ function deleteAtPath(root, path2) {
1450
1481
  delete cur[path2[path2.length - 1]];
1451
1482
  return copy;
1452
1483
  }
1453
- function mutate(baseline, schema, label) {
1484
+ function mutate(baseline, schema, label, level = "standard") {
1454
1485
  const cases = [];
1455
1486
  const walk = (schemaNode, path2) => {
1456
1487
  const target = [label, ...path2].join(".");
@@ -1467,9 +1498,14 @@ function mutate(baseline, schema, label) {
1467
1498
  });
1468
1499
  }
1469
1500
  }
1470
- if (schemaNode["additionalProperties"] === false && current) {
1501
+ if (current) {
1502
+ const forbidden = schemaNode["additionalProperties"] === false;
1471
1503
  cases.push({
1472
- mutation: { target: `${target}.__fuzz_extra`, kind: "unexpected-field", description: "add a field the schema forbids" },
1504
+ mutation: {
1505
+ target: `${target}.__fuzz_extra`,
1506
+ kind: forbidden ? "unexpected-field" : "extra-property",
1507
+ description: forbidden ? "add a field the schema forbids" : "add an unexpected extra field"
1508
+ },
1473
1509
  value: setAtPath(baseline, [...path2, "__fuzz_extra"], "unexpected")
1474
1510
  });
1475
1511
  }
@@ -1513,9 +1549,25 @@ function mutate(baseline, schema, label) {
1513
1549
  value: setAtPath(baseline, path2, "x".repeat(maxLen + 1))
1514
1550
  });
1515
1551
  }
1516
- for (const [name, str] of ADVERSARIAL_STRINGS) {
1552
+ if (level !== "basic") {
1553
+ for (const [name, str] of ADVERSARIAL_STRINGS) {
1554
+ cases.push({
1555
+ mutation: { target, kind: `adversarial:${name}`, description: `inject a ${name} string` },
1556
+ value: setAtPath(baseline, path2, str)
1557
+ });
1558
+ }
1559
+ }
1560
+ if (level === "aggressive") {
1561
+ for (const [cat, name, payload] of INJECTION_PAYLOADS) {
1562
+ cases.push({
1563
+ mutation: { target, kind: `injection:${cat}:${name}`, description: `inject a ${cat} probe` },
1564
+ value: setAtPath(baseline, path2, payload)
1565
+ });
1566
+ }
1567
+ }
1568
+ if (level !== "basic") for (const [name, str] of NAUGHTY_STRINGS) {
1517
1569
  cases.push({
1518
- mutation: { target, kind: `adversarial:${name}`, description: `inject a ${name} string` },
1570
+ mutation: { target, kind: `naughty:${name}`, description: `inject a naughty string (${name})` },
1519
1571
  value: setAtPath(baseline, path2, str)
1520
1572
  });
1521
1573
  }
@@ -1583,10 +1635,11 @@ function setParam(base, key, value) {
1583
1635
  function dropParam(base, key) {
1584
1636
  return base.filter((p) => p.key !== key);
1585
1637
  }
1586
- function mutateQueryParams(base, schemas = [], label = "query") {
1638
+ function mutateQueryParams(base, schemas = [], label = "query", level = "standard") {
1587
1639
  const cases = [];
1588
1640
  const schemaByName = new Map(schemas.map((s) => [s.name, s]));
1589
1641
  const target = (name) => `${label}.${name}`;
1642
+ const injectionProbes = /* @__PURE__ */ new Set(["sql", "xss", "traversal", "null-byte"]);
1590
1643
  for (const s of schemas) {
1591
1644
  if (s.required && base.some((p) => p.enabled && p.key === s.name)) {
1592
1645
  cases.push({
@@ -1614,8 +1667,11 @@ function mutateQueryParams(base, schemas = [], label = "query") {
1614
1667
  if (typeof min === "number") cases.push({ mutation: { target: t, kind: "below-minimum", description: `value below minimum (${min})` }, params: setParam(base, p.key, String(min - 1)) });
1615
1668
  if (typeof max === "number") cases.push({ mutation: { target: t, kind: "above-maximum", description: `value above maximum (${max})` }, params: setParam(base, p.key, String(max + 1)) });
1616
1669
  }
1617
- for (const [name, val] of QUERY_ADVERSARIAL) {
1618
- cases.push({ mutation: { target: t, kind: `adversarial:${name}`, description: `inject a ${name} value` }, params: setParam(base, p.key, val) });
1670
+ if (level !== "basic") {
1671
+ for (const [name, val] of QUERY_ADVERSARIAL) {
1672
+ if (injectionProbes.has(name) && level !== "aggressive") continue;
1673
+ cases.push({ mutation: { target: t, kind: `adversarial:${name}`, description: `inject a ${name} value` }, params: setParam(base, p.key, val) });
1674
+ }
1619
1675
  }
1620
1676
  }
1621
1677
  cases.push({
@@ -1647,6 +1703,46 @@ function inferSchema(value) {
1647
1703
  return { type: "string" };
1648
1704
  }
1649
1705
  }
1706
+ function duplicateKeyBody(baseline) {
1707
+ if (!baseline || typeof baseline !== "object" || Array.isArray(baseline)) return null;
1708
+ const entries = Object.entries(baseline);
1709
+ if (entries.length === 0) return null;
1710
+ const parts = entries.map(([k, v]) => `${JSON.stringify(k)}:${JSON.stringify(v)}`);
1711
+ const [firstKey, firstVal] = entries[0];
1712
+ const dupVal = typeof firstVal === "string" ? `${firstVal}-dup` : "__duplicate__";
1713
+ parts.push(`${JSON.stringify(firstKey)}:${JSON.stringify(dupVal)}`);
1714
+ return `{${parts.join(",")}}`;
1715
+ }
1716
+ function mutateHeaders(base, label = "header") {
1717
+ const withHeader = (key, value) => [
1718
+ ...base.filter((h) => h.key.toLowerCase() !== key.toLowerCase()),
1719
+ { key, value, enabled: true }
1720
+ ];
1721
+ const cases = [];
1722
+ const contentTypes = [
1723
+ ["multipart-mixed", "multipart/mixed"],
1724
+ ["text-plain", "text/plain"],
1725
+ ["xml", "application/xml"],
1726
+ ["empty", ""],
1727
+ ["wildcard", "*/*"],
1728
+ ["bad-charset", "application/json; charset=not-a-charset"]
1729
+ ];
1730
+ for (const [name, ct] of contentTypes) {
1731
+ cases.push({
1732
+ mutation: { target: `${label}.Content-Type`, kind: `header:content-type:${name}`, description: `tamper Content-Type (${ct || "empty"})` },
1733
+ headers: withHeader("Content-Type", ct)
1734
+ });
1735
+ }
1736
+ cases.push({
1737
+ mutation: { target: `${label}.Accept`, kind: "header:accept-unsatisfiable", description: "demand an unsatisfiable Accept" },
1738
+ headers: withHeader("Accept", "application/x-does-not-exist")
1739
+ });
1740
+ cases.push({
1741
+ mutation: { target: `${label}.X-Fuzz-Overflow`, kind: "header:oversized", description: "send an oversized header value" },
1742
+ headers: [...base, { key: "X-Fuzz-Overflow", value: "A".repeat(8192), enabled: true }]
1743
+ });
1744
+ return cases;
1745
+ }
1650
1746
  function sampleApplied(arr, n, rng) {
1651
1747
  if (arr.length <= n) return arr;
1652
1748
  const a = [...arr];
@@ -1723,6 +1819,7 @@ async function runFuzz(opts) {
1723
1819
  const spec = opts.specUrl || opts.specPath ? await loadSpec(opts.specUrl, opts.specPath) : null;
1724
1820
  const seed = opts.seed ?? 1;
1725
1821
  const casesPerOp = opts.casesPerOperation ?? 40;
1822
+ const level = opts.level ?? "standard";
1726
1823
  const vars = requestExec.mergeVars(opts.envVars, opts.collectionVars ?? {}, {}, {}, await requestExec.buildDynamicVars());
1727
1824
  const dispatcher = await requestExec.buildDispatcher(void 0, void 0);
1728
1825
  const start = Date.now();
@@ -1767,13 +1864,30 @@ async function runFuzz(opts) {
1767
1864
  }
1768
1865
  const applied = [];
1769
1866
  if (hasBody) {
1770
- for (const c of mutate(baselineBody, bodySchema, "body")) {
1867
+ for (const c of mutate(baselineBody, bodySchema, "body", level)) {
1771
1868
  applied.push({ mutation: c.mutation, bodyJson: JSON.stringify(c.value), bodyValue: c.value, params: baselineParams, isBody: true });
1772
1869
  }
1870
+ if (level !== "basic") {
1871
+ const dupRaw = duplicateKeyBody(baselineBody);
1872
+ if (dupRaw) {
1873
+ applied.push({
1874
+ mutation: { target: "body", kind: "duplicate-key", description: "send the same JSON key twice" },
1875
+ bodyJson: dupRaw,
1876
+ bodyValue: baselineBody,
1877
+ params: baselineParams,
1878
+ isBody: true
1879
+ });
1880
+ }
1881
+ }
1773
1882
  }
1774
- for (const c of mutateQueryParams(baselineParams, specCtx?.queryParams ?? [], "query")) {
1883
+ for (const c of mutateQueryParams(baselineParams, specCtx?.queryParams ?? [], "query", level)) {
1775
1884
  applied.push({ mutation: c.mutation, bodyJson: baselineBodyJson, params: c.params, isBody: false });
1776
1885
  }
1886
+ if (level === "aggressive") {
1887
+ for (const hc of mutateHeaders(req.headers ?? [], "header")) {
1888
+ applied.push({ mutation: hc.mutation, bodyJson: baselineBodyJson, params: baselineParams, isBody: false, headers: hc.headers });
1889
+ }
1890
+ }
1777
1891
  const cases = sampleApplied(applied, casesPerOp, makeRng(seed + 1));
1778
1892
  const findings = [];
1779
1893
  const trace = opts.trace ? [] : void 0;
@@ -1788,6 +1902,7 @@ async function runFuzz(opts) {
1788
1902
  const fuzzReq = {
1789
1903
  ...req,
1790
1904
  params: c.params,
1905
+ ...c.headers ? { headers: c.headers } : {},
1791
1906
  body: c.bodyJson !== void 0 ? { mode: "json", json: c.bodyJson } : req.body
1792
1907
  };
1793
1908
  const resolvedUrl = requestExec.rebaseUrl(requestExec.buildUrl(fuzzReq.url, fuzzReq.params, vars), opts.providerBaseUrl);
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
  const promises = require("fs/promises");
4
4
  const path = require("path");
5
- const snapshots = require("./chunks/snapshots-CN8g7lqA.js");
5
+ const snapshots = require("./chunks/snapshots-CcvHHTnQ.js");
6
6
  const crypto = require("crypto");
7
7
  const undici = require("undici");
8
8
  const os = require("os");
@@ -602,9 +602,13 @@ async function cmdFuzz(args) {
602
602
  process.exit(2);
603
603
  }
604
604
  const includeWrites = Boolean(args["include-writes"]);
605
- console.log(` Fuzzing ${allRequests.length} request(s)${specUrl || specPath ? " against the spec" : " from request bodies"}...`);
605
+ const levelArg = typeof args["level"] === "string" ? args["level"].toLowerCase() : "standard";
606
+ const level = ["basic", "standard", "aggressive"].includes(levelArg) ? levelArg : "standard";
607
+ console.log(` Fuzzing ${allRequests.length} request(s)${specUrl || specPath ? " against the spec" : " from request bodies"} at the ${level} level...`);
608
+ if (level === "aggressive") console.log(" Aggressive: includes injection probes (SQL/NoSQL/…) and header tampering. Only run against systems you own.");
606
609
  if (!includeWrites) console.log(" Write methods (POST/PUT/PATCH/DELETE) are skipped. Add --include-writes to fuzz them (sends malformed writes; target staging or a mock).");
607
610
  const report = await snapshots.runFuzz({
611
+ level,
608
612
  requests: allRequests,
609
613
  envVars,
610
614
  collectionVars,
@@ -931,7 +935,7 @@ async function main() {
931
935
  api-spector contract record-deployment --workspace <path> --pacticipant <name> --app-version <ver> --env <name>
932
936
  api-spector contract environments --workspace <path>
933
937
  api-spector contract webhooks --workspace <path> [--test]
934
- api-spector contract fuzz --workspace <path> --provider-base-url <url> [--snapshot <id> | --spec-url <url>] [--cases <n>] [--seed <n>] [--include-writes] [--trace] [--html <path>]
938
+ api-spector contract fuzz --workspace <path> --provider-base-url <url> [--snapshot <id> | --spec-url <url>] [--level basic|standard|aggressive] [--cases <n>] [--seed <n>] [--include-writes] [--trace] [--html <path>]
935
939
  api-spector contract pact-import --file <pact.json> [--out <collection.json>]
936
940
  api-spector contract pact-export --workspace <path> --out <pact.json> [--consumer <name> --provider <name> --collection <name>]
937
941
 
package/out/main/index.js CHANGED
@@ -45,7 +45,7 @@ const protoLoader = require("@grpc/proto-loader");
45
45
  const soapHandler = require("./chunks/soap-handler-DCCE344x.js");
46
46
  const promises$1 = require("node:fs/promises");
47
47
  const os$1 = require("os");
48
- const snapshots = require("./chunks/snapshots-CN8g7lqA.js");
48
+ const snapshots = require("./chunks/snapshots-CcvHHTnQ.js");
49
49
  const simpleGit = require("simple-git");
50
50
  const recorder = require("./chunks/recorder-0Ij921El.js");
51
51
  require("tls");
@@ -5941,7 +5941,7 @@ function isNewer(a, b) {
5941
5941
  return a2 > b2;
5942
5942
  }
5943
5943
  async function checkForUpdate() {
5944
- const current = "0.5.5";
5944
+ const current = "0.5.6";
5945
5945
  try {
5946
5946
  const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(4e3) });
5947
5947
  if (!res.ok) return null;
@@ -402,7 +402,7 @@ async function main() {
402
402
  } else if (!envName && workspace.settings?.defaultEnvironment && !env) {
403
403
  console.warn(cliCommon.color(`Warning: default environment "${workspace.settings.defaultEnvironment}" not found. Running without environment.`, cliCommon.C.yellow));
404
404
  }
405
- const version = `v${"0.5.5"}`;
405
+ const version = `v${"0.5.6"}`;
406
406
  console.log("");
407
407
  console.log(cliCommon.color(" API Test Runner" + (version ? ` ${version}` : ""), cliCommon.C.bold, cliCommon.C.white));
408
408
  console.log(cliCommon.color(` Workspace: ${wsPath}`, cliCommon.C.gray));
@@ -1,4 +1,4 @@
1
- import { l as linter, E as EditorView, L as LanguageSupport, a as LRLanguage, s as styleTags, i as indentNodeProp, d as delimitedIndent, f as foldNodeProp, b as foldInside, S as StateField, c as LRParser, e as StateEffect, t as tags, u as useT, r as reactExports, g as useStore, h as useActiveEnvironment, j as jsxRuntimeExports, R as ReactCodeMirror, o as oneDark, k as json } from "./index-e3ZM7oe9.js";
1
+ import { l as linter, E as EditorView, L as LanguageSupport, a as LRLanguage, s as styleTags, i as indentNodeProp, d as delimitedIndent, f as foldNodeProp, b as foldInside, S as StateField, c as LRParser, e as StateEffect, t as tags, u as useT, r as reactExports, g as useStore, h as useActiveEnvironment, j as jsxRuntimeExports, R as ReactCodeMirror, o as oneDark, k as json } from "./index-BKJHj4_d.js";
2
2
  function devAssert(condition, message) {
3
3
  const booleanCondition = Boolean(condition);
4
4
  if (!booleanCondition) {
@@ -1,4 +1,4 @@
1
- import { u as useT, r as reactExports, j as jsxRuntimeExports, R as ReactCodeMirror, m as commentKeymap, o as oneDark, x as xml } from "./index-e3ZM7oe9.js";
1
+ import { u as useT, r as reactExports, j as jsxRuntimeExports, R as ReactCodeMirror, m as commentKeymap, o as oneDark, x as xml } from "./index-BKJHj4_d.js";
2
2
  const SOAP_11_CONTENT_TYPE = "text/xml; charset=utf-8";
3
3
  const SOAP_12_CONTENT_TYPE = "application/soap+xml; charset=utf-8";
4
4
  function contentTypeForSoap(version) {