@prodcycle/prodcycle 0.6.2 → 0.6.4

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.
@@ -322,6 +322,7 @@ class ComplianceApiClient {
322
322
  let lastError = null;
323
323
  for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
324
324
  let response;
325
+ let responseText;
325
326
  try {
326
327
  response = await fetch(url, {
327
328
  method,
@@ -332,11 +333,23 @@ class ComplianceApiClient {
332
333
  ...(data !== null ? { body: JSON.stringify(data) } : {}),
333
334
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
334
335
  });
336
+ // Body read inside the same try/catch as fetch() because undici can
337
+ // throw mid-stream (ALB drops the connection, abort signal fires
338
+ // during body read, server sends a partial response). Pre-fix this
339
+ // leaked out of `request()` as an unhandled error instead of being
340
+ // retried — long chunked-session scans were especially exposed,
341
+ // since every `appendChunk` call is its own request and any one
342
+ // mid-stream drop torpedoed the whole scan. The chunk write is
343
+ // idempotent on the server (unique `(scan_id, fingerprint)` index),
344
+ // so retry is safe. Mirror of the Python client's catch over
345
+ // `OSError, http.client.HTTPException` — keep both in lockstep.
346
+ responseText = await response.text();
335
347
  }
336
348
  catch (networkErr) {
337
- // Connection-level failures (DNS, TCP, TLS). Treat as retryable up
338
- // to the same cap as 503 the server may be momentarily down or
339
- // the network blip may resolve.
349
+ // Connection-level failures (DNS, TCP, TLS) OR body-read-level
350
+ // failures (abort, RST mid-stream). Treat as retryable up to the
351
+ // same cap as 503 — the server may be momentarily down or the
352
+ // network blip may resolve.
340
353
  lastError =
341
354
  networkErr instanceof Error ? networkErr : new Error(String(networkErr));
342
355
  if (attempt < MAX_RETRY_ATTEMPTS - 1) {
@@ -345,7 +358,6 @@ class ComplianceApiClient {
345
358
  }
346
359
  throw new Error(`Failed to connect to ProdCycle API: ${lastError.message}`);
347
360
  }
348
- const responseText = await response.text();
349
361
  let parsed = null;
350
362
  try {
351
363
  parsed = responseText ? JSON.parse(responseText) : null;
package/dist/utils/fs.js CHANGED
@@ -38,6 +38,12 @@ const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const minimatch_1 = require("minimatch");
40
40
  const MAX_FILE_SIZE = 256 * 1024; // 256 KB
41
+ // Reusable strict UTF-8 decoder. `TextDecoder` is stateless on the same
42
+ // instance (encoding + fatal are fixed at construction), so we allocate
43
+ // once at module load instead of once per file in the walk loop —
44
+ // repos with thousands of files would otherwise produce thousands of
45
+ // short-lived decoder objects per scan.
46
+ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
41
47
  /**
42
48
  * Total file ceiling per scan. Hit on the OSS-CLI benchmark scanning
43
49
  * `hapifhir/hapi-fhir` (~13k files) — the CLI silently dropped ~3k files
@@ -397,18 +403,31 @@ function walk(dir, repoRoot, gitignores, prodcycleIgnores, includePatterns, user
397
403
  }
398
404
  if (isBinary(buffer))
399
405
  continue;
400
- const content = buffer.toString('utf8');
401
- // Post-decode filter: the service enforces the 256 KB per-file limit
402
- // on the UTF-8 byte length of the decoded string content, which can
403
- // differ from the file's on-disk byte count. `buffer.toString('utf8')`
404
- // silently replaces invalid UTF-8 byte sequences with U+FFFD (3 UTF-8
405
- // bytes each), so a file with invalid bytes that's under 256 KB on
406
- // disk can balloon over the limit after the round trip. The cheap
407
- // `stats.size` check above would let it through; the service then
408
- // rejects the entire chunk with 413 and torpedoes the scan.
409
- // Re-measuring here keeps the CLI's filter aligned with the service
410
- // enforcement. Concrete case from the GA-validation sweep:
411
- // `web/pnpm-lock.yaml` with stray non-UTF-8 bytes.
406
+ // Strict UTF-8 decode: throw (and skip the file) on invalid byte
407
+ // sequences. Pre-fix this was `buffer.toString('utf8')`, which
408
+ // silently replaces invalid bytes with U+FFFD and includes the file
409
+ // anyway. Python's `open(encoding='utf-8')` raises UnicodeDecodeError
410
+ // on the same input and skips the file via its except clause, so
411
+ // Node ended up sending files Python wouldn't. Those files were
412
+ // overwhelmingly garbage (U+FFFD soup with no real content), but
413
+ // the inflated payload pushed many scans over the sync /validate
414
+ // limit and into the chunked-session fallback Node ran 5–75x
415
+ // slower than Python on the same repos (dexidp-dex: npm=525s vs
416
+ // py=7s, frappe-erpnext: 1448s vs 42s, both 0 finding differences)
417
+ // during the 2026-05-12 GA-validation sweep. Catch the decode
418
+ // error and treat exactly like Python.
419
+ let content;
420
+ try {
421
+ content = UTF8_DECODER.decode(buffer);
422
+ }
423
+ catch {
424
+ continue;
425
+ }
426
+ // Post-decode size filter mirrors the service's 256 KB per-file
427
+ // enforcement. Without invalid-UTF-8 inflation (now skipped above),
428
+ // post-decode byte length usually matches `stats.size`, but a BOM
429
+ // or rare normalization edge case can still differ — keep the
430
+ // re-measure as defense-in-depth.
412
431
  if (Buffer.byteLength(content, 'utf8') > MAX_FILE_SIZE)
413
432
  continue;
414
433
  files[relPath] = content;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Multi-framework policy-as-code compliance scanner for infrastructure and application code.",
5
5
  "homepage": "https://docs.prodcycle.com",
6
6
  "repository": {