dfir-unfurl: expanding a URL into a graph of everything it reveals
Extract and Visualize Data from URLs using Unfurl
At a glance
- What is it?
- Unfurl takes a URL apart, parses each component with a site specific or generic parser, and presents the result as a directed graph, so an analyst can see the host, the path segments, embedded identifiers and timestamps instead of a flat string. It ships a command line tool, a local web app and a Docker image, and it makes remote lookups opt in because enabling them sends data from the analysed URL to third party APIs.
- Who is it for?
- Adopt dfir-unfurl if you work through URLs found in images, browser history, chat logs or memory and want their structure, identifiers and timestamps laid out rather than guessed at, and if you need the output as JSON for a pipeline rather than only as a picture.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 8 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What the tool does with a URL
Unfurl takes a URL and expands it into a directed graph, extracting information from the URL and exposing what is obscured. The description says it does this by breaking up a URL into components, extracting as much information as it can from each piece, and presenting it all visually.
The framing the README uses is a show your work approach, combined with embedded references and documentation, and the stated benefit is twofold. It makes the analysis transparent to the user, and it helps them learn about semantic and syntactical URL structures.
That second benefit is the one that distinguishes it from a single-purpose tool. A URL is often the only artifact you have from an image, a chat message or a memory capture, and reading one by eye means knowing which service uses which path convention. Unfurl encodes that knowledge as parsers, with dedicated ones for URLs in general, for search engines, for chat applications, for social media sites, and for more, plus generic parsers for things like timestamps and UUIDs that are useful when you meet a URL format nobody has written a parser for yet.
The audience is analysts doing OSINT and digital forensics, and the keywords in the package metadata say so: forensics, dfir, reverse-engineering, security, osint and digital forensics. The README puts the use case plainly, mentioning a URL extracted from a memory image, carved from slack space, or pulled from a browser's history file.
The implementation is Python 3, the licence is Apache-2.0, the distribution name is dfir-unfurl, and the project site is unfurl.link. The repository is not archived and the last push was on 2026-10-15.
The graph, and why the tree output is structured
The output is a tree of numbered nodes, and its structure is the analysis.
In the README's example, a status URL on twitter.com is broken into a scheme node, a host node, and a path node, and the host is further broken into a domain name and a top level domain. The path is split into its segments, so the user name, the action name and the identifier each become their own node with an edge back to the path they came from.
Then the parsers do their work on the leaves. The identifier is recognised as a Twitter snowflake and expanded into a timestamp of 1576167751484, which is then rendered as the human date and time, together with a machine ID of 334 and a sequence of 1. So a single opaque number in a URL becomes a moment in time, and that is the kind of derivation that matters when you are reconstructing a timeline.
The node types are distinguished visually in the tree output, with different markers for the structural nodes, the ones a parser created, and the ones a further parser derived. The node identifiers in square brackets are stable, which means the same input produces the same node numbering, so a change between two runs is easy to spot in a diff.
The command line can also emit JSON instead of the tree, with the type option taking tree or json. That matters more than it sounds: the tree is for reading, the JSON is for feeding into a script, and the difference between an analyst reading a screen and a pipeline consuming results is exactly what a tool like this has to support both of.
Running it: CLI, local web app or Docker
Unfurl requires Python 3.11 or newer, and the package installs with:
pip install dfir-unfurl[all]The extra matters, because the core requirements and the web interface dependencies are kept in separate files, requirements.txt and requirements-ui.txt, with requirements-all.txt covering both. The pyproject metadata maps them to the ui and all optional dependency groups.
After installation there are two entry points, and both are declared as console scripts in the project metadata. The web app is started with unfurl_app, and you then browse to localhost:5000, which is editable through the config file, enter the URL and press Unfurl. The command line is the unfurl command, for example unfurl followed by a URL.
There is also an online version at dfir.blog/unfurl, and the README describes a bookmarklet that sends the current page's URL to that service, so you can expand a URL from any page you are on.
Docker is the third route, and the compose file builds the image locally:
git clone https://github.com/RyanDFIR/unfurl
cd unfurl
docker compose up -dThe compose file maps port 5000, mounts the repository's unfurl.ini into the container read-only, sets PYTHONUNBUFFERED, and defines a healthcheck that spiders the local URL with wget. The Dockerfile is based on python:3.11-alpine, installs both requirement files, installs the package in editable mode and exposes 5000 with the web app as its entry point.
For a one-off analysis, the CLI is faster than either. The README also notes a practical detail: if the URL contains characters your shell would interpret, such as an ampersand, put the URL in quotes.
The options that change what Unfurl is allowed to do
The command line options are few and each one matters for how an investigation should be run.
The lookups option, -l, allows remote lookups to enhance results. That is the flag to think hardest about, because it changes the privacy posture of the tool, and the README treats it as a configuration decision rather than a convenience.
The filter option, -f, only outputs lines that match a filter, which is how you get a single field out of a large tree. The detailed option, -d, shows more detailed explanations, which is where the embedded documentation becomes visible. The output option, -o, saves output as CSV to a file instead of printing to stdout, and the type option chooses between tree and json.
The positional argument is more flexible than it first appears. It is typically a URL, but it also supports integers, which are treated as timestamps, and encoded protobufs, and more. If the argument is a file path instead, Unfurl opens the file and processes each line as a separate input, which is the shape you want when you have a batch of URLs extracted from a file.
Combining them is where the tool earns its keep in a pipeline. A filter that keeps only the timestamp nodes, a json output type, and an output file, applied to a file of URLs, gives you a machine readable timeline without a human reading trees.
Configuration and the deliberate default of no remote lookups
Unfurl reads its settings from unfurl.ini, and the shipped file is a template with empty API keys. The structure is two sections, one for the application and one for the keys:
[UNFURL_APP]
host = localhost
port = 5000
remote_lookups = false
debug = false
[API_KEYS]
bitly =
virustotal =
google_kg =The remote_lookups switch controls whether Unfurl may send data from the URL being analysed to external destinations, including third party APIs such as VirusTotal, Bitly and Google Knowledge Graph. It is false by default, and the API keys are only used when it is enabled.
That default is the most important design decision in the project, because the tool is used on URLs that are often evidence. A URL from a phishing message or a dark web forum is not something you want copied to a third party without deciding to do that.
The file layering supports the same caution. unfurl.ini is tracked in git as a template, and your own values go in unfurl.local.ini next to it, which is gitignored and overrides unfurl.ini. Only the values you want to change need to appear, and anything omitted falls back to the tracked file.
Environment variables are the third route and the most convenient for containers. The key names map to UNFURL_BITLY_API_KEY, UNFURL_VIRUSTOTAL_API_KEY, UNFURL_GOOGLE_KG_API_KEY, with the API key variables used when neither config file sets that key. The remote_lookups variable, UNFURL_REMOTE_LOOKUPS, is different, and the README says it explicitly: it overrides both config files, whereas the key variables fall back. The reason given is that otherwise it would be useless in a container where unfurl.ini is mounted from the host.
The precedence therefore has a defined top: the command line -l flag still wins over both. And the tool logs a warning when the environment turns remote lookups on, and any value it cannot interpret leaves lookups disabled rather than falling through to the config file, so a typo fails safe.
Containers, keys and what the compose file does not mount
The Docker instructions in the README are short and they encode a security decision worth reading twice.
The compose file mounts the repository's unfurl.ini into the container read-only. The local configuration file is deliberately not mounted, and the .dockerignore keeps it out of the image, so your API keys are never baked into an image layer. Instead the keys and the remote lookups switch, which is off by default, are passed to the container as environment variables through the environment block of docker-compose.yaml:
environment:
- PYTHONUNBUFFERED=1
- UNFURL_REMOTE_LOOKUPS=true
- UNFURL_VIRUSTOTAL_API_KEY=<your key>The pattern is the one you want for any tool that talks to third party APIs: the configuration that may contain secrets is never part of the image, and the operator decides at deploy time whether lookups happen at all.
The build itself is a plain Alpine Python image. The Dockerfile copies the two requirement files, installs them, copies the source, installs the package in editable mode, exposes 5000 and runs the web app as its entry point. The image is labelled with a maintainer and a version, and the repository also carries a SECURITY.md and a .flake8 configuration.
One more detail in the requirements list is worth noting for anyone extending the parsers. The dependencies include networkx, the graph library the visualisation is built on, along with publicsuffix2 for the public suffix list, dnslib and netaddr for network and DNS handling, protobuf for encoded protobuf inputs, torf and bbpb for hash and identifier work, pycountry for country codes, pymispwarninglists for indicator lists, and beautifulsoup4 for HTML. That set tells you the parsers are not string regexes over a URL but small programs that can do real work.
Maintenance, licensing and where Unfurl stops
The project is not archived, and the last push was on 2026-10-15. The release history uses calendar versioning, with v2026.09 from 2026-09-16 and v2026.10 from 2026-10-07, and there is a separate tag for the web build, web-20261007.post1. The distinction between the library and the deployed web app having their own version lines is sensible for a project where the online instance and the pip package are consumed by different people at different rates.
The licence is Apache-2.0, which permits commercial use and modification with notice retention. This is not legal advice, and the notice obligations still apply to derived works.
As for limits, there are three worth naming. The first is that Unfurl is a URL parser and graph builder, not a fetcher that renders the destination. The output you get is what the URL itself encodes, plus whatever the enabled remote lookups add. If a service hides information behind a page that requires JavaScript or a login, Unfurl will not see it.
The second is that a parser can only be as good as the service convention it encodes. Social platforms change their URL shapes, and a parser written for a format from two years ago will produce a wrong decomposition rather than an obvious error, which is the failure mode to worry about in an investigation.
The third is the plugin system, which the README describes as making it easy to build new parsers. That is a strength and a cost at once: extending Unfurl to a service nobody has written a parser for means writing the parser yourself, and the generic parsers for timestamps, UUIDs and similar are a starting point rather than a substitute.
For a package that is 3.11 and up, has no separate releases on PyPI naming convention, and ships both a CLI and a web app, the sensible first step is to install it, run it against one URL you already understand, and check the decomposition against what you would have told a colleague by hand.
Editorial conclusion
Adopt dfir-unfurl if you work through URLs found in images, browser history, chat logs or memory and want their structure, identifiers and timestamps laid out rather than guessed at, and if you need the output as JSON for a pipeline rather than only as a picture. Do not adopt it expecting it to fetch and render the destination page, since it parses the URL and its components, and keep remote lookups off unless you have decided that sending the URL to VirusTotal, Bitly and Google Knowledge Graph is acceptable. Before you deploy it, read the ini and the environment variable precedence in the README, because UNFURL_REMOTE_LOOKUPS overrides both config files and the default is false.
Frequently asked questions
How do I install and run Unfurl locally?
Unfurl requires Python 3.11 or newer. Install it with pip install dfir-unfurl[all], then run unfurl_app for the local web interface on localhost:5000, or run unfurl with a URL for command line output. Docker is also supported with git clone, cd unfurl and docker compose up -d.
What does Unfurl do with a URL?
It breaks the URL into components, extracts as much information as it can from each piece and presents the result as a directed graph. Parsers exist for URLs, search engines, chat applications, social media sites and more, plus generic parsers for things like timestamps and UUIDs.
When does Unfurl send data to third party services?
Only when remote lookups are enabled, which is false by default in unfurl.ini. Enabling it allows data from the analysed URL to be sent to external destinations including VirusTotal, Bitly and Google Knowledge Graph, and the API keys are only used when it is on. The -l flag enables it for a single run.
How do I set the API keys without committing them?
Put them in unfurl.local.ini next to unfurl.ini, which is gitignored and overrides the tracked template, or pass them as environment variables such as UNFURL_VIRUSTOTAL_API_KEY. The local ini file is not mounted into the container and .dockerignore keeps it out of the image, so keys are never baked into an image layer.
What output formats and command line options does Unfurl support?
The type option chooses between tree and json output, the output option saves results as CSV, the filter option keeps only matching lines, the detailed option shows more explanations, and the lookups option enables remote lookups for one run. The licence is Apache-2.0 and the last push was on 2026-10-15.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/ryandfir-unfurl)Was this useful?