npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

artifact-inspector

v0.1.0

Published

Composable, security-minded URL/byte artifact inspection for Node.js

Downloads

20

Readme

artifact-inspector

Composable, security-minded URL/byte artifact inspection for Node.js. It produces an append-only array of independently attributable findings—not a collision-prone metadata object.

Requires Node >= 22.

npm install artifact-inspector
import {
  createInspector,
  recommendedAnalyzers,
  recommendedContentAnalyzers,
} from "artifact-inspector";

const inspector = createInspector(recommendedAnalyzers());
const report = await inspector.inspect("https://example.com/download.zip");

// Content-only path (skips URL-only analyzers such as DNS/TLS/RDAP):
const contentInspector = createInspector(recommendedContentAnalyzers());
const bytesReport = await contentInspector.inspect({
  type: "bytes",
  bytes: zipBytes,
  filename: "download.zip",
  declaredMime: "application/zip",
});

inspect() is the only entry point. Pass a URL string/URL, or an explicit target: { type: "url", url } or { type: "bytes", bytes, filename?, declaredMime? }.

allow is not "safe." It means no configured analyzer found meaningful risk — nothing more. Missing analysis produces unknown risk and a review verdict.

The verdict fails closed: if any analyzer threw, or none ran, the report is review rather than allow, even when no finding carries risk. Check report.errors to see what did not complete. verdictPolicyAnalyzer can raise or lower thresholds and governs report.verdict directly; it may not downgrade a crashed inspection to allow, and its position in the analyzer list does not matter (the inspector always evaluates it last).

Finding values that can succeed, fail, or skip use a discriminated union on kind ("ok" | "error" | "skipped"), not optional fields mixed into one object.

Analyzers declare inputs: ["url"] and/or inputs: ["url", "bytes"]. Unsupported analyzers are skipped and listed on report.skippedAnalyzers.

Implemented analyzers

| Analyzer | What it does | Network | | --- | --- | --- | | urlStructureAnalyzer | Scheme allowlist, embedded credentials, suspicious encoding | No | | privateNetworkAnalyzer | Literal IP / special hostname classification via ipaddr.js | No | | dnsResolutionAnalyzer | Resolve A/AAAA and classify answers | DNS | | tlsAnalyzer | Certificate subject/issuer, validity, protocol/cipher fingerprint; refuses hosts that are or resolve to private/loopback/link-local/special | HTTPS | | httpMetadataAnalyzer | Disposition, cache, server, ETag, nosniff | Via content | | redirectChainAnalyzer | Hop-by-hop redirect walk; refuses any hop resolving to private/loopback/link-local/special | Yes | | Inspector context.content() | SSRF-safe bounded GET, SHA-256, file-type + WHATWG sniff, type/length mismatch | On demand | | contentHashAnalyzer | SHA-512 in addition to SHA-256 | Via content | | contentEntropyAnalyzer | Shannon entropy over payload bytes | Via content | | executableDetectionAnalyzer | Maps file-type detections (exe / elf / macho) to executable findings | Via content | | malwareScannerAnalyzer(scanner) | Injected YARA/ClamAV/worker scanner adapter | Via content | | documentSafetyAnalyzer | Structural PDF (xref/object), OOXML (ZIP entries), SVG/HTML (parse tree) inspection in a subprocess | Via content | | archiveSafetyAnalyzer | Strict ZIP/tar validation in a heap-capped subprocess; bombs, traversal, nested/duplicate/depth | Via content | | brandSimilarityAnalyzer | Typosquat / brand-lookalike hostnames; requires a caller-supplied brand list | No | | rdapAnalyzer | Domain registration age via RDAP. Opt-in: sends the hostname to rdap.org | Yes | | safeBrowsingAnalyzer({ apiKey }) | Google Safe Browsing threatMatches | Yes | | virusTotalAnalyzer({ apiKey }) | VirusTotal URL + hash lookup (lookup-only or submit-url) | Yes | | asnGeoAnalyzer(provider) | ASN/geo/hosting via injected provider | Provider | | threatIntelAnalyzer(provider) | Generic threat-intel feed adapter | Provider | | policyAnalyzer(rules) | Allow/block hosts, schemes, MIME, size, categories | Optional content | | verdictPolicyAnalyzer(policy) | Custom block/review thresholds as a finding | No |

recommendedAnalyzers() requires no API keys, and every analyzer in it talks only to the host being inspected — none sends the target to a third party. recommendedContentAnalyzers() is the subset that supports { type: "bytes" } targets.

Two analyzers are in neither set, on purpose: rdapAnalyzer transmits the hostname to rdap.org, so inspecting an internal URL would disclose it to an outside service; and brandSimilarityAnalyzer ships no data and must be configured (see below).

Content retrieval

Content is fetched only when an analyzer calls await context.content(). The inspector retrieves it once per inspection and shares the bounded in-memory artifact. Disable it or override the transport:

createInspector(recommendedAnalyzers(), { content: false });
createInspector(recommendedAnalyzers(), { content: { fetch: customSafeFetch } });

Default transport is ssrf-fetch (DNS pinning, private-range refusal, redirects disabled). Redirect following is available only through redirectChainAnalyzer(). The analyzer classifies every hop itself and refuses to request one whose host is private, loopback, link-local, or special-use — that refusal happens before the request and holds even when you inject your own fetch.

Two limits on the chain walk are worth knowing. Hops are probed with HEAD, so a server that answers HEAD and GET differently can present a chain that is not the one context.content() later retrieves. And the address validation is only as good as the transport: the built-in walker pins each hop to the addresses it validated, but an injected fetch that re-resolves the hostname reopens a DNS-rebinding window that only the pre-request host classification above still covers.

archiveSafetyAnalyzer and documentSafetyAnalyzer write the artifact to a private temp directory and parse it in a child process (128 MB heap / 5s timeout for archives, 256 MB / 10s for documents). That isolates crashes and memory exhaustion; it is not an OS sandbox.

budget.timeoutMs (default 45s) bounds the whole inspection, not each analyzer. Analyzers run sequentially and share it, so the default leaves room for the worker budgets above; lower it and a slow payload can consume the budget before later analyzers run. Any analyzer that never ran for that reason is recorded in report.errors, so a starved inspection is distinguishable from a clean one — and, having run no analysis, never reports allow.

Neither analyzer ever extracts an archive or executes document content. Structural parsing failure is reported as fail (evidence against the payload); a worker that could not run at all is reported as error / unknown risk, never as a clean result.

Archive coverage

ZIP and tar are parsed from their headers alone. Both report entry counts, duplicate names, nested archives, path depth, encrypted and symlink entries, plus two rejections that matter before extraction:

  • Decompression bombs — flagged above a 100x expansion ratio or 1 GB uncompressed (maxCompressionRatio / maxUncompressedBytes). tar stores entries uncompressed, so its ratio is always 1.0 and carries no signal; the finding sets ratioApplicable: false and only the absolute size limit is enforced for it.
  • Path traversal (Zip Slip) — entries escaping the extraction root via .., and absolute paths.

Entry names are sanitized of control characters and capped (maxReportedNames, default 256) with namesTruncated set, so a small archive cannot amplify into a huge finding in your logs. namesTruncated covers every reported list — entries, traversal, absolute, duplicate, and nested — so a capped traversal list is never mistaken for a complete one.

Containers this analyzer cannot open (gzip, bzip2, xz, 7z, rar, and the rest, including .tar.gz) are reported as unsupported-container with unknown risk. Their contents are unanalyzed, which is not the same as clean, so they never reach an allow verdict. A payload that is not a container at all is reported not-an-archive at none risk.

Document coverage

| Format | How it is inspected | | --- | --- | | PDF | Parsed through xref/object streams, so JavaScript inside compressed objects is visible. Detects document/field/annotation actions, OpenAction, embedded files. | | OOXML | Read as a ZIP central directory: macro storage is an entry-existence question, not a substring search. Relationship parts are inflated and parsed as XML, so external targets and attached remote templates are read from attributes rather than guessed from raw bytes (they are DEFLATE-compressed, and a substring scan misses them). Detects vbaProject.bin, external relationship targets, attached remote templates, OLE objects. | | SVG / HTML | Parsed into a real tree with parse5. Detects <script>, inline on* handlers, javascript: and active data: URLs, <iframe>/<object>/<embed>, <foreignObject>, meta refresh. |

When a document cannot be parsed, the finding reports parsed: false and unknown risk. Absence of signals from an unparsed document is not evidence of safety, and the analyzer does not pretend otherwise.

Brand lookalike detection

This package ships no brand, phishing, or reputation data. Such lists rot, and which brands matter depends on your users rather than on this library. Supply your own, ideally from a maintained open-source source (a Tranco or Cisco Umbrella top-sites slice, an internal brand registry, or a phishing feed):

brandSimilarityAnalyzer({
  brands: ["paypal", "github"],
  // Brand-owned infrastructure, suffix-matched. Without it, `s3.amazonaws.com` and
  // `raw.githubusercontent.com` look like lookalikes.
  knownDomains: ["paypal.com", "paypalobjects.com", "github.com", "githubusercontent.com"],
});

For lists refreshed independently of deploys, pass a provider instead:

brandSimilarityAnalyzer({
  provider: {
    brands: () => cache.topBrands(),
    knownDomains: () => cache.knownDomains(),
  },
});

A hostname label matches when it is one edit away from a brand (paypa1) or uses the brand as one token beside others (secure-paypal-verify). A brand inside a longer unbroken label (paypalobjects) does not match on its own — that is how brands name their own infrastructure. With no list configured the analyzer reports unknown / review, never a false pass.

Opt-in reputation / scanners

import {
  createInspector,
  recommendedAnalyzers,
  safeBrowsingAnalyzer,
  virusTotalAnalyzer,
  malwareScannerAnalyzer,
  policyAnalyzer,
} from "artifact-inspector";

const inspector = createInspector([
  ...recommendedAnalyzers(),
  safeBrowsingAnalyzer({ apiKey: process.env.SAFE_BROWSING_API_KEY! }),
  virusTotalAnalyzer({ apiKey: process.env.VIRUSTOTAL_API_KEY!, mode: "lookup-only" }),
  malwareScannerAnalyzer(async (artifact) => scanInWorker(artifact.bytes)),
  policyAnalyzer({
    requireHttps: true,
    maxBytes: 5_000_000,
    allowMimeTypes: ["application/pdf", "image/png", "image/jpeg"],
  }),
]);

VirusTotal never uploads file bytes from this package. Hash lookup uses the local SHA-256. submit-url only asks VT to reanalyze the URL.

Writing plugins

Every analyzer must declare a Standard Schema findings map. First-party plugins use Zod; third-party plugins may use any Standard Schema implementation. createInspector([...]) intersects those maps so report.find(kind) infers the value type.

import { createInspector, defineAnalyzer, okValue, resultUnion, errorValue, urlStructureAnalyzer } from "artifact-inspector";
import { z } from "zod";

const scoreAnalyzer = defineAnalyzer({
  id: "@acme/score",
  version: "1.0.0",
  inputs: ["url", "bytes"],
  findings: {
    "@acme/score.value": resultUnion(
      okValue({ score: z.number() }),
      errorValue({ message: z.string() }),
    ),
  },
  async analyze(context) {
    context.emit({
      kind: "@acme/score.value",
      status: "pass",
      risk: "none",
      value: { kind: "ok", score: 42 },
    });
  },
});

const report = await createInspector([urlStructureAnalyzer(), scoreAnalyzer]).inspect(url);
const [score] = report.find("@acme/score.value");
if (score?.value.kind === "ok") score.value.score; // number

emit is compile-time typed from the schema and does not re-validate at runtime.

Not implemented yet

These remain deliberate non-goals until there is a strong dependency or containment story:

  • OCSP stapling / Certificate Transparency live queries and HSTS preload list checks
  • RAR / 7z inspection. Both need a native binary or a licence-encumbered unrar; adding one would weaken the containment story that keeps parsing to pure JS in a capped subprocess. ZIP and tar are supported because they can be read from headers alone.
  • Archive extraction of any format. Entries are described, never written to disk.
  • Built-in YARA or ClamAV engines (use malwareScannerAnalyzer with your worker)
  • Browser-grade redirect DNS rebinding pinning equivalent to ssrf-fetch for every hop
  • Bundled brand, phishing, or reputation lists (see below)

Demo

npm run demo -- https://example.com/

Development

pnpm install
pnpm check        # typecheck
pnpm test         # vitest
pnpm build        # tsup + declarations into dist/
pnpm verify:dist  # exercise the built package, including the forked workers

verify:dist matters more than it looks. The archive and document workers resolve their entry paths differently in dist/ than in src/, so a worker path can break while the source tests stay green. CI runs it on every push for that reason.

Tests run on Node 22 and 24. Forking a worker from src/ relies on the runtime stripping TypeScript types, so running the test suite on an older Node fails at the worker boundary even though published dist/ builds fork plain .js.

Releasing

Versioning goes through Changesets. Any change that affects consumers needs one:

pnpm changeset

Pick the bump and describe the change in terms of what a caller sees—a new finding kind, a changed default, a verdict that moves. The file lands in .changeset/ and is committed with the work it describes.

On merge to main, the release workflow opens a "Version Packages" PR that applies every pending changeset and writes the changelog. Merging that PR publishes to npm. Publishing runs prepublishOnly, so nothing reaches the registry without a clean build and a passing verify:dist.