capdrift 0.2.0
capdrift: ^0.2.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.
Path dependencies are read from disk and marked local path in the report,
because a reader deciding how much to trust a finding wants to know it came
from a directory beside their own code rather than a published archive.
Internal packages are also the ones no registry ever reviewed.
If any dependency cannot be read, a git or SDK 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. 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.
Precisely: direct dev dependencies are excluded by default. Their
transitives are still analysed, because a lockfile labels a package
transitive without recording which root introduced it. So a project whose
only dev dependency is test still has analyzer and the rest of that subtree
analysed.
That errs toward analysing more than promised, which is the safe direction.
--include-dev adds the direct dev dependencies as well.
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 comes from the dependency graph. A lockfile marks a direct dev
dependency but tags every transitive simply as transitive, without saying
which root pulled it in, so capdrift walks the edges from each package's own
pubspec, which it already has from the archives it downloaded. Anything not
reachable from a runtime dependency arrived through a dev one.
A package reachable both ways counts as runtime, because that is what ships. Being wrong in the other direction, telling you something is dev-only when it reaches production, is the dangerous one.
audit caches downloaded archives, so a repeated run over the same tree does
no network work at all. Measured on a 13-package tree: 1451ms cold, 113ms
warm. That is the difference between running it occasionally and running it on
every build.
The cache is keyed on package and version, which is immutable on pub.dev, and
cached bytes are verified against the lockfile hash where one is pinned.
--no-cache bypasses it; --clear-cache empties it.
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: 195 total findings, 19 of them consumer facing. dio alone produces 141 findings, three of which reach a consumer.
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.
Policy applies to inspect, audit and diff. On diff it blocks only on
violations the upgrade introduces: a capability that already broke your
policy in the old version is not news, and reporting it would make every diff of
an already-non-compliant package look like a regression. Use inspect or
audit to find standing violations.
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.
GitHub Action #
- uses: Tisankan-dev/capdrift@v1
with:
command: audit
args: --baseline
That is the whole adoption path. The action installs a pinned capdrift, runs
it, writes the findings to the job summary so they are readable without opening
the log, and fails the job according to fail-on.
| Input | Default | |
|---|---|---|
command |
audit |
audit, diff, inspect or verify |
args |
Everything after the command | |
fail-on |
block |
allow, review, block or incomplete |
version |
pinned | So a new release cannot change a build's result |
format |
text |
text, json or sarif |
install-from |
Path to a checkout, to try a fork or an unreleased fix | |
output-file |
Write the report to a file, required for SARIF upload |
fail-on: block is the default because a policy violation should fail a build
while a finding that only needs a human read should not. Set fail-on: review
if you want any capability change to stop the line.
Outputs decision, exit-code and output for downstream steps.
GitHub code scanning #
capdrift --format sarif --output capdrift.sarif inspect <package>
Use --output rather than a shell redirect. Anything wrapping the process is
free to print progress to stdout, and that lands in front of the document and
makes it unparseable. --output writes the report and nothing else.
- uses: Tisankan-dev/capdrift@v1
with:
command: inspect
args: some_package
format: sarif
output-file: capdrift.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: capdrift.sarif
Findings then appear in the repository's Security tab and annotated on the pull request diff, beside CodeQL results, instead of only in a CI log.
Execution surface maps to severity: hook and native are errors, lib and
bin are warnings, everything else is a note. A policy violation is an error
whatever the surface, because a rule the project wrote for itself deserves the
annotation.
The CF1 fingerprint is used as SARIF's partialFingerprints, so GitHub
recognises a finding across runs where line numbers moved. That is the property
CF1 was designed for, so the deduplication works rather than approximately
works.
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 | 195 findings, 19 consumer facing |
| B: synthetic malicious fixtures | False negatives | Recall 11/11 |
| 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 #
dart run tool/check.dart # format, analyze, tests, publish dry run
dart run tool/check.dart --network # plus the live pub.dev suite
One command, one exit code. 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