capdrift 0.1.1 copy "capdrift: ^0.1.1" to clipboard
capdrift: ^0.1.1 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.

pub package pub points CI license pub likes downloads recall never executes dependencies last commit issues good first issues PRs welcome Buy Me A Coffee


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.

If any dependency cannot be fetched, a git, path or custom-registry entry, audit never returns ALLOW. It names what it could not read and returns REVIEW, or INCOMPLETE under incomplete_analysis: fail. A pass over a tree that was only partly read is a pass that means nothing.

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. audit resolves your tree by running dart pub get --no-precompile in a temporary directory with a temporary PUB_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.

License #

MIT © Tisankan Jeyakumar

0
likes
0
points
92
downloads

Publisher

verified publishertisankan.dev

Weekly Downloads

Show what a Dart package can do, what changed between versions, and whether that change violates your policy.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

analyzer, archive, args, crypto, http, path, pub_semver, yaml

More

Packages that depend on capdrift