Skip to content

feat: implement RFC npm#897 — npm approve-scripts review-report mode#1

Open
vbjay with Copilot wants to merge 357 commits into
approveScripts-Reportfrom
copilot/implement-rfc-897-plan
Open

feat: implement RFC npm#897 — npm approve-scripts review-report mode#1
vbjay with Copilot wants to merge 357 commits into
approveScripts-Reportfrom
copilot/implement-rfc-897-plan

Conversation

Copilot AI commented Jun 13, 2026

Copy link
Copy Markdown

feat: implement RFC npm#897 — npm approve-scripts review-report mode

Summary

Implements RFC #897: adds a first-class review-report mode to npm approve-scripts --allow-scripts-pending that turns the existing pending-script listing into a structured, auditable report suitable for human review or AI-assisted analysis.


Configuration & Arguments

--allow-scripts-report-format

Controls the output format when --allow-scripts-pending is used.

Value Description
markdown (default) Human-readable Markdown document — suitable for PR comments, file artifacts, or pasting into a code review
json Machine-readable JSON document — suitable for CI pipelines, dependency bots, or AI-assisted security review
null Opt out of the review report; falls back to the legacy compact plain-text listing

Usage:

# Default: Markdown report
npm approve-scripts --allow-scripts-pending

# Explicit Markdown
npm approve-scripts --allow-scripts-pending --allow-scripts-report-format=markdown

# JSON report
npm approve-scripts --allow-scripts-pending --allow-scripts-report-format=json

# Legacy plain-text listing (opt out of report)
npm approve-scripts --allow-scripts-pending --allow-scripts-report-format=null

# --json is an alias for --allow-scripts-report-format=json
npm approve-scripts --allow-scripts-pending --json

Notes:

  • --allow-scripts-report-format throws a usage error if specified without --allow-scripts-pending.
  • Setting a preferred format in .npmrc is silently ignored when --allow-scripts-pending is absent, so it does not interfere with normal approve/deny flows.
  • --json takes precedence over --allow-scripts-report-format unless --allow-scripts-report-format=null is explicitly passed.

The problem RFC npm#897 solves

npm approve-scripts --allow-scripts-pending already identifies which packages need approval and shows their lifecycle script commands, but it leaves developers to manually trace what those commands actually do. In practice this causes approval fatigue and reflexive approvals — especially for transitive dependencies whose names are unfamiliar. The RFC calls for a report mode that surfaces the concrete execution path: which files the scripts load, what risk signals appear in those files, how the package entered the dependency graph, and whether its scripts have changed since the last approved version. The goal is to turn approval from "do I trust this package name?" into "do I trust this specific code path?" and to produce auditable evidence that can be committed to a repository.

New review-report mode

Running npm approve-scripts --allow-scripts-pending now automatically generates a Markdown review report (the existing --json flag selects JSON output instead). The new --allow-scripts-report-format=null flag opt-out falls back to the legacy compact text listing. The --allow-scripts-report-format config option is gated behind --allow-scripts-pending and throws a usage error if specified without it.

For each pending package the report includes:

  • Dependency path — whether the dep is direct or transitive, and all graph paths through which it was introduced (up to 8 paths), built by walking arborist's edgesIn graph (dep-path-walker.js).
  • Change classification — whether the package is brand-new, a version upgrade from a previously approved entry, or a re-review of a version whose scripts changed (script-change-classifier.js).
  • Referenced files and risk signals — a static walk of the files that lifecycle scripts load (script-risk-scanner.js): the scanner parses each lifecycle command to find directly-referenced JS/shell files, then follows require()/import references up to 3 levels deep, reads each file in 64 KB chunks (with overlap to catch boundary-straddling patterns), hashes each file with SHA-256, and emits signals for patterns such as uses-child-process, network-access, references-credential-env-var, writes-outside-package, base64-decode-exec, obfuscation-pattern, jsfuck-obfuscation, and more. Files over 50 MB are partially scanned and flagged. The scanner never executes code and makes no network requests.
  • Native build info — for packages that reference node-gyp or binding.gyp, the scanner parses the binding.gyp file and extracts target names, source files, libraries, include directories, and whether conditional logic is present (gyp-scanner.js).
  • Formatted outputreview-report-formatter.js renders the collected data as a focused Markdown document with a risk summary section (highlighting HIGH_RISK_SIGNALS such as eval, VM, credential env vars, and obfuscation) and a "suggested focus areas" callout, or as a structured JSON object for programmatic consumption.

A standalone scripts/generate-allow-scripts-report.js script is also added to allow report generation outside the CLI (e.g. from CI scripts).

GitHub Actions demo workflow

.github/workflows/allow-scripts-demo.yml is added to exercise the new report mode on every PR that touches the relevant source files. It runs npm ci --ignore-scripts, generates both Markdown and JSON reports, validates the JSON output (asserting all listed packages have pending status), and posts a formatted summary comment to the PR with the full report embedded in collapsible sections.

Bug fix: bundleDependencies evasion in collectUnreviewedScripts

workspaces/arborist/lib/unreviewed-scripts.js previously skipped any node where node.inBundle was true. inBundle is set for any bundled dependency — including packages listed in the root project's own bundleDependencies. A root-project bundleDependencies entry is still fetched from the registry and installed normally; its lifecycle scripts will run. The guard is changed to node.inDepBundle, which is only true when the bundler is a non-root package (i.e. the dep is physically pre-built inside a third-party tarball). This closes the gap where a root-level bundled dep could silently bypass the unreviewed-scripts check and the approval workflow.

Smoke test and environment fixes

  • The approve-scripts-report smoke-test fixture adds "bundleDependencies": ["canvas"] to exercise the bundleDependencies evasion fix end-to-end.
  • smoke-tests/test/fixtures/setup.js now forwards NODE_EXTRA_CA_CERTS into spawned npm child processes so that smoke tests work correctly in environments with a custom CA certificate (e.g. enterprise CI).

Indicator detection improvements

Driven by analysis of indicator-suggestions.json deep scans across 20,000+ npm packages:

  • New bundled-binary-installer indicator — packages like @icp-sdk/ic-wasm that pre-bundle a platform binary inside the npm tarball and chmod +x it during postinstall are now classified under a dedicated bundled-binary-installer indicator (emitting the activates-bundled-binary signal), distinct from binary-downloader which is reserved for packages that fetch a binary from the network. Both indicators may fire together when a package both downloads and makes a binary executable. The makes-executable signal is the discriminator: a telemetry POST or JSON config download never needs to flip the execute bit.
  • binary-downloader is now strictly network-only — the makes-executable trigger has been removed from binary-downloader. It fires solely on command patterns (install-binary, download-binary, etc.) or the binary-download content signal (network fetch patterns detected in the install script).
  • source-downloader label corrected — renamed from "External source or binary downloader" to "Lifecycle script URL fetch" with an explicit low-confidence caveat. URL presence in lifecycle code does not by itself confirm a binary or source download; it may be a telemetry endpoint, CDN for config, or docs reference.
  • New obfuscated-install-script virtual indicator — triggered by the obfuscation-pattern signal. Packages whose install scripts contain obfuscated code patterns (eval+atob, jsfuck, etc.) are now surfaced as a distinct, named risk category rather than appearing as uncategorized build packages.
  • New dynamic-require-loader virtual indicator — triggered by the dynamic-require signal. Packages whose install scripts resolve require() calls at runtime from computed values are now classified separately, flagging a pattern that evades static analysis of the import graph.
  • hasBuildHint updated — the zero-I/O hint check returns true when makes-executable, obfuscation-pattern, or dynamic-require signals are among the signals on an already-scanned file, ensuring the full indicator scan is invoked for these packages.

Files changed

  • lib/utils/script-risk-scanner.js (new) — static file walker and signal detector for lifecycle script files
  • lib/utils/review-report-formatter.js (new) — Markdown and JSON report renderer
  • lib/utils/dep-path-walker.js (new) — arborist graph walker that computes all dependency paths from root to a node
  • lib/utils/gyp-scanner.js (new) — binding.gyp parser extracting native build targets and sources
  • lib/utils/script-change-classifier.js (new) — classifies whether a package's scripts changed relative to a previously approved version
  • lib/utils/indicator-definitions.js — new bundled-binary-installer indicator with activates-bundled-binary signal; new obfuscated-install-script virtual indicator (triggered by obfuscation-pattern signal); new dynamic-require-loader virtual indicator (triggered by dynamic-require signal); binary-downloader restricted to network-fetch detection only; source-downloader label corrected
  • lib/utils/indicator-scanner.jsdetectClues and hasBuildHint updated to handle makes-executable, obfuscation-pattern, and dynamic-require trigger signals
  • scripts/build-indicator-suggestions.js — trigger-flag serialisation list updated to include triggeredByObfuscationPatternSignal and triggeredByDynamicRequireSignal
  • scripts/generate-allow-scripts-report.js (new) — standalone report generator script
  • lib/utils/allow-scripts-cmd.js — wires the new utilities into the approve-scripts command; adds --allow-scripts-report-format param; adds runReviewReport()
  • workspaces/arborist/lib/unreviewed-scripts.js — fix inBundleinDepBundle to close bundleDependencies evasion
  • .github/workflows/allow-scripts-demo.yml (new) — CI demo workflow that generates and posts a report on PRs
  • smoke-tests/ — new fixture, smoke test, and CA cert forwarding fix
  • test/ — unit tests for all five new utility modules, new indicator trigger tests, and updates to existing command tests
  • docs/ — updated command docs and new RFC implementation plan document
  • workspaces/config/lib/definitions/definitions.js — new allow-scripts-report-format config definition

Copilot AI changed the title feat: implement RFC #897 — structured review reports for npm approve-scripts feat(approve-scripts): RFC #897 — structured review reports for pending lifecycle scripts Jun 13, 2026
Copilot AI requested a review from vbjay June 13, 2026 17:36
Copilot finished work on behalf of vbjay June 13, 2026 17:36
Copilot finished work on behalf of vbjay June 13, 2026 17:43
@vbjay

vbjay commented Jun 13, 2026

Copy link
Copy Markdown
Owner

@copilot parseCommandFile is silently incomplete for shell pipelines. "node install.js && node patch.js" only finds install.js. Since lifecycle scripts often chain commands, this could silently miss referenced files. Consider adding a signals: ['multi-command-script'] hint so reviewers know the scan is partial. If multiple commands then check each command and build distinct list from all commands the file references and such.

SIGNAL_PATTERNS for uses-child-process only detects require('child_process') — not ESM import { exec } from 'child_process'. The import regex (LOCAL_IMPORT_FROM_RE) only handles relative paths, so bare import { exec } from 'child_process' goes undetected. This is a gap given the feature's security focus.

dep-path-walker.js accesses arborist internals directly (node.edgesIn, node.package, node.isProjectRoot, etc.). These aren't part of a stable public API. Not a blocker, try to reimplement with known public apis but if you can't then add a comment saying so.
scanPackageScripts could call getInstallScripts(node) instead of accepting raw scripts — the caller in allow-scripts-cmd.js passes the scripts already from checkAllowScripts. This is actually fine as-is since it avoids a second disk read, but the fact that install-scripts.js also synthesizes node-gyp rebuild (which scanPackageScripts would handle via the native-build signal pattern anyway) means both paths converge correctly.

resolveLocalRef doesn't read package.json#main — e.g., require('./lib') where lib/package.json has "main": "build/index.js" won't be followed. Fix this.

Copilot finished work on behalf of vbjay June 13, 2026 17:56
Copilot AI changed the title feat(approve-scripts): RFC #897 — structured review reports for pending lifecycle scripts fix(script-risk-scanner): pipeline scanning, ESM child_process detection, package.json#main resolution Jun 13, 2026
Copilot finished work on behalf of vbjay June 13, 2026 18:06
Copilot finished work on behalf of vbjay June 13, 2026 18:10
Copilot AI changed the title fix(script-risk-scanner): pipeline scanning, ESM child_process detection, package.json#main resolution Add JSFuck detector, inline command scanner, and git committish length validation Jun 13, 2026
Copilot finished work on behalf of vbjay June 13, 2026 18:28
Copilot AI changed the title Add JSFuck detector, inline command scanner, and git committish length validation fix(script-risk-scanner): scan node --require/-r and --import preload files in lifecycle scripts Jun 13, 2026
Copilot finished work on behalf of vbjay June 13, 2026 18:37
Copilot AI changed the title fix(script-risk-scanner): scan node --require/-r and --import preload files in lifecycle scripts fix(script-risk-scanner): close 8 bypass gaps that let lifecycle scripts hide Jun 13, 2026
Copilot finished work on behalf of vbjay June 13, 2026 18:45
Copilot AI changed the title fix(script-risk-scanner): close 8 bypass gaps that let lifecycle scripts hide feat: strengthen lifecycle script guardian — close parser gaps and add signal coverage Jun 13, 2026
Copilot finished work on behalf of vbjay June 13, 2026 18:58
Copilot finished work on behalf of vbjay June 13, 2026 19:05
vbjay and others added 30 commits July 8, 2026 23:50
The 3250-line monolith is replaced by a thin entry point + 8 modules in
scripts/build-indicator/:

  integrity.js     (38 lines)  — SHA-256 hash/wrap/unwrap helpers + seeds
  defang.js       (187 lines)  — file defanging: JS/shell/batch/binary logic
  http.js         (359 lines)  — HTTP layer: circuit-breaker, fetchRaw/Json,
                                 fetchChangedNames, getPackageManifest
  lifecycle.js    (313 lines)  — lifecycle script analysis: extract, diff,
                                 match indicators, infer files, suggest signal
  package-cache.js(161 lines)  — loadPackageCache / savePackageCache
  process-lock.js  (64 lines)  — makeLockHelpers / isPidAlive
  deep-cache.js   (421 lines)  — deep fetch/scan pipeline, version hashes,
                                 hashDirTree, deepFetchPackage, deepAnalyzePackage
  main.js        (1685 lines)  — CLI args, run loop, drain, search, output

build-indicator-suggestions.js is now a 3-line entry:
  require('./build-indicator/main')

No behaviour changes — all logic is identical, only the file layout changed.

Co-authored-by: Copilot <[email protected]>
…ckpoint...' remained

'    saving checkpoint...' is 24 chars; the erase write had 23 spaces,
leaving the trailing '.' visible on its own line before the candidates
drain header.

Co-authored-by: Copilot <[email protected]>
…kage-lock.json

CI's npm install removes node_modules/git-raw-commits/node_modules/
conventional-commits-filter (a dev/optional/peer dep that doesn't need
deduplication on the CI runner), leaving the lockfile dirty and failing
the git-dirty check.

Normalized with: npm install --ignore-scripts

Co-authored-by: Copilot <[email protected]>
…ed .meta.json marked stale

The pre-drain cache validity scan read .meta.json with JSON.parse() directly,
then checked meta.state and meta.fetchVersion on the raw envelope object.
Wrapped files have shape { data: {...}, hash: '...' }, so meta.state was
undefined → every wrapped cache entry resolved as 'stale', causing the
entire deep cache to be re-fetched on every --deep run after the integrity
hashing commit.

Fix: apply the same envelope-unwrap logic already present in deepFetchPackage
and deepAnalyzePackage. Also add the missing META_HASH_SEED and unwrapVerified
imports (OUTPUT_HASH_SEED was the only export imported before).

Co-authored-by: Copilot <[email protected]>
…ng deep fetch

Previously deepFetchPackage always wrote state='fetched' regardless of whether
any files were actually downloaded. If unpkg was unreachable or rate-limiting,
all fetchRaw calls returned null and fetchedFiles stayed empty — but the meta
still said 'fetched' with a valid fetchVersion, so subsequent runs treated the
empty cache as valid and never retried.

Fix: package.json is always attempted in step 1. If it's missing from
fetchedFiles after all attempts, every file request failed — write
state='failed' instead of 'fetched' and emit a per-package warning.

The cache validity check already treats any state other than 'fetched' or
'scanned' as 'stale', so failed entries are automatically re-fetched on the
next --deep run without any additional logic.

Co-authored-by: Copilot <[email protected]>
…rovable

approve-scripts/deny-scripts <pkg> was throwing ENOMATCH for packages
listed in the root project's own bundleDependencies (e.g. canvas).
Those packages are fetched from the registry and installed normally;
inBundle=true but inDepBundle=false. The positional-arg node scan was
using the broader inBundle guard, matching the pattern already fixed in
unreviewed-scripts.js and script-allowed.js.

Adds a test asserting root-bundled deps can be approved by name.

Co-authored-by: Copilot <[email protected]>
…d.js comment

The comment on trustedDisplay still said \
pm approve-scripts --allow-scripts-pending\
after the namespace rename in 0c4dd41. Updated to \
pm install-scripts ls\.

Co-authored-by: Copilot <[email protected]>
…!list

npm install-scripts ls --allow-scripts-report-format=json was throwing
EUSAGE because the guard used !pending (only true with --allow-scripts-pending)
instead of !list (true for both --allow-scripts-pending and ls mode).

Also update test: install-scripts ls default output is now markdown report,
and add explicit test cases for the ls+format combo.

Co-authored-by: Copilot <[email protected]>
…detection

- parseSingleCommand: follow
ode install/check and bare paths containing '/'
  (e.g. sharp's
ode install/check, mist's script/install-ninja.js)
- parseSingleCommand: add handler for direct executables without './' prefix when
  the path contains '/' (always a local file, never an npm package name)
- scanFile: track spawnSync/execFile calls using process.execPath as executable;
  resolve and recursively scan the referenced scripts (findExecPathRefs)
- SIGNAL_PATTERNS: extend binary-download to match 'binary-install' npm helper
  and GitHub Releases '/releases/download/' URL pattern
- SIGNAL_PATTERNS: add 'git-hook-setup' signal (husky, lefthook, simple-git-hooks,
  pinst) so lifecycle commands that install hook managers are flagged
- findGitHookScripts: enumerate .husky/ (flat) and .lefthook/ (one level recurse)
  hook script directories and feed them through the recursive scan pipeline
- indicator-definitions.js: add git-hook-runner virtual indicator with commandPatterns
  for husky/lefthook/simple-git-hooks/pinst; add SIGNAL_DESCRIPTIONS entry
- indicator-definitions.js: add 'node install/' and 'node . run' commandPatterns to
  native-addon indicator to cover sharp-style install/check and CLI-driven builds
- deep-cache.js: add fetchUnpkgDirListing to fetch .husky/ and .lefthook/ hook
  directories from unpkg for packages not yet in the local cache; recurse one
  level for lefthook nested hook subdirectories
- defang.js: add case 13 -- prepend exit-1 guard to extensionless files inside
  .husky/, .lefthook/, and hooks/ directories (no-shebang hook scripts)
- DEEP_CACHE_SCHEMA bump to defang-v12 (invalidates stale fetch/scan caches)
…ded hasBuildHint signals

- parseSingleCommand: recognise bun and deno as node-like JS runtimes;
  strip the 'run' subcommand ('bun run ./file.ts', 'deno run ./file.ts')
  before resolving the script path through the existing node handler
- LOCAL_IMPORT_META_RESOLVE_RE: detect import.meta.resolve('./path') — the
  standard ESM way to get local file URLs — and include matched paths in
  the findLocalRefs recursive scan pipeline
- indicator-scanner: replace 7 chained .includes() checks in hasBuildHint
  with a BUILD_HINT_SIGNALS Set + .some(); add five previously-missing
  signals: wasm-load, shell-network-fetch, uses-eval, base64-decode-exec,
  jsfuck-obfuscation so packages whose scanned files emit these signals
  enter the categorisation pipeline instead of silently falling to lifecycleOnly
- tests: add findLocalRefs import.meta.resolve unit test; add bun/deno
  scanner integration tests covering direct invocation and 'run <file>'
  delegation with permission flags
Custom agent that autonomously improves indicator-definitions.js by
reviewing indicator-suggestions.json tasks, tuning commandPatterns and
signal descriptions, and running the build-indicator-suggestions pipeline.
…injection into unpkg URLs

Lifecycle scripts using node -e with inline code containing || or ;
were split naively by SHELL_OPS_RE, isolating the code content as a
standalone sub-command. When that fragment contained / (e.g. a package
name like @scope/pkg inside the inline string), the
!startsWith('@') && includes('/') branch in parseSingleCommand treated
it as a local file path, which was then used to construct a malformed
unpkg URL resulting in HTTP 403.

Replace the naive trimmed.split(SHELL_OPS_RE) in parseCommandFile with
a new quote-aware splitOnShellOps() helper that skips over single- and
double-quoted strings (honouring backslash escapes inside double quotes)
before splitting on shell operators. This keeps node -e "..." as a
single sub-command so only the -e handler runs, which correctly calls
findLocalRefs on the inline code string.
fetchUnpkgDirListing used the default retries=5, so an ECONNRESET
response from unpkg (e.g. for .husky?meta on packages that do not
publish that directory) produced 4 warning lines and a 5-second
sleep-based backoff before silently returning [] via the outer catch.

Pass retries=1 to fetchJson so that a single network failure throws
immediately — no warning is printed, no backoff sleep occurs — and
the outer catch { return [] } handles it in the normal best-effort way.
404 (directory absent on unpkg) was already silent; this brings network
errors in line with the same silent-failure semantics.
Add a SKIP_FETCH_EXTS set (.node, .so, .dll, .dylib, .pyd) so that
when findLocalRefs follows a require('./addon.node') reference the HTTP
fetch is skipped entirely rather than downloading the binary, detecting
the magic bytes in writeDefanged, and printing a noisy "skipped binary"
warning.  Avoids a wasted HTTP round-trip per reference.
…ipt-compiler

indicator-scanner.js: detect the platform-specific-script signal from
scanned lifecycle files and use it to trigger the platform-binary-selector
virtual indicator (triggeredByPlatformSpecificScriptSignal).  Without this
wire-up the signal was detected by the scanner but never caused the
indicator to fire.

indicator-definitions.js: add /\bbun\b/ to the typescript-compiler
commandPatterns.  Bun is a TypeScript-native runtime/bundler (equivalent
to ts-node) and packages using "bun run scripts/build.ts" in prepack or
prepare hooks are TypeScript compilation steps that must not be left
uncategorized.
…dations

Previously, file-tree hash mismatches were detected inside deepFetchPackage
as each package was processed, printing 🗑️ messages interleaved between
"scanning ..." lines and immediately re-fetching in the same worker.

Move the hashDirTree check into the existing cacheStatus pre-check pass,
which already runs concurrently across all manifests via Promise.all before
the fetch loop starts.  Stale directories are now deleted during that pass
so all invalidation messages appear as a single upfront batch — before any
"scanning ..." output.  deepFetchPackage receives either an absent directory
(fresh fetch) or a verified-valid directory, so it never encounters a
tree mismatch mid-scan.  quietTreeWarning is now always true since the
warning path in deepFetchPackage is unreachable from this caller.
…file offload

When unpkg fails to serve package.json (rate-limit, downtime, or 403),
deepFetchPackage now falls back to the npm registry tarball instead of
immediately marking the package failed.

Tarball handling (deep-cache.js):
- fetchNpmTarball(): downloads registry.npmjs.org .tgz, gunzips via
  built-in zlib, returns raw tar Buffer (2 retries, since fallback path).
- buildTarIndex(): single O(n) POSIX ustar parse; returns
  Map<path -> {offset,size}>.  Strips "package/" npm prefix.
- tarFetch(): O(1) index lookup; slices Buffer (small) or fd.read()
  byte-range (large) transparently.
- fetchOne: unpkg retries exhausted first; once tarBuf/tarFd is set,
  skips unpkg entirely for all remaining BFS files.
- TAR_MEMORY_THRESHOLD (10 MB): tarballs larger than this are written to
  os.tmpdir() and served via an open fs.FileHandle so the heap is freed.
- tarballAttempted flag: set when step 1b triggers; used in the failure
  message so "unreachable on unpkg and registry tarball" is printed
  correctly even after tarBuf/tarFd have been cleaned up.
- Cleanup: fd.close() + fs.unlink() after BFS completes; tempFilesSet
  entry removed before unlink so the next checkpoint no longer lists it.

Checkpoint tracking (package-cache.js + main.js):
- savePackageCache: new tempTarFiles=Set param; saves pendingTempFiles[]
  in the checkpoint JSON so orphaned temp files survive a process kill.
- loadPackageCache: returns pendingTempFiles from saved data.
- main.js: activeTempFiles Set shared across all DeepFetch workers;
  pre-flight block on every startup deletes any pendingTempFiles left
  by a previously interrupted run; DeepFetch-mode resume checkpoint
  saves pass activeTempFiles.
fetchedFiles was a plain Array, so if fetchOne was called twice for the
same path (e.g. package.json fetched in step 1, then again when a BFS
file has require("./package.json")), the path appeared twice in the
stored fetchedFiles list.

Two changes:
  1. fetchedFiles: [] -> new Set()  (push->add, includes->has,
     spread to [] at the two JSON/return sites)
  2. fetched BFS Set initialised from fetchedFiles so step-1 files
     (package.json + indicator files) are pre-marked as seen and the
     BFS never calls fetchOne for them again.
Sort fetchedFiles when writing to .meta.json and returning from
deepFetchPackage so the stored list is deterministic and diffs cleanly.
Bump DEEP_CACHE_SCHEMA to defang-v14 to invalidate caches written with
the old unsorted/potentially-duplicate format.
…stop perpetual invalidation

deepAnalyzePackage wrote scan results via `...meta` spread, which
preserved whatever filesHash was in the existing meta.json.  If a
previous scan run had stored a stale or wrong filesHash (e.g. from an
interrupted run, a schema bump transition, or a prior re-scan that
itself used the ...meta spread), the next run's cacheStatus pre-check
would compute the real directory hash, find it differed from the stored
one, delete the cache and re-fetch — only for the cycle to repeat.

Fix: compute a fresh hashDirTree() right before writing the scan results
and store it explicitly (overriding any stale value from ...meta).  This
guarantees the persisted filesHash always matches the actual on-disk
state after a scan, so the next pre-check finds a match and skips the
re-fetch.
…e scripts

The runtime-installer indicator previously only caught npm install when
invoked via node -e "execSync/spawnSync('npm install')" — missing the
equally dangerous pattern of calling npm/yarn/pnpm/bun install directly
as a shell command in a lifecycle hook or in a script reached via npm-run
delegation (extractLifecycleScripts already follows those chains).

A package whose lifecycle runs "cd subdir && npm install && npm run build"
installs arbitrary dependencies at the consumer's install time, completely
outside the parent lockfile and integrity checks — a clear supply-chain red
flag.

Add direct commandPatterns for:
  npm install, npm ci
  yarn install, yarn add
  pnpm install, pnpm add
  bun install, bun add

These close the scanner gap for @fieldwangai/agentflow (and similar
packages) whose build:web-ui delegated script contains npm install.
…gnals/references

Two related fixes in script-risk-scanner.js:

1. referencedFiles dedup: when multiple lifecycle hooks (e.g. postinstall
   and prepare) reference the same script file, scanFile previously pushed
   a shallow copy with a new reason for each hook — resulting in duplicate
   entries with identical path/sha256/signals but different reasons.
   Fix: mutate the existing cached entry to append the new reason ("; "
   separated) instead of pushing a duplicate.

2. Set-based accumulation: replace plain Array push() with Set.add() for
   signals and references throughout scanFile and detectSignals so that
   duplicates are impossible by construction.  Arrays are only produced at
   the output boundary via [...set] so JSON serialization is unaffected.
   Also deduplicates signals in scanInlineCommand via the same pattern.
… scanner dedup fixes

The referencedFiles dedup and Set-based signals/references changes in
script-risk-scanner.js affect scan output but not fetched files.
Bumping DEEP_CACHE_SCHEMA would unnecessarily re-fetch all 8000+
packages; instead, add a SCAN_IMPL_VERSION string included only in
computeDeepScanVersion() so the scan cache is invalidated (forcing a
re-scan with clean deduplicated output) while DEEP_FETCH_VERSION stays
unchanged and fetched file trees are kept on disk.
New module discovery in findLocalRefs / BFS fetch:
- new URL('./file', import.meta.url) — ESM file-relative path pattern
- require.resolve('./path') — local path resolution without loading
Both are followed transitively in deepFetchPackage BFS and in
scanPackageScripts, catching scripts that reference sibling files
via these common patterns.

TypeScript source file support (.ts/.mts/.cts):
- resolveRelPosix (deepFetchPackage BFS) now probes .ts/.mts/.cts
  extensions so ts-node/tsx-executed scripts are fetched
- resolveLocalRef and scanFile extension probing extended to match
- isLocalSpecifier updated to recognise .mts and .cts suffixes

package.json#exports resolution for bare dep entry points:
- deepAnalyzePackage previously only consulted #main when scanning
  a followed bare dep; now resolveExportsEntry() prefers the CJS
  ('require') conditional export, falling back through 'node',
  'default', #main, then index.js — covering modern ESM+CJS packages

TypeScript type-only import filtering:
- stripTypeOnlyImports() strips 'import type ...' / 'export type ...'
  lines before ref extraction in findBareRefs and findLocalRefs
- Prevents @types/* and type-only packages from being queued as bare
  follows, saving fetch budget on deps with no lifecycle scripts
- ESM edge case preserved: 'import type from ./foo' (where 'type' is
  the default binding name) is correctly left intact via lookahead

resolvedFollows sorted before persisting to .meta.json for
deterministic output across concurrent bare-follow resolutions.

Cache invalidation:
- DEEP_CACHE_SCHEMA defang-v14 -> defang-v16 (fetch set may grow)
- SCAN_IMPL_VERSION scan-impl-v1 -> scan-impl-v5 (scan results change)
defang.js: TypeScript files (.ts/.mts/.cts) now receive a top-level
throw new Error() in addition to the null byte.  TypeScript's
transpiler (ts-node, tsx) treats U+0000 as an invalid character and
silently strips it before emitting JS — the null byte alone does not
prevent execution via ts-node.  The throw statement is valid TypeScript,
survives transpilation unchanged, and fires before any code in the
file runs.  JS files (.js/.mjs/.cjs) are unchanged (null byte only).

deep-cache.js: DEEP_CACHE_SCHEMA bumped defang-v16 → defang-v17 to
invalidate existing .ts cache entries and trigger re-fetch with the
updated defanging.

package-cache.js: savePackageCache now uses an atomic write (write to
.new temp file, then rename) instead of fs.writeFile directly on the
target.  On Windows, antivirus/VS Code file watcher/Search indexer can
hold indicator-suggestions.packages.json open briefly, causing
UNKNOWN (ERROR_SHARING_VIOLATION) on the direct open() call.  Writing
to a fresh temp path avoids the contention; rename is near-instantaneous
on NTFS.
…e cache write

main.js: split the DeepFetch pre-check into two serial phases so all
stale cache directories are deleted before any fetch worker starts.

Previously, version-mismatch deletions happened inline inside
deepFetchPackage as each worker processed its package, interleaving
rmReadOnly I/O with network fetches and slowing the overall pipeline.
The pre-check already ran a concurrent meta-read pass but only deleted
tree-hash mismatches there; version mismatches were left for the workers.

New flow:
  Phase 1 — Promise.all: read every .meta.json concurrently, classify
             each package as valid/stale/missing.  Pure reads only, no
             deletions, maximum I/O parallelism.
  Phase 2 — log tree-warning messages, emit a single summary line for
             version-mismatch wipes, then Promise.all: delete all stale
             directories concurrently.  The entire wipe completes before
             the first fetch worker starts.
  Phase 3 — build fetchQueue and start fetch workers against a clean
             directory tree.

On a schema-version bump (e.g. defang-v17 invalidating 7000+ entries)
this eliminates thousands of per-worker delete calls that previously
competed with network I/O.

package-cache.js: savePackageCache now uses an atomic write (write to
.new temp file then rename) instead of fs.writeFile directly on the
target, fixing UNKNOWN (ERROR_SHARING_VIOLATION) on Windows when
antivirus/VS Code file watcher briefly holds the file open.
Temporary workflow demonstrating the deep-scan pipeline check running
'node scripts/build-indicator-suggestions.js --deep --top 1' on PRs.
Includes deep cache files for testing purposes — will be cleaned up.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants