artifact-inspector
v0.1.0
Published
Composable, security-minded URL/byte artifact inspection for Node.js
Downloads
20
Maintainers
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-inspectorimport {
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? }.
allowis not "safe." It means no configured analyzer found meaningful risk — nothing more. Missing analysis producesunknownrisk and areviewverdict.
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 setsratioApplicable: falseand 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; // numberemit 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
malwareScannerAnalyzerwith your worker) - Browser-grade redirect DNS rebinding pinning equivalent to
ssrf-fetchfor 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 workersverify: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 changesetPick 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.
