capdrift 0.1.0
capdrift: ^0.1.0 copied to clipboard
Show what a Dart package can do, what changed between versions, and whether that change violates your policy.
capdrift #
We do not decide whether a package is malicious. We show what it can do, what changed, and whether that change violates your policy.
The problem nothing else solves #
A package was safe at 1.2.0. At 1.2.1 its build hook runs curl.
Every check in the Dart ecosystem looks at one version in isolation, so that
change passes all of them. dart pub get prints a version number. A scanner
prints a risk score for the new version and has nothing to compare it against.
capdrift compares.
capdrift diff dio 5.7.0 5.11.0
dio 5.7.0 -> 5.11.0
Gained
test/ dio.get example.com
test/ dio.get google.com
test/ dio.get 10.0.0.0
ALLOW (exit 0)
Three network calls appeared, all of them in test code that no consumer ever
runs, so the exit code is 0 and your build does not break. Had one of them
appeared in hook/, it would have been the first line on screen.
Install #
# Dart 3.10 and newer
dart install capdrift
# Earlier SDKs
dart pub global activate capdrift
The SDK constraint stays at ^3.6.0. Raising it purely for an install
convenience would exclude users for no functional gain.
Or as a dev dependency:
dev_dependencies:
capdrift: ^0.1.0
The five questions #
| Question | Command |
|---|---|
| Did this version change what it can do? | capdrift diff <pkg> <from> <to> |
| What can this package actually do? | capdrift inspect <pkg> |
| Is this package real, or did an AI invent it? | capdrift verify <pkg> |
| What can my whole dependency tree do? | capdrift audit |
| Did anything drift since I last approved it? | capdrift audit --baseline |
diff #
The reason this package exists.
capdrift diff http 1.5.0 1.6.0
http 1.5.0 -> 1.6.0
No capability change.
Four minor versions apart, and the answer is one line. That is deliberate:
capability identity is independent of file position, so running dart format
over a package produces an empty diff while changing Process.run('cmake') to
Process.run('curl') produces a real one.
inspect #
capdrift inspect http
http 1.6.0
lib/
File
lib/src/multipart_file_io.dart:16
CF1:FS:LIB:File:*
HttpClient
lib/src/io_client.dart:101
CF1:NETWORK:LIB:HttpClient:*
test/
HttpServer.bind
test/stub_server.dart:14
CF1:NETWORK:TEST:HttpServer.bind:*
ALLOW (exit 0)
Every finding carries its file, its line, and its fingerprint. You can check any of them in seconds.
audit #
What your whole dependency tree can do, including the packages you never chose.
capdrift audit
Dependency capability audit
12 packages (3 direct, 9 transitive)
What your dependencies can do
Filesystem access (6)
term_glyph@1.2.2 (transitive)
http@1.6.0 (direct)
Network access (1)
http@1.6.0 (direct)
ALLOW (exit 0)
Transitive packages are listed first on purpose. A capability you chose is one you already thought about; a capability you inherited is the one worth reading.
audit --include-dev #
Dev dependencies never reach your users, so audit leaves them out by default.
They do run on your machine and in CI, with your credentials in the
environment, which makes a build tool with a hostile hook/build.dart a better
target than a runtime library.
capdrift audit --include-dev
On a project whose only dev dependency is test:
47 packages (2 direct, 45 transitive)
Including dev dependencies (46 dev-only)
Filesystem access (158)
analyzer@14.1.0 (dev only)
coverage@1.15.1 (dev only)
One runtime dependency, forty six that arrived because of test. Every finding
is labelled dev only, so a reader can tell at a glance which ones reach
production.
Attribution is exact rather than guessed. The lockfile marks a direct dev
dependency but tags every transitive simply as transitive, without saying
which root pulled it in, so capdrift resolves twice and takes the difference.
Labelling one package dev-only and the other forty five as ordinary transitives
would tell you something false.
audit --baseline #
Approve the current state once, then let CI report only what drifted.
capdrift audit --approve # writes capdrift-baseline.yaml, commit it
capdrift audit --baseline # in CI, from then on
Adding archive: ^4.0.0 to a project produces this:
Drift from the approved baseline:
archive@4.1.0 NEW, never reviewed
+ CF1:FS:LIB:Directory:*
+ CF1:FS:LIB:File:*
ffi@2.2.0 NEW, never reviewed
+ CF1:NATIVE:LIB:DynamicLibrary.open:ole32.dll
Review the changes, then run: capdrift audit --approve
REVIEW (exit 1)
You asked for an archive library. You also got native library loading, through a transitive dependency you never named. That is the report doing its job.
Drift on test and example surfaces is shown but exits 0. A dependency that bumps a patch version and adds a test has genuinely drifted, and saying so is honest, but failing the build for it trains a team to re-approve without reading, which is worse than never running the check.
--approve refuses to write a baseline from an incomplete audit. Approving a
tree we could not fully read would bake an unknown into the file every later run
is measured against.
verify #
Assistants invent package names. An attacker who registers the invented name owns everyone who accepts the suggestion.
capdrift verify htttp
htttp does not exist on pub.dev.
It is 1 edit from "http", which is widely used.
You probably want http.
It also catches the harder case, a name that does exist:
capdrift verify diox
diox exists on pub.dev.
latest 5.0.0
publisher flutter.cn
likes 77
downloads/30d 87
This name is 1 edit from "dio".
It has 87 downloads in 30 days, under the 10000 we treat as established.
A near-miss name with little adoption is the shape a squatted package has. It
is not proof of one: legitimate forks and abandoned packages look the same from
here. Check the repository and the publisher before installing.
REVIEW (exit 1)
diox is a legitimate flutter.cn fork, and capdrift says so carefully. It
reports the shape and refuses to call it malicious, because from the outside a
fork, an abandoned package and a squat are indistinguishable.
Four checks, in order: does the name exist; is it within a length-scaled edit distance of a popular package; does it have adoption of its own; and how old is it relative to that adoption. Similarity alone never warns. Real packages have similar names, and flagging every one of them is how a tool teaches people to ignore it.
The edit-distance threshold scales with length, and that is the whole
calibration. At three characters two edits is a different word, not a slip; at
eighteen it is a plausible mistype. A fixed threshold of two flagged znv as a
typo of ajv in an earlier tool of ours, and there is a test pinning that case.
Execution surface: why the same call means different things #
Process.run in a build hook executes on your machine during flutter build,
without anything calling it. The same line in test/ runs only if someone runs
that package's own tests. A tool that reports both identically teaches you to
ignore it.
| Surface | Weight | Why |
|---|---|---|
hook/ |
Critical | Executes at build time, unprompted, with your full privileges |
lib/ |
High | Runs whenever your application runs |
bin/ |
High | Runs when you invoke the package's executable |
native/ |
Critical | Ships native code we cannot read, merged into your app |
tool/ |
Medium | Maintainer tooling, runs only when explicitly invoked |
example/ |
Low | Demonstration code, not linked into consumers |
test/ |
Low | Runs only under the package's own test command |
native/ covers a plugin's own android/ and ios/ directories. A permission
declared there is merged into every app that depends on the plugin, and the
Kotlin or Swift beside it is invisible to static Dart analysis. Unanalysable and
consumer-facing is the worst combination in the model, so it ranks above lib/.
An example/android/ manifest is the demo app's, not yours, and classifies as
example.
Measured across 15 of the most-used packages on pub.dev: 185 total findings, 12 of them consumer facing. dio alone produces 141 findings, three of which reach a consumer. Eight of the fifteen report nothing at all.
That gap is the entire argument for the model.
Capability fingerprints #
Every capability gets an identity that survives reformatting:
CF1:PROCESS:HOOK:Process.run:cmake
CF1:NETWORK:LIB:HttpClient.get:api.example.com
CF1:ENV:LIB:Platform.environment:aws_secret_access_key
CF1 is the format version. Then the family, the execution surface, the exact
API, and the literal argument when one is statically known.
Move a call from line 12 to line 400 and the fingerprint does not change. Change
cmake to curl and it does. This is what makes diffing and baselines possible
rather than heuristic.
Policy #
capdrift.yaml at your repository root. Rules are scoped by execution surface.
version: 1
policy:
# Build-time execution is the highest-value attack surface.
- deny: process_execution
surface: [hook]
reason: "No subprocess execution in build hooks."
# The same capability is fine in maintainer tooling.
- allow: process_execution
surface: [tool, test]
- deny: environment_read
matching: ["*SECRET*", "*TOKEN*", "AWS_*"]
reason: "No credential reads from dependencies."
- review: network
surface: [lib]
reason: "Network access in library code needs sign-off."
# Named exception, with a reason and an owner, never a silent skip.
- allow: process_execution
surface: [hook]
package: flutter_native_splash
reason: "Documented image generation. Approved 2026-08-22 by tisankan."
incomplete_analysis: warn # or: fail
Every rule needs a reason. A policy file full of unexplained allows is a policy
file nobody can review, so the format makes the lazy path harder than the honest
one.
CI #
- name: Check dependency capabilities
run: dart pub global run capdrift audit --baseline
Four exit codes, because two cannot express the difference between "this is bad" and "I could not tell":
| Code | Meaning |
|---|---|
| 0 | ALLOW. No policy violation |
| 1 | REVIEW. Findings that need a human |
| 2 | BLOCK. A policy rule was violated |
| 3 | INCOMPLETE. Analysis did not finish |
INCOMPLETE defaults to warning rather than failing, because a security gate
that breaks your build on a transient network error is a security gate you
delete that same afternoon. Set incomplete_analysis: fail if you need the
strict reading.
What capdrift does not do #
- It never rewrites your lockfile.
auditresolves your tree by runningdart pub get --no-precompilein a temporary directory with a temporaryPUB_CACHE, then deletes both. A tool that changes your lockfile while auditing it causes the incident it was meant to prevent. - It never executes the package. Not the library, not the hooks, not the binaries, not the build scripts, not the tests, not the examples. Analysis is static from first byte to last, and a test in the suite proves it: a fixture that writes a marker file when run is analysed on every CI run, and the marker never appears.
- It does not tell you a package is malicious. It has no way to know intent. It tells you what the code can do and what changed.
- It sends nothing anywhere. No telemetry, ever. All network access is unauthenticated reads from pub.dev, through a single file you can audit.
Precision and recall #
A security tool fails in two directions and most only test one.
| Corpus | What it measures | Result |
|---|---|---|
| A: 15 popular packages | False positives | 185 findings, 12 consumer facing, 8 packages clean |
| B: synthetic malicious fixtures | False negatives | Recall 8/8 |
| C: real version pairs | Diff correctness | Stable, no phantom changes |
Corpus B is the one that matters. Each fixture is labelled with the exact fingerprints it must produce, so a detector that goes blind fails the build instead of silently passing everything it is shown. Every new detector needs a corpus B fixture before it can be merged.
All three run in CI on every push.
How this compares to dart_audit #
dart_audit is the closest tool and it
is good. It already does static Dart source scanning, hardcoded URL and socket
detection, Process.run detection, sensitive path detection, Shannon entropy
analysis, a 0-100 risk score, JSON output and a CI exit code. If you want a
single-version risk assessment, it does that well.
Three things capdrift adds:
| dart_audit | capdrift | |
|---|---|---|
| Single-version risk scan | Yes | Yes |
Build hook (hook/) analysis |
No | Yes |
| Stable capability fingerprints | No | Yes |
| Version-to-version diff | No | Yes |
| Execution surface weighting | No | Yes |
The difference in one sentence: dart_audit answers "is this package risky",
capdrift answers "did this package become riskier".
Contributing #
See CONTRIBUTING.md. Any new detector needs a corpus B fixture with its expected fingerprints. A detector without a recall fixture cannot be merged.
Security #
See SECURITY.md. Report vulnerabilities privately rather than in a public issue.
Contact #
Tisankan Jeyakumar, Chief Technical Officer, Yarl Ventures (PVT) Ltd.
- Web: tisankan.dev
- Email: hello@tisankan.dev
- GitHub: @rascal-sl
License #
MIT © Tisankan Jeyakumar